|
| 1 | +import 'dart:convert'; |
| 2 | +import 'dart:io'; |
| 3 | + |
| 4 | +import 'package:flutter_test/flutter_test.dart'; |
| 5 | +import 'package:path/path.dart' as p; |
| 6 | + |
| 7 | +/// End-to-end tests for `tools/arb.dart`. |
| 8 | +/// |
| 9 | +/// The tool is deliberately not parameterised: it hardcodes the `localization/` |
| 10 | +/// directory relative to the current working directory. So rather than import |
| 11 | +/// it, each test spins up a throwaway fixture dir with a `localization/` folder |
| 12 | +/// and runs the real script as a subprocess with that dir as CWD — exercising |
| 13 | +/// exactly the binary a developer/agent invokes. The whole value of the tool is |
| 14 | +/// "never corrupt these files", so the load-bearing assertion throughout is the |
| 15 | +/// byte-for-byte comparison of untouched content. |
| 16 | +void main() { |
| 17 | + // Resolve the script once. The test file lives at test/tools/, the script at |
| 18 | + // tools/arb.dart, both under the repo root. |
| 19 | + final repoRoot = _findRepoRoot(); |
| 20 | + final script = p.join(repoRoot, 'tools', 'arb.dart'); |
| 21 | + // Under `flutter test`, Platform.resolvedExecutable is the flutter_tester, |
| 22 | + // not a Dart VM — spawning the script against it would hang. Resolve a real |
| 23 | + // Dart binary from the SDK that ships beside it instead. |
| 24 | + final dart = _resolveDart(); |
| 25 | + |
| 26 | + late Directory tmp; |
| 27 | + |
| 28 | + setUp(() { |
| 29 | + tmp = Directory.systemTemp.createTempSync('arb_tool_test_'); |
| 30 | + Directory(p.join(tmp.path, 'localization')).createSync(); |
| 31 | + }); |
| 32 | + |
| 33 | + tearDown(() => tmp.deleteSync(recursive: true)); |
| 34 | + |
| 35 | + /// Writes a locale file verbatim and returns its path. |
| 36 | + String writeLocale(String locale, String content) { |
| 37 | + final path = p.join(tmp.path, 'localization', 'app_$locale.arb'); |
| 38 | + File(path).writeAsStringSync(content); |
| 39 | + return path; |
| 40 | + } |
| 41 | + |
| 42 | + String readLocale(String locale) => File( |
| 43 | + p.join(tmp.path, 'localization', 'app_$locale.arb'), |
| 44 | + ).readAsStringSync(); |
| 45 | + |
| 46 | + /// Runs the tool with [args] in the fixture dir. Uses the Dart VM directly |
| 47 | + /// (no `dart run`) so there are no per-call package build hooks — fast and |
| 48 | + /// hermetic. |
| 49 | + ProcessResult run(List<String> args) => |
| 50 | + Process.runSync(dart, [script, ...args], workingDirectory: tmp.path); |
| 51 | + |
| 52 | + // A minimal but representative template: a translated key with metadata, a |
| 53 | + // plain key, and a key carrying placeholders — mirroring real .arb shape. |
| 54 | + const enTemplate = |
| 55 | + '{\n' |
| 56 | + ' "@@locale": "en",\n' |
| 57 | + ' "greeting": "Hello",\n' |
| 58 | + ' "@greeting": {\n' |
| 59 | + ' "description": "a greeting"\n' |
| 60 | + ' },\n' |
| 61 | + ' "count": "{n, plural, =1{1 item} other{{n} items}}",\n' |
| 62 | + ' "@count": {\n' |
| 63 | + ' "placeholders": {\n' |
| 64 | + ' "n": {\n' |
| 65 | + ' "type": "int"\n' |
| 66 | + ' }\n' |
| 67 | + ' }\n' |
| 68 | + ' },\n' |
| 69 | + ' "farewell": "Bye"\n' |
| 70 | + '}\n'; |
| 71 | + |
| 72 | + const frTemplate = |
| 73 | + '{\n' |
| 74 | + ' "@@locale": "fr",\n' |
| 75 | + ' "greeting": "Bonjour"\n' |
| 76 | + '}\n'; |
| 77 | + |
| 78 | + group('validate', () { |
| 79 | + test('accepts well-formed files', () { |
| 80 | + writeLocale('en', enTemplate); |
| 81 | + writeLocale('fr', frTemplate); |
| 82 | + final r = run(['validate']); |
| 83 | + expect(r.exitCode, 0, reason: r.stderr.toString()); |
| 84 | + expect(r.stdout, contains('app_en.arb: ok')); |
| 85 | + }); |
| 86 | + |
| 87 | + test('rejects a file whose layout does not match the parsed JSON', () { |
| 88 | + // Valid JSON, but the nested "nested" field is indented at two spaces |
| 89 | + // instead of four — exactly the reflowed-metadata corruption the invariant |
| 90 | + // guards against. It reads as an extra top-level key line, so the line-key |
| 91 | + // count no longer matches the JSON key count and the tool must refuse. |
| 92 | + writeLocale( |
| 93 | + 'en', |
| 94 | + '{\n "@@locale": "en",\n "a": {\n "nested": "x"\n },\n "b": "2"\n}\n', |
| 95 | + ); |
| 96 | + final r = run(['validate']); |
| 97 | + expect(r.exitCode, 1); |
| 98 | + }); |
| 99 | + }); |
| 100 | + |
| 101 | + group('add', () { |
| 102 | + test('is byte-identical after a matching delete (round-trip)', () { |
| 103 | + writeLocale('en', enTemplate); |
| 104 | + writeLocale('fr', frTemplate); |
| 105 | + final enBefore = readLocale('en'); |
| 106 | + final frBefore = readLocale('fr'); |
| 107 | + |
| 108 | + final add = run([ |
| 109 | + 'add', |
| 110 | + 'newKey', |
| 111 | + '--translations', |
| 112 | + jsonEncode({'en': 'New', 'fr': 'Nouveau'}), |
| 113 | + '--description', |
| 114 | + 'brand new', |
| 115 | + ]); |
| 116 | + expect(add.exitCode, 0, reason: add.stderr.toString()); |
| 117 | + expect(readLocale('en'), isNot(enBefore)); |
| 118 | + expect(readLocale('fr'), isNot(frBefore)); |
| 119 | + |
| 120 | + final del = run(['delete', 'newKey']); |
| 121 | + expect(del.exitCode, 0, reason: del.stderr.toString()); |
| 122 | + expect(readLocale('en'), enBefore, reason: 'en not restored byte-exact'); |
| 123 | + expect(readLocale('fr'), frBefore, reason: 'fr not restored byte-exact'); |
| 124 | + }); |
| 125 | + |
| 126 | + test('produces valid JSON with the new value and metadata', () { |
| 127 | + writeLocale('en', enTemplate); |
| 128 | + run([ |
| 129 | + 'add', |
| 130 | + 'newKey', |
| 131 | + '--translations', |
| 132 | + jsonEncode({'en': 'New'}), |
| 133 | + '--description', |
| 134 | + 'brand new', |
| 135 | + ]); |
| 136 | + final map = jsonDecode(readLocale('en')) as Map<String, dynamic>; |
| 137 | + expect(map['newKey'], 'New'); |
| 138 | + expect((map['@newKey'] as Map)['description'], 'brand new'); |
| 139 | + // The previously-last key must have gained a comma without breaking JSON. |
| 140 | + expect(map['farewell'], 'Bye'); |
| 141 | + }); |
| 142 | + |
| 143 | + test('gives the prior last key a comma (no trailing-comma corruption)', () { |
| 144 | + writeLocale('en', enTemplate); |
| 145 | + run([ |
| 146 | + 'add', |
| 147 | + 'newKey', |
| 148 | + '--translations', |
| 149 | + jsonEncode({'en': 'New'}), |
| 150 | + ]); |
| 151 | + final lines = readLocale('en').split('\n'); |
| 152 | + final farewell = lines.firstWhere((l) => l.contains('"farewell"')); |
| 153 | + expect(farewell.trimRight().endsWith(','), isTrue); |
| 154 | + }); |
| 155 | + |
| 156 | + test('refuses to overwrite an existing key and writes nothing', () { |
| 157 | + writeLocale('en', enTemplate); |
| 158 | + final before = readLocale('en'); |
| 159 | + final r = run([ |
| 160 | + 'add', |
| 161 | + 'greeting', |
| 162 | + '--translations', |
| 163 | + jsonEncode({'en': 'x'}), |
| 164 | + ]); |
| 165 | + // Exit 1 (ArbException): a state conflict, distinct from the exit-64 |
| 166 | + // usage errors above (bad key, bad locale, bad placeholder shape). |
| 167 | + expect(r.exitCode, 1); |
| 168 | + expect(readLocale('en'), before); |
| 169 | + }); |
| 170 | + |
| 171 | + test('rejects a non-camelCase key before writing', () { |
| 172 | + writeLocale('en', enTemplate); |
| 173 | + final before = readLocale('en'); |
| 174 | + final r = run([ |
| 175 | + 'add', |
| 176 | + 'BadKey', |
| 177 | + '--translations', |
| 178 | + jsonEncode({'en': 'x'}), |
| 179 | + ]); |
| 180 | + expect(r.exitCode, 64); |
| 181 | + expect(readLocale('en'), before); |
| 182 | + }); |
| 183 | + |
| 184 | + test('rejects an unknown locale and leaves all files untouched', () { |
| 185 | + writeLocale('en', enTemplate); |
| 186 | + final before = readLocale('en'); |
| 187 | + final r = run([ |
| 188 | + 'add', |
| 189 | + 'newKey', |
| 190 | + '--translations', |
| 191 | + jsonEncode({'en': 'x', 'zz': 'y'}), |
| 192 | + ]); |
| 193 | + expect(r.exitCode, 64); |
| 194 | + expect(readLocale('en'), before); |
| 195 | + expect( |
| 196 | + File(p.join(tmp.path, 'localization', 'app_zz.arb')).existsSync(), |
| 197 | + isFalse, |
| 198 | + ); |
| 199 | + }); |
| 200 | + |
| 201 | + test('rejects a malformed placeholder shape', () { |
| 202 | + writeLocale('en', enTemplate); |
| 203 | + final before = readLocale('en'); |
| 204 | + final r = run([ |
| 205 | + 'add', |
| 206 | + 'newKey', |
| 207 | + '--translations', |
| 208 | + jsonEncode({'en': 'x'}), |
| 209 | + '--placeholders', |
| 210 | + jsonEncode({'n': 'int'}), // should be {"type":"int"} |
| 211 | + ]); |
| 212 | + expect(r.exitCode, 64); |
| 213 | + expect(readLocale('en'), before); |
| 214 | + }); |
| 215 | + }); |
| 216 | + |
| 217 | + group('set', () { |
| 218 | + test('updates an existing locale value in place', () { |
| 219 | + writeLocale('en', enTemplate); |
| 220 | + writeLocale('fr', frTemplate); |
| 221 | + final r = run(['set', 'greeting', 'fr', 'Coucou']); |
| 222 | + expect(r.exitCode, 0, reason: r.stderr.toString()); |
| 223 | + final fr = jsonDecode(readLocale('fr')) as Map<String, dynamic>; |
| 224 | + expect(fr['greeting'], 'Coucou'); |
| 225 | + }); |
| 226 | + |
| 227 | + test('refuses a key absent from the template', () { |
| 228 | + writeLocale('en', enTemplate); |
| 229 | + writeLocale('fr', frTemplate); |
| 230 | + final before = readLocale('fr'); |
| 231 | + final r = run(['set', 'ghost', 'fr', 'x']); |
| 232 | + expect(r.exitCode, 64); |
| 233 | + expect(readLocale('fr'), before); |
| 234 | + }); |
| 235 | + }); |
| 236 | + |
| 237 | + group('rename', () { |
| 238 | + test('preserves value and metadata across locales', () { |
| 239 | + writeLocale('en', enTemplate); |
| 240 | + writeLocale('fr', frTemplate); |
| 241 | + final r = run(['rename', 'greeting', 'salutation']); |
| 242 | + expect(r.exitCode, 0, reason: r.stderr.toString()); |
| 243 | + |
| 244 | + final en = jsonDecode(readLocale('en')) as Map<String, dynamic>; |
| 245 | + expect(en.containsKey('greeting'), isFalse); |
| 246 | + expect(en['salutation'], 'Hello'); |
| 247 | + expect((en['@salutation'] as Map)['description'], 'a greeting'); |
| 248 | + |
| 249 | + final fr = jsonDecode(readLocale('fr')) as Map<String, dynamic>; |
| 250 | + expect(fr['salutation'], 'Bonjour'); |
| 251 | + }); |
| 252 | + |
| 253 | + test('refuses when the new key already exists', () { |
| 254 | + writeLocale('en', enTemplate); |
| 255 | + final before = readLocale('en'); |
| 256 | + final r = run(['rename', 'greeting', 'farewell']); |
| 257 | + expect(r.exitCode, 1); |
| 258 | + expect(readLocale('en'), before); |
| 259 | + }); |
| 260 | + }); |
| 261 | + |
| 262 | + group('--dry-run', () { |
| 263 | + test('reports intended writes but changes nothing on disk', () { |
| 264 | + writeLocale('en', enTemplate); |
| 265 | + writeLocale('fr', frTemplate); |
| 266 | + final enBefore = readLocale('en'); |
| 267 | + final frBefore = readLocale('fr'); |
| 268 | + final r = run([ |
| 269 | + 'add', |
| 270 | + 'newKey', |
| 271 | + '--translations', |
| 272 | + jsonEncode({'en': 'New', 'fr': 'Nouveau'}), |
| 273 | + '--dry-run', |
| 274 | + ]); |
| 275 | + expect(r.exitCode, 0, reason: r.stderr.toString()); |
| 276 | + expect(r.stderr, contains('[dry-run]')); |
| 277 | + expect(readLocale('en'), enBefore); |
| 278 | + expect(readLocale('fr'), frBefore); |
| 279 | + }); |
| 280 | + }); |
| 281 | + |
| 282 | + group('option validation', () { |
| 283 | + test( |
| 284 | + 'rejects an option that is valid globally but wrong for the command', |
| 285 | + () { |
| 286 | + writeLocale('en', enTemplate); |
| 287 | + final r = run(['get', 'greeting', '--description', 'x']); |
| 288 | + expect(r.exitCode, 64); |
| 289 | + expect(r.stderr, contains('Unknown option')); |
| 290 | + }, |
| 291 | + ); |
| 292 | + |
| 293 | + test('rejects an outright unknown option', () { |
| 294 | + writeLocale('en', enTemplate); |
| 295 | + final r = run(['missing', '--lst']); |
| 296 | + expect(r.exitCode, 64); |
| 297 | + }); |
| 298 | + }); |
| 299 | +} |
| 300 | + |
| 301 | +/// Finds a real Dart executable to run the script with. `flutter test` sets |
| 302 | +/// [Platform.resolvedExecutable] to the flutter_tester, so we look for the |
| 303 | +/// `dart` (or `dart.exe`) that ships in the same SDK's `bin/`, walking up from |
| 304 | +/// the tester until a `dart-sdk/bin/dart` or a sibling `dart` is found. |
| 305 | +String _resolveDart() { |
| 306 | + final exe = Platform.isWindows ? 'dart.exe' : 'dart'; |
| 307 | + // 1) Sibling of the resolved executable (covers `dart test`). |
| 308 | + final sibling = p.join(p.dirname(Platform.resolvedExecutable), exe); |
| 309 | + if (File(sibling).existsSync() && |
| 310 | + p.basenameWithoutExtension(sibling) == 'dart') { |
| 311 | + return sibling; |
| 312 | + } |
| 313 | + // 2) The dart-sdk bundled under a Flutter cache: walk up looking for it. |
| 314 | + var dir = Directory(p.dirname(Platform.resolvedExecutable)); |
| 315 | + while (true) { |
| 316 | + final candidate = p.join(dir.path, 'cache', 'dart-sdk', 'bin', exe); |
| 317 | + if (File(candidate).existsSync()) return candidate; |
| 318 | + final nested = p.join(dir.path, 'dart-sdk', 'bin', exe); |
| 319 | + if (File(nested).existsSync()) return nested; |
| 320 | + final parent = dir.parent; |
| 321 | + if (parent.path == dir.path) break; |
| 322 | + dir = parent; |
| 323 | + } |
| 324 | + // 3) Last resort: hope `dart` is on PATH. |
| 325 | + return exe; |
| 326 | +} |
| 327 | + |
| 328 | +/// Walks up from this test file to the directory that contains `tools/arb.dart`. |
| 329 | +String _findRepoRoot() { |
| 330 | + var dir = Directory.current; |
| 331 | + while (true) { |
| 332 | + if (File(p.join(dir.path, 'tools', 'arb.dart')).existsSync()) { |
| 333 | + return dir.path; |
| 334 | + } |
| 335 | + final parent = dir.parent; |
| 336 | + if (parent.path == dir.path) { |
| 337 | + throw StateError('Could not locate repo root from ${Directory.current}'); |
| 338 | + } |
| 339 | + dir = parent; |
| 340 | + } |
| 341 | +} |
0 commit comments