Skip to content

Commit b0053bc

Browse files
Copilotkotlarmilos
andcommitted
Add complete localization infrastructure for XHarness CLI
Co-authored-by: kotlarmilos <11523312+kotlarmilos@users.noreply.github.com>
1 parent 5da42bd commit b0053bc

28 files changed

+4527
-60
lines changed

docs/localization.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# XHarness Localization Guidelines
2+
3+
XHarness supports localization of user-facing messages through .NET resource files. This enables translation of the CLI into different languages.
4+
5+
## Current Implementation
6+
7+
The XHarness CLI now includes a localization infrastructure that centralizes user-facing strings in resource files.
8+
9+
### What is Localized
10+
11+
- Command descriptions and help text
12+
- Argument descriptions
13+
- Error messages and validation messages
14+
- Log messages visible to users
15+
- Help system messages
16+
17+
### What is NOT Localized
18+
19+
The following items are intentionally excluded from localization to maintain consistency and functionality:
20+
21+
- Command names and option switches (`apple`, `test`, `--app`, `-v`, etc.)
22+
- Exit code identifiers (`HELP_SHOWN`, `INVALID_ARGUMENTS`, etc.)
23+
- Bundle identifiers and file paths
24+
- Raw device or system logs copied without modification
25+
- Technical identifiers and internal debugging output
26+
27+
## Resource Files
28+
29+
The main resource file is located at:
30+
```
31+
src/Microsoft.DotNet.XHarness.CLI/Resources/Strings.resx
32+
```
33+
34+
This file contains all the localizable strings organized by category:
35+
- `Apple_*` - Apple platform command descriptions
36+
- `Android_*` - Android platform command descriptions
37+
- `Wasm_*` - WASM platform command descriptions
38+
- `Error_*` - Error message templates
39+
- `Help_*` - Help system messages
40+
- `Arg_*` - Argument descriptions
41+
- `Log_*` - User-visible log messages
42+
43+
## Resource Naming Conventions
44+
45+
Resource keys follow a consistent naming pattern:
46+
47+
1. **Prefix by category**: `Apple_`, `Android_`, `Error_`, `Help_`, `Arg_`, `Log_`
48+
2. **Use descriptive names**: `Apple_Test_Description`, `Error_UnknownArguments`
49+
3. **No spaces**: Use underscores to separate words
50+
4. **Clear context**: Include enough context to understand the usage
51+
52+
Examples:
53+
- `Apple_Test_Description` - Description for the apple test command
54+
- `Error_RequiredArgumentMissing` - Error when a required argument is missing
55+
- `Arg_Target_Description` - Description for the target argument
56+
57+
## Using Localized Strings
58+
59+
### In Command Classes
60+
61+
```csharp
62+
using Microsoft.DotNet.XHarness.CLI.Resources;
63+
64+
internal class AppleTestCommand : AppleAppCommand<AppleTestCommandArguments>
65+
{
66+
protected override string CommandUsage { get; } = Strings.Apple_Test_Usage;
67+
protected override string CommandDescription { get; } = Strings.Apple_Test_Description;
68+
69+
public AppleTestCommand(IServiceCollection services) : base("test", false, services, Strings.Apple_Test_Description)
70+
{
71+
}
72+
}
73+
```
74+
75+
### In Argument Classes
76+
77+
```csharp
78+
using Microsoft.DotNet.XHarness.CLI.Resources;
79+
80+
internal class AppPathArgument : RequiredPathArgument
81+
{
82+
public AppPathArgument() : base("app|a=", Strings.Arg_AppPath_Description)
83+
{
84+
}
85+
}
86+
```
87+
88+
### In Error Messages
89+
90+
```csharp
91+
throw new ArgumentException(string.Format(Strings.Error_UnknownArguments, string.Join(" ", extraArgs)));
92+
```
93+
94+
## Adding New Localizable Strings
95+
96+
1. **Add to Strings.resx**: Edit the resource file and add new entries following naming conventions
97+
2. **Regenerate Designer class**: The Strings.Designer.cs file should be regenerated automatically, or manually regenerate it
98+
3. **Use in code**: Reference the new string using `Strings.YourNewKey`
99+
4. **Test**: Verify the string appears correctly in the CLI output
100+
101+
## Future Translations
102+
103+
To add support for additional languages:
104+
105+
1. **Create language-specific resource files**:
106+
- `Strings.es.resx` for Spanish
107+
- `Strings.fr.resx` for French
108+
- `Strings.de.resx` for German
109+
- etc.
110+
111+
2. **Translate strings**: Copy all entries from Strings.resx and translate the values
112+
113+
3. **Test with different cultures**: Set the current UI culture and test the CLI
114+
115+
## Testing Localization
116+
117+
The project includes tests to verify localization functionality:
118+
119+
```csharp
120+
// Verify resources can be loaded
121+
Assert.NotNull(Strings.Apple_Test_Description);
122+
Assert.NotEmpty(Strings.Apple_Test_Description);
123+
124+
// Verify error message templates have placeholders
125+
Assert.Contains("{0}", Strings.Error_UnknownArguments);
126+
```
127+
128+
## Best Practices
129+
130+
1. **Keep strings meaningful**: Avoid cryptic abbreviations
131+
2. **Include context**: Make sure translators can understand the usage
132+
3. **Use placeholders correctly**: Follow .NET string formatting conventions (`{0}`, `{1}`, etc.)
133+
4. **Test with longer translations**: Some languages require more space
134+
5. **Avoid cultural assumptions**: Keep messages neutral and professional
135+
6. **Group related strings**: Use consistent prefixes to organize resources
136+
137+
## Localization Architecture
138+
139+
The localization system is built on standard .NET resource management:
140+
141+
- **ResourceManager**: Handles loading resources from assemblies
142+
- **CultureInfo**: Determines which language to use
143+
- **Satellite assemblies**: Would contain translated resources (future)
144+
- **Fallback mechanism**: Falls back to default English if translation missing
145+
146+
This infrastructure allows XHarness to be easily translated into any language supported by .NET while maintaining full functionality and performance.

src/Microsoft.DotNet.XHarness.CLI/CommandArguments/Apple/Arguments/TargetArgument.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
// Licensed to the .NET Foundation under one or more agreements.
1+
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

55
using System;
6+
using Microsoft.DotNet.XHarness.CLI.Resources;
67
using Microsoft.DotNet.XHarness.iOS.Shared;
78
using Microsoft.DotNet.XHarness.iOS.Shared.Utilities;
89

@@ -13,7 +14,7 @@ namespace Microsoft.DotNet.XHarness.CLI.CommandArguments.Apple;
1314
/// </summary>
1415
internal class TargetArgument : Argument<TestTargetOs>
1516
{
16-
public TargetArgument() : base("target=|targets=|t=", "Test target (device/simulator and OS)", TestTargetOs.None)
17+
public TargetArgument() : base("target=|targets=|t=", Strings.Arg_Target_Description, TestTargetOs.None)
1718
{
1819
}
1920

src/Microsoft.DotNet.XHarness.CLI/CommandArguments/Argument.cs

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Collections.Generic;
77
using System.IO;
88
using System.Linq;
9+
using Microsoft.DotNet.XHarness.CLI.Resources;
910
using OpenQA.Selenium;
1011

1112
namespace Microsoft.DotNet.XHarness.CLI.CommandArguments;
@@ -80,15 +81,13 @@ protected static TEnum ParseArgument<TEnum>(string argumentName, string? value,
8081
{
8182
if (value == null)
8283
{
83-
throw new ArgumentNullException(message: $"Empty value supplied to {argumentName}", null);
84+
throw new ArgumentNullException(message: string.Format(Strings.Error_EmptyValue, argumentName), null);
8485
}
8586

8687
if (value.All(c => char.IsDigit(c)))
8788
{
8889
// Any int would parse into enum successfully, so we forbid that
89-
throw new ArgumentException(
90-
$"Invalid value '{value}' supplied for {argumentName}. " +
91-
$"Valid values are:" + GetAllowedValues(invalidValues: invalidValues));
90+
throw new ArgumentException(string.Format(Strings.Error_InvalidValue, value, argumentName, GetAllowedValues(invalidValues: invalidValues)));
9291
}
9392

9493
var type = typeof(TEnum);
@@ -115,9 +114,7 @@ protected static TEnum ParseArgument<TEnum>(string argumentName, string? value,
115114
validOptions = validOptions.Where(v => !invalidValues.Contains(v));
116115
}
117116

118-
throw new ArgumentException(
119-
$"Invalid value '{value}' supplied in {argumentName}. " +
120-
$"Valid values are:" + GetAllowedValues(invalidValues: invalidValues));
117+
throw new ArgumentException(string.Format(Strings.Error_InvalidValue, value, argumentName, GetAllowedValues(invalidValues: invalidValues)));
121118
}
122119
}
123120

@@ -151,7 +148,7 @@ public override void Action(string argumentValue)
151148
return;
152149
}
153150

154-
throw new ArgumentException($"{Prototype} must be an integer");
151+
throw new ArgumentException(string.Format(Strings.Error_MustBeInteger, Prototype));
155152
}
156153
}
157154

@@ -170,7 +167,7 @@ public override void Action(string argumentValue)
170167
return;
171168
}
172169

173-
throw new ArgumentException($"{Prototype} must be an integer");
170+
throw new ArgumentException(string.Format(Strings.Error_MustBeInteger, Prototype));
174171
}
175172
}
176173

@@ -197,7 +194,7 @@ public override void Validate()
197194
{
198195
if (string.IsNullOrEmpty(Value))
199196
{
200-
throw new ArgumentException($"Required argument {Prototype} was not supplied");
197+
throw new ArgumentException(string.Format(Strings.Error_RequiredArgumentMissing, Prototype));
201198
}
202199
}
203200
}
@@ -223,7 +220,7 @@ public override void Action(string argumentValue)
223220
return;
224221
}
225222

226-
throw new ArgumentException($"{Prototype} must be an integer - a number of seconds, or a timespan (00:30:00)");
223+
throw new ArgumentException(string.Format(Strings.Error_MustBeTimespan, Prototype));
227224
}
228225
}
229226

@@ -242,7 +239,7 @@ public override void Validate()
242239
{
243240
if (_isRequired && string.IsNullOrEmpty(Value))
244241
{
245-
throw new ArgumentException($"Required argument {Prototype} was not supplied");
242+
throw new ArgumentException(string.Format(Strings.Error_RequiredArgumentMissing, Prototype));
246243
}
247244
}
248245
}
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
// Licensed to the .NET Foundation under one or more agreements.
1+
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5+
using Microsoft.DotNet.XHarness.CLI.Resources;
6+
57
namespace Microsoft.DotNet.XHarness.CLI.CommandArguments;
68

79
/// <summary>
810
/// Path to the app bundle.
911
/// </summary>
1012
internal class AppPathArgument : RequiredPathArgument
1113
{
12-
public AppPathArgument() : base("app|a=", "Path to an already-packaged app")
14+
public AppPathArgument() : base("app|a=", Strings.Arg_AppPath_Description)
1315
{
1416
}
1517
}
@@ -19,7 +21,7 @@ public AppPathArgument() : base("app|a=", "Path to an already-packaged app")
1921
/// </summary>
2022
internal class OptionalAppPathArgument : PathArgument
2123
{
22-
public OptionalAppPathArgument() : base("app|a=", "Path to an already-packaged app", false)
24+
public OptionalAppPathArgument() : base("app|a=", Strings.Arg_AppPath_Description, false)
2325
{
2426
}
2527
}

src/Microsoft.DotNet.XHarness.CLI/CommandArguments/Arguments/OutputDirectoryArgument.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
// Licensed to the .NET Foundation under one or more agreements.
1+
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

55
using System;
66
using System.IO;
7+
using Microsoft.DotNet.XHarness.CLI.Resources;
78

89
namespace Microsoft.DotNet.XHarness.CLI.CommandArguments;
910

@@ -12,7 +13,7 @@ namespace Microsoft.DotNet.XHarness.CLI.CommandArguments;
1213
/// </summary>
1314
internal class OutputDirectoryArgument : RequiredPathArgument
1415
{
15-
public OutputDirectoryArgument() : base("output-directory=|o=", "Directory where logs and results will be saved")
16+
public OutputDirectoryArgument() : base("output-directory=|o=", Strings.Arg_OutputDirectory_Description)
1617
{
1718
}
1819

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
// Licensed to the .NET Foundation under one or more agreements.
1+
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5+
using Microsoft.DotNet.XHarness.CLI.Resources;
6+
57
namespace Microsoft.DotNet.XHarness.CLI.CommandArguments;
68

79
public class HelpArgument : SwitchArgument
810
{
9-
public HelpArgument() : base("help|h", string.Empty, false)
11+
public HelpArgument() : base("help|h", Strings.Arg_Help_Description, false)
1012
{
1113
}
1214
}

src/Microsoft.DotNet.XHarness.CLI/CommandArguments/VerbosityArgument.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
// Licensed to the .NET Foundation under one or more agreements.
1+
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5+
using Microsoft.DotNet.XHarness.CLI.Resources;
56
using Microsoft.Extensions.Logging;
67

78
namespace Microsoft.DotNet.XHarness.CLI.CommandArguments;
@@ -11,7 +12,7 @@ public class VerbosityArgument : Argument
1112
public LogLevel Value { get; private set; } = LogLevel.Information;
1213

1314
public VerbosityArgument(LogLevel level)
14-
: base("verbosity:|v:", "Verbosity level - defaults to 'Information' if not specified. If passed without value, 'Debug' is assumed (highest)")
15+
: base("verbosity:|v:", Strings.Arg_Verbosity_Description)
1516
{
1617
Value = level;
1718
}

src/Microsoft.DotNet.XHarness.CLI/Commands/Apple/AppleTestCommand.cs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Threading.Tasks;
77
using Microsoft.DotNet.XHarness.Apple;
88
using Microsoft.DotNet.XHarness.CLI.CommandArguments.Apple;
9+
using Microsoft.DotNet.XHarness.CLI.Resources;
910
using Microsoft.DotNet.XHarness.Common.CLI;
1011
using Microsoft.Extensions.DependencyInjection;
1112

@@ -16,14 +17,11 @@ namespace Microsoft.DotNet.XHarness.CLI.Commands.Apple;
1617
/// </summary>
1718
internal class AppleTestCommand : AppleAppCommand<AppleTestCommandArguments>
1819
{
19-
private const string CommandHelp = "Installs, runs and uninstalls a given iOS/tvOS/watchOS/xrOS/MacCatalyst test application bundle containing TestRunner " +
20-
"in a target device/simulator.";
21-
22-
protected override string CommandUsage { get; } = "apple test --app=... --output-directory=... --target=... [OPTIONS] [-- [RUNTIME ARGUMENTS]]";
23-
protected override string CommandDescription { get; } = CommandHelp;
20+
protected override string CommandUsage { get; } = Strings.Apple_Test_Usage;
21+
protected override string CommandDescription { get; } = Strings.Apple_Test_Description;
2422
protected override AppleTestCommandArguments Arguments { get; } = new();
2523

26-
public AppleTestCommand(IServiceCollection services) : base("test", false, services, CommandHelp)
24+
public AppleTestCommand(IServiceCollection services) : base("test", false, services, Strings.Apple_Test_Description)
2725
{
2826
}
2927

src/Microsoft.DotNet.XHarness.CLI/Commands/XHarnessCommand.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Linq;
88
using System.Threading.Tasks;
99
using Microsoft.DotNet.XHarness.CLI.CommandArguments;
10+
using Microsoft.DotNet.XHarness.CLI.Resources;
1011
using Microsoft.DotNet.XHarness.Common;
1112
using Microsoft.DotNet.XHarness.Common.CLI;
1213
using Microsoft.Extensions.DependencyInjection;
@@ -100,13 +101,13 @@ public sealed override int Invoke(IEnumerable<string> arguments)
100101
}
101102
else
102103
{
103-
throw new ArgumentException($"Unknown arguments: {string.Join(" ", extra)}");
104+
throw new ArgumentException(string.Format(Strings.Error_UnknownArguments, string.Join(" ", extra)));
104105
}
105106
}
106107

107108
if (Arguments.ShowHelp)
108109
{
109-
Console.WriteLine("usage: " + CommandUsage + Environment.NewLine + Environment.NewLine + CommandDescription + Environment.NewLine);
110+
Console.WriteLine(string.Format(Strings.Help_Usage, CommandUsage) + Environment.NewLine + Environment.NewLine + CommandDescription + Environment.NewLine);
110111
options.WriteOptionDescriptions(Console.Out);
111112
return (int)ExitCode.HELP_SHOWN;
112113
}
@@ -126,7 +127,7 @@ public sealed override int Invoke(IEnumerable<string> arguments)
126127
}
127128
catch (Exception e)
128129
{
129-
parseLogger.LogCritical("Unexpected failure argument: {error}", e);
130+
parseLogger.LogCritical(Strings.Error_UnexpectedFailure, e);
130131
return (int)ExitCode.GENERAL_FAILURE;
131132
}
132133

0 commit comments

Comments
 (0)