i18nsmith 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/audit.d.ts +3 -0
- package/dist/commands/audit.d.ts.map +1 -0
- package/dist/commands/audit.js +180 -0
- package/dist/commands/audit.js.map +1 -0
- package/dist/commands/backup.d.ts +6 -0
- package/dist/commands/backup.d.ts.map +1 -0
- package/dist/commands/backup.js +85 -0
- package/dist/commands/backup.js.map +1 -0
- package/dist/commands/check.d.ts +3 -0
- package/dist/commands/check.d.ts.map +1 -0
- package/dist/commands/check.js +151 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +235 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/debug-patterns.d.ts +3 -0
- package/dist/commands/debug-patterns.d.ts.map +1 -0
- package/dist/commands/debug-patterns.js +192 -0
- package/dist/commands/debug-patterns.js.map +1 -0
- package/dist/commands/debug-patterns.test.d.ts +2 -0
- package/dist/commands/debug-patterns.test.d.ts.map +1 -0
- package/dist/commands/debug-patterns.test.js +109 -0
- package/dist/commands/debug-patterns.test.js.map +1 -0
- package/dist/commands/diagnose.d.ts +3 -0
- package/dist/commands/diagnose.d.ts.map +1 -0
- package/dist/commands/diagnose.js +117 -0
- package/dist/commands/diagnose.js.map +1 -0
- package/dist/commands/init.d.ts +8 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +450 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/init.test.d.ts +2 -0
- package/dist/commands/init.test.d.ts.map +1 -0
- package/dist/commands/init.test.js +74 -0
- package/dist/commands/init.test.js.map +1 -0
- package/dist/commands/install-hooks.d.ts +3 -0
- package/dist/commands/install-hooks.d.ts.map +1 -0
- package/dist/commands/install-hooks.js +52 -0
- package/dist/commands/install-hooks.js.map +1 -0
- package/dist/commands/preflight.d.ts +7 -0
- package/dist/commands/preflight.d.ts.map +1 -0
- package/dist/commands/preflight.js +417 -0
- package/dist/commands/preflight.js.map +1 -0
- package/dist/commands/preflight.test.d.ts +5 -0
- package/dist/commands/preflight.test.d.ts.map +1 -0
- package/dist/commands/preflight.test.js +108 -0
- package/dist/commands/preflight.test.js.map +1 -0
- package/dist/commands/rename.d.ts +6 -0
- package/dist/commands/rename.d.ts.map +1 -0
- package/dist/commands/rename.js +204 -0
- package/dist/commands/rename.js.map +1 -0
- package/dist/commands/scaffold-adapter.d.ts +3 -0
- package/dist/commands/scaffold-adapter.d.ts.map +1 -0
- package/dist/commands/scaffold-adapter.js +204 -0
- package/dist/commands/scaffold-adapter.js.map +1 -0
- package/dist/commands/scaffold-adapter.test.d.ts +2 -0
- package/dist/commands/scaffold-adapter.test.d.ts.map +1 -0
- package/dist/commands/scaffold-adapter.test.js +102 -0
- package/dist/commands/scaffold-adapter.test.js.map +1 -0
- package/dist/commands/scan.d.ts +3 -0
- package/dist/commands/scan.d.ts.map +1 -0
- package/dist/commands/scan.js +93 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/sync-seed.test.d.ts +2 -0
- package/dist/commands/sync-seed.test.d.ts.map +1 -0
- package/dist/commands/sync-seed.test.js +86 -0
- package/dist/commands/sync-seed.test.js.map +1 -0
- package/dist/commands/sync.d.ts +3 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +590 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/transform.d.ts +3 -0
- package/dist/commands/transform.d.ts.map +1 -0
- package/dist/commands/transform.js +114 -0
- package/dist/commands/transform.js.map +1 -0
- package/dist/commands/translate/csv-handler.d.ts +21 -0
- package/dist/commands/translate/csv-handler.d.ts.map +1 -0
- package/dist/commands/translate/csv-handler.js +270 -0
- package/dist/commands/translate/csv-handler.js.map +1 -0
- package/dist/commands/translate/executor.d.ts +31 -0
- package/dist/commands/translate/executor.d.ts.map +1 -0
- package/dist/commands/translate/executor.js +117 -0
- package/dist/commands/translate/executor.js.map +1 -0
- package/dist/commands/translate/index.d.ts +10 -0
- package/dist/commands/translate/index.d.ts.map +1 -0
- package/dist/commands/translate/index.js +170 -0
- package/dist/commands/translate/index.js.map +1 -0
- package/dist/commands/translate/reporter.d.ts +29 -0
- package/dist/commands/translate/reporter.d.ts.map +1 -0
- package/dist/commands/translate/reporter.js +103 -0
- package/dist/commands/translate/reporter.js.map +1 -0
- package/dist/commands/translate/types.d.ts +50 -0
- package/dist/commands/translate/types.d.ts.map +1 -0
- package/dist/commands/translate/types.js +5 -0
- package/dist/commands/translate/types.js.map +1 -0
- package/dist/commands/translate.d.ts +7 -0
- package/dist/commands/translate.d.ts.map +1 -0
- package/dist/commands/translate.js +7 -0
- package/dist/commands/translate.js.map +1 -0
- package/dist/commands/translate.test.d.ts +2 -0
- package/dist/commands/translate.test.d.ts.map +1 -0
- package/dist/commands/translate.test.js +118 -0
- package/dist/commands/translate.test.js.map +1 -0
- package/dist/e2e.test.d.ts +6 -0
- package/dist/e2e.test.d.ts.map +1 -0
- package/dist/e2e.test.js +376 -0
- package/dist/e2e.test.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/integration.test.d.ts +6 -0
- package/dist/integration.test.d.ts.map +1 -0
- package/dist/integration.test.js +320 -0
- package/dist/integration.test.js.map +1 -0
- package/dist/utils/diagnostics-exit.d.ts +12 -0
- package/dist/utils/diagnostics-exit.d.ts.map +1 -0
- package/dist/utils/diagnostics-exit.js +49 -0
- package/dist/utils/diagnostics-exit.js.map +1 -0
- package/dist/utils/diagnostics-exit.test.d.ts +2 -0
- package/dist/utils/diagnostics-exit.test.d.ts.map +1 -0
- package/dist/utils/diagnostics-exit.test.js +40 -0
- package/dist/utils/diagnostics-exit.test.js.map +1 -0
- package/dist/utils/diff-utils.d.ts +4 -0
- package/dist/utils/diff-utils.d.ts.map +1 -0
- package/dist/utils/diff-utils.js +30 -0
- package/dist/utils/diff-utils.js.map +1 -0
- package/dist/utils/diff-utils.test.d.ts +2 -0
- package/dist/utils/diff-utils.test.d.ts.map +1 -0
- package/dist/utils/diff-utils.test.js +30 -0
- package/dist/utils/diff-utils.test.js.map +1 -0
- package/dist/utils/exit-codes.d.ts +142 -0
- package/dist/utils/exit-codes.d.ts.map +1 -0
- package/dist/utils/exit-codes.js +168 -0
- package/dist/utils/exit-codes.js.map +1 -0
- package/dist/utils/package-manager.d.ts +4 -0
- package/dist/utils/package-manager.d.ts.map +1 -0
- package/dist/utils/package-manager.js +40 -0
- package/dist/utils/package-manager.js.map +1 -0
- package/dist/utils/pkg.d.ts +3 -0
- package/dist/utils/pkg.d.ts.map +1 -0
- package/dist/utils/pkg.js +24 -0
- package/dist/utils/pkg.js.map +1 -0
- package/dist/utils/provider-injector.d.ts +36 -0
- package/dist/utils/provider-injector.d.ts.map +1 -0
- package/dist/utils/provider-injector.js +223 -0
- package/dist/utils/provider-injector.js.map +1 -0
- package/dist/utils/provider-injector.test.d.ts +2 -0
- package/dist/utils/provider-injector.test.d.ts.map +1 -0
- package/dist/utils/provider-injector.test.js +67 -0
- package/dist/utils/provider-injector.test.js.map +1 -0
- package/dist/utils/scaffold.d.ts +20 -0
- package/dist/utils/scaffold.d.ts.map +1 -0
- package/dist/utils/scaffold.js +197 -0
- package/dist/utils/scaffold.js.map +1 -0
- package/package.json +35 -0
- package/src/commands/audit.ts +234 -0
- package/src/commands/backup.ts +96 -0
- package/src/commands/check.ts +191 -0
- package/src/commands/config.ts +263 -0
- package/src/commands/debug-patterns.test.ts +134 -0
- package/src/commands/debug-patterns.ts +257 -0
- package/src/commands/diagnose.ts +136 -0
- package/src/commands/init.test.ts +82 -0
- package/src/commands/init.ts +536 -0
- package/src/commands/install-hooks.ts +66 -0
- package/src/commands/preflight.test.ts +139 -0
- package/src/commands/preflight.ts +488 -0
- package/src/commands/rename.ts +264 -0
- package/src/commands/scaffold-adapter.test.ts +110 -0
- package/src/commands/scaffold-adapter.ts +250 -0
- package/src/commands/scan.ts +125 -0
- package/src/commands/sync-seed.test.ts +116 -0
- package/src/commands/sync.ts +736 -0
- package/src/commands/transform.ts +151 -0
- package/src/commands/translate/README.md +75 -0
- package/src/commands/translate/csv-handler.ts +301 -0
- package/src/commands/translate/executor.ts +188 -0
- package/src/commands/translate/index.ts +220 -0
- package/src/commands/translate/reporter.ts +138 -0
- package/src/commands/translate/types.ts +56 -0
- package/src/commands/translate.test.ts +173 -0
- package/src/commands/translate.ts +6 -0
- package/src/e2e.test.ts +479 -0
- package/src/fixtures/README.md +61 -0
- package/src/fixtures/basic-react/i18n.config.json +15 -0
- package/src/fixtures/basic-react/locales/de.json +8 -0
- package/src/fixtures/basic-react/locales/en.json +8 -0
- package/src/fixtures/basic-react/locales/fr.json +8 -0
- package/src/fixtures/basic-react/src/App.tsx +15 -0
- package/src/fixtures/basic-react/src/Messages.tsx +12 -0
- package/src/fixtures/nested-locales/i18n.config.json +9 -0
- package/src/fixtures/nested-locales/locales/en.json +23 -0
- package/src/fixtures/nested-locales/locales/fr.json +23 -0
- package/src/fixtures/nested-locales/src/HomePage.tsx +13 -0
- package/src/fixtures/suspicious-keys/i18n.config.json +9 -0
- package/src/fixtures/suspicious-keys/locales/en.json +11 -0
- package/src/fixtures/suspicious-keys/locales/fr.json +11 -0
- package/src/fixtures/suspicious-keys/src/BadKeys.tsx +19 -0
- package/src/index.ts +43 -0
- package/src/integration.test.ts +438 -0
- package/src/utils/diagnostics-exit.test.ts +47 -0
- package/src/utils/diagnostics-exit.ts +63 -0
- package/src/utils/diff-utils.test.ts +36 -0
- package/src/utils/diff-utils.ts +42 -0
- package/src/utils/exit-codes.ts +201 -0
- package/src/utils/package-manager.ts +44 -0
- package/src/utils/pkg.ts +23 -0
- package/src/utils/provider-injector.test.ts +79 -0
- package/src/utils/provider-injector.ts +315 -0
- package/src/utils/scaffold.ts +240 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
|
|
7
|
+
describe('translate command', () => {
|
|
8
|
+
let tempDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18nsmith-translate-test-'));
|
|
12
|
+
|
|
13
|
+
// Create basic project structure
|
|
14
|
+
await fs.mkdir(path.join(tempDir, 'src'), { recursive: true });
|
|
15
|
+
await fs.mkdir(path.join(tempDir, 'locales'), { recursive: true });
|
|
16
|
+
|
|
17
|
+
// Create source file with translation calls
|
|
18
|
+
await fs.writeFile(
|
|
19
|
+
path.join(tempDir, 'src', 'App.tsx'),
|
|
20
|
+
`import { useTranslation } from 'react-i18next';
|
|
21
|
+
export function App() {
|
|
22
|
+
const { t } = useTranslation();
|
|
23
|
+
return <div>{t('greeting')}{t('farewell')}</div>;
|
|
24
|
+
}
|
|
25
|
+
`
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Create source locale
|
|
29
|
+
await fs.writeFile(
|
|
30
|
+
path.join(tempDir, 'locales', 'en.json'),
|
|
31
|
+
JSON.stringify({ greeting: 'Hello', farewell: 'Goodbye' }, null, 2)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Create target locale with one missing key
|
|
35
|
+
await fs.writeFile(
|
|
36
|
+
path.join(tempDir, 'locales', 'fr.json'),
|
|
37
|
+
JSON.stringify({ greeting: 'Bonjour' }, null, 2)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Create config
|
|
41
|
+
await fs.writeFile(
|
|
42
|
+
path.join(tempDir, 'i18n.config.json'),
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
version: 1,
|
|
45
|
+
sourceLanguage: 'en',
|
|
46
|
+
targetLanguages: ['fr'],
|
|
47
|
+
localesDir: 'locales',
|
|
48
|
+
include: ['src/**/*.{ts,tsx}'],
|
|
49
|
+
}, null, 2)
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(async () => {
|
|
54
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const runCli = (args: string): string => {
|
|
58
|
+
const cliPath = path.resolve(__dirname, '..', '..', 'dist', 'index.js');
|
|
59
|
+
try {
|
|
60
|
+
return execSync(`node ${cliPath} ${args}`, {
|
|
61
|
+
cwd: tempDir,
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
64
|
+
});
|
|
65
|
+
} catch (error) {
|
|
66
|
+
const err = error as { stdout?: string; stderr?: string };
|
|
67
|
+
return (err.stdout ?? '') + (err.stderr ?? '');
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
describe('--export', () => {
|
|
72
|
+
it('exports missing translations to CSV', async () => {
|
|
73
|
+
const csvPath = path.join(tempDir, 'missing.csv');
|
|
74
|
+
const output = runCli(`translate --export ${csvPath}`);
|
|
75
|
+
|
|
76
|
+
expect(output).toContain('Exported');
|
|
77
|
+
|
|
78
|
+
const csvContent = await fs.readFile(csvPath, 'utf8');
|
|
79
|
+
expect(csvContent).toContain('key,sourceLocale,sourceValue,targetLocale,translatedValue');
|
|
80
|
+
expect(csvContent).toContain('farewell');
|
|
81
|
+
expect(csvContent).toContain('Goodbye');
|
|
82
|
+
expect(csvContent).toContain('fr');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('handles empty values correctly', async () => {
|
|
86
|
+
// All translations are present
|
|
87
|
+
await fs.writeFile(
|
|
88
|
+
path.join(tempDir, 'locales', 'fr.json'),
|
|
89
|
+
JSON.stringify({ greeting: 'Bonjour', farewell: 'Au revoir' }, null, 2)
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const output = runCli('translate --export output.csv');
|
|
93
|
+
expect(output).toContain('No missing translations');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('--import', () => {
|
|
98
|
+
it('imports translations from CSV', async () => {
|
|
99
|
+
const csvPath = path.join(tempDir, 'translations.csv');
|
|
100
|
+
await fs.writeFile(
|
|
101
|
+
csvPath,
|
|
102
|
+
`key,sourceLocale,sourceValue,targetLocale,translatedValue
|
|
103
|
+
farewell,en,Goodbye,fr,Au revoir
|
|
104
|
+
`
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const output = runCli(`translate --import ${csvPath} --write`);
|
|
108
|
+
expect(output).toContain('1 row');
|
|
109
|
+
|
|
110
|
+
const frContent = await fs.readFile(path.join(tempDir, 'locales', 'fr.json'), 'utf8');
|
|
111
|
+
const frData = JSON.parse(frContent);
|
|
112
|
+
expect(frData.farewell).toBe('Au revoir');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('skips rows with empty translatedValue', async () => {
|
|
116
|
+
const csvPath = path.join(tempDir, 'translations.csv');
|
|
117
|
+
await fs.writeFile(
|
|
118
|
+
csvPath,
|
|
119
|
+
`key,sourceLocale,sourceValue,targetLocale,translatedValue
|
|
120
|
+
farewell,en,Goodbye,fr,
|
|
121
|
+
`
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const output = runCli(`translate --import ${csvPath}`);
|
|
125
|
+
expect(output).toContain('skipped');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('handles quoted CSV fields with commas', async () => {
|
|
129
|
+
// Add a source key with comma
|
|
130
|
+
await fs.writeFile(
|
|
131
|
+
path.join(tempDir, 'locales', 'en.json'),
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
greeting: 'Hello',
|
|
134
|
+
farewell: 'Goodbye',
|
|
135
|
+
message: 'Hello, world!'
|
|
136
|
+
}, null, 2)
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const csvPath = path.join(tempDir, 'translations.csv');
|
|
140
|
+
await fs.writeFile(
|
|
141
|
+
csvPath,
|
|
142
|
+
`key,sourceLocale,sourceValue,targetLocale,translatedValue
|
|
143
|
+
message,en,"Hello, world!",fr,"Bonjour, le monde!"
|
|
144
|
+
`
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const output = runCli(`translate --import ${csvPath} --write`);
|
|
148
|
+
expect(output).toContain('1 row');
|
|
149
|
+
|
|
150
|
+
const frContent = await fs.readFile(path.join(tempDir, 'locales', 'fr.json'), 'utf8');
|
|
151
|
+
const frData = JSON.parse(frContent);
|
|
152
|
+
expect(frData.message).toBe('Bonjour, le monde!');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('dry-run mode does not write files', async () => {
|
|
156
|
+
const csvPath = path.join(tempDir, 'translations.csv');
|
|
157
|
+
await fs.writeFile(
|
|
158
|
+
csvPath,
|
|
159
|
+
`key,sourceLocale,sourceValue,targetLocale,translatedValue
|
|
160
|
+
farewell,en,Goodbye,fr,Au revoir
|
|
161
|
+
`
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const originalContent = await fs.readFile(path.join(tempDir, 'locales', 'fr.json'), 'utf8');
|
|
165
|
+
|
|
166
|
+
const output = runCli(`translate --import ${csvPath}`);
|
|
167
|
+
expect(output).toContain('DRY RUN');
|
|
168
|
+
|
|
169
|
+
const newContent = await fs.readFile(path.join(tempDir, 'locales', 'fr.json'), 'utf8');
|
|
170
|
+
expect(newContent).toBe(originalContent);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
package/src/e2e.test.ts
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E Tests using fixture projects
|
|
3
|
+
* These tests run against pre-configured fixture projects to test real-world scenarios
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest';
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import { spawnSync } from 'child_process';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const __dirname = path.dirname(__filename);
|
|
15
|
+
|
|
16
|
+
// Get paths
|
|
17
|
+
const CLI_PATH = path.resolve(__dirname, '../dist/index.js');
|
|
18
|
+
const FIXTURES_DIR = path.resolve(__dirname, './fixtures');
|
|
19
|
+
|
|
20
|
+
// Helper to run CLI commands
|
|
21
|
+
function runCli(
|
|
22
|
+
args: string[],
|
|
23
|
+
options: { cwd?: string } = {}
|
|
24
|
+
): { stdout: string; stderr: string; output: string; exitCode: number } {
|
|
25
|
+
const result = spawnSync('node', [CLI_PATH, ...args], {
|
|
26
|
+
cwd: options.cwd ?? process.cwd(),
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
timeout: 30000,
|
|
29
|
+
env: {
|
|
30
|
+
...process.env,
|
|
31
|
+
CI: 'true',
|
|
32
|
+
NO_COLOR: '1',
|
|
33
|
+
FORCE_COLOR: '0',
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const stdout = result.stdout ?? '';
|
|
38
|
+
const stderr = result.stderr ?? '';
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
stdout,
|
|
42
|
+
stderr,
|
|
43
|
+
output: stdout + stderr,
|
|
44
|
+
exitCode: result.status ?? 1,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Helper to copy fixture to temp directory
|
|
49
|
+
async function setupFixture(fixtureName: string): Promise<string> {
|
|
50
|
+
const fixtureSource = path.join(FIXTURES_DIR, fixtureName);
|
|
51
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), `i18nsmith-e2e-${fixtureName}-`));
|
|
52
|
+
await copyDir(fixtureSource, tmpDir);
|
|
53
|
+
return tmpDir;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Helper to recursively copy directory
|
|
57
|
+
async function copyDir(src: string, dest: string): Promise<void> {
|
|
58
|
+
await fs.mkdir(dest, { recursive: true });
|
|
59
|
+
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
60
|
+
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const srcPath = path.join(src, entry.name);
|
|
63
|
+
const destPath = path.join(dest, entry.name);
|
|
64
|
+
|
|
65
|
+
if (entry.isDirectory()) {
|
|
66
|
+
await copyDir(srcPath, destPath);
|
|
67
|
+
} else {
|
|
68
|
+
await fs.copyFile(srcPath, destPath);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Helper to cleanup temp directory
|
|
74
|
+
async function cleanupFixture(fixtureDir: string): Promise<void> {
|
|
75
|
+
await fs.rm(fixtureDir, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Helper to extract JSON from output
|
|
79
|
+
function extractJson<T>(output: string): T {
|
|
80
|
+
const jsonMatch = output.match(/\{[\s\S]*\}/);
|
|
81
|
+
if (!jsonMatch) {
|
|
82
|
+
throw new Error(`No JSON found in output: ${output.slice(0, 200)}...`);
|
|
83
|
+
}
|
|
84
|
+
return JSON.parse(jsonMatch[0]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
beforeAll(() => {
|
|
88
|
+
const cliRoot = path.resolve(__dirname, '..');
|
|
89
|
+
const result = spawnSync('pnpm', ['build'], {
|
|
90
|
+
cwd: cliRoot,
|
|
91
|
+
stdio: 'inherit',
|
|
92
|
+
env: { ...process.env },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (result.status !== 0) {
|
|
96
|
+
throw new Error('Failed to build CLI before running E2E tests');
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('E2E Fixture Tests', () => {
|
|
101
|
+
describe('basic-react fixture', () => {
|
|
102
|
+
let fixtureDir: string;
|
|
103
|
+
|
|
104
|
+
beforeEach(async () => {
|
|
105
|
+
fixtureDir = await setupFixture('basic-react');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterEach(async () => {
|
|
109
|
+
await cleanupFixture(fixtureDir);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should run preflight successfully', () => {
|
|
113
|
+
const result = runCli(['preflight'], { cwd: fixtureDir });
|
|
114
|
+
|
|
115
|
+
expect(result.output).toContain('Config File');
|
|
116
|
+
expect(result.output).toContain('pass');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should scan for translation references', () => {
|
|
120
|
+
const result = runCli(['scan'], { cwd: fixtureDir });
|
|
121
|
+
|
|
122
|
+
expect(result.exitCode).toBe(0);
|
|
123
|
+
expect(result.output).toContain('Scanned');
|
|
124
|
+
expect(result.output).toContain('candidate');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should check locale files without errors', () => {
|
|
128
|
+
const result = runCli(['check'], { cwd: fixtureDir });
|
|
129
|
+
|
|
130
|
+
expect(result.exitCode).toBe(0);
|
|
131
|
+
expect(result.output).toContain('Locales directory');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should run sync in dry-run mode by default', () => {
|
|
135
|
+
const result = runCli(['sync'], { cwd: fixtureDir });
|
|
136
|
+
|
|
137
|
+
expect(result.output).toContain('DRY RUN');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should output scan JSON when requested', () => {
|
|
141
|
+
const result = runCli(['scan', '--json'], { cwd: fixtureDir });
|
|
142
|
+
const parsed = extractJson<{ filesScanned: number; candidates: unknown[] }>(result.stdout);
|
|
143
|
+
|
|
144
|
+
expect(parsed).toHaveProperty('filesScanned');
|
|
145
|
+
expect(parsed).toHaveProperty('candidates');
|
|
146
|
+
expect(typeof parsed.filesScanned).toBe('number');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should output check JSON when requested', () => {
|
|
150
|
+
const result = runCli(['check', '--json'], { cwd: fixtureDir });
|
|
151
|
+
const parsed = extractJson<{ diagnostics: unknown }>(result.stdout);
|
|
152
|
+
|
|
153
|
+
expect(parsed).toHaveProperty('diagnostics');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('suspicious-keys fixture', () => {
|
|
158
|
+
let fixtureDir: string;
|
|
159
|
+
|
|
160
|
+
beforeEach(async () => {
|
|
161
|
+
fixtureDir = await setupFixture('suspicious-keys');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
afterEach(async () => {
|
|
165
|
+
await cleanupFixture(fixtureDir);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should detect suspicious keys in audit', () => {
|
|
169
|
+
const result = runCli(['audit'], { cwd: fixtureDir });
|
|
170
|
+
|
|
171
|
+
// Should find suspicious patterns
|
|
172
|
+
expect(result.output).toContain('Suspicious');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should fail with --strict when suspicious keys exist', () => {
|
|
176
|
+
const result = runCli(['sync', '--strict'], { cwd: fixtureDir });
|
|
177
|
+
|
|
178
|
+
// Should fail due to suspicious keys
|
|
179
|
+
expect(result.exitCode).not.toBe(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should pass with --strict when no suspicious patterns', async () => {
|
|
183
|
+
// Remove suspicious keys from en.json, leaving only good ones
|
|
184
|
+
const localeFile = path.join(fixtureDir, 'locales', 'en.json');
|
|
185
|
+
const cleanLocale = {
|
|
186
|
+
'proper.namespaced.key': 'This is a properly namespaced key',
|
|
187
|
+
'buttons.submit': 'Submit',
|
|
188
|
+
'common.title': 'Application Title'
|
|
189
|
+
};
|
|
190
|
+
await fs.writeFile(localeFile, JSON.stringify(cleanLocale, null, 2));
|
|
191
|
+
|
|
192
|
+
// Also update fr.json
|
|
193
|
+
const frLocaleFile = path.join(fixtureDir, 'locales', 'fr.json');
|
|
194
|
+
const cleanFrLocale = {
|
|
195
|
+
'proper.namespaced.key': 'Ceci est une clé correctement nommée',
|
|
196
|
+
'buttons.submit': 'Soumettre',
|
|
197
|
+
'common.title': 'Titre de l\'application'
|
|
198
|
+
};
|
|
199
|
+
await fs.writeFile(frLocaleFile, JSON.stringify(cleanFrLocale, null, 2));
|
|
200
|
+
|
|
201
|
+
const result = runCli(['sync', '--strict'], { cwd: fixtureDir });
|
|
202
|
+
|
|
203
|
+
// The suspicious keys in source files may still trigger warnings
|
|
204
|
+
// but the locale files themselves should be clean
|
|
205
|
+
expect(result.output).toContain('Scanned');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should auto-rename suspicious keys when --write is provided', async () => {
|
|
209
|
+
const result = runCli(
|
|
210
|
+
[
|
|
211
|
+
'sync',
|
|
212
|
+
'--auto-rename-suspicious',
|
|
213
|
+
'--write',
|
|
214
|
+
'--rename-map-file',
|
|
215
|
+
'auto-rename-map.txt',
|
|
216
|
+
],
|
|
217
|
+
{ cwd: fixtureDir }
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
expect(result.exitCode).toBe(0);
|
|
221
|
+
expect(result.output).toContain('Applying safe rename proposals');
|
|
222
|
+
|
|
223
|
+
const enLocalePath = path.join(fixtureDir, 'locales', 'en.json');
|
|
224
|
+
const frLocalePath = path.join(fixtureDir, 'locales', 'fr.json');
|
|
225
|
+
const enLocale = JSON.parse(await fs.readFile(enLocalePath, 'utf8'));
|
|
226
|
+
const frLocale = JSON.parse(await fs.readFile(frLocalePath, 'utf8'));
|
|
227
|
+
|
|
228
|
+
expect(enLocale).toHaveProperty('common.hello-world');
|
|
229
|
+
expect(enLocale).not.toHaveProperty('Hello World');
|
|
230
|
+
expect(frLocale).toHaveProperty('common.hello-world');
|
|
231
|
+
|
|
232
|
+
const sourceFile = await fs.readFile(path.join(fixtureDir, 'src', 'BadKeys.tsx'), 'utf8');
|
|
233
|
+
expect(sourceFile).toContain("t('common.hello-world')");
|
|
234
|
+
expect(sourceFile).not.toContain("t('Hello World')");
|
|
235
|
+
|
|
236
|
+
const mapPath = path.join(fixtureDir, 'auto-rename-map.txt');
|
|
237
|
+
const mapContents = await fs.readFile(mapPath, 'utf8');
|
|
238
|
+
expect(mapContents).toContain('"Hello World" = "common.hello-world"');
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('nested-locales fixture', () => {
|
|
243
|
+
let fixtureDir: string;
|
|
244
|
+
|
|
245
|
+
beforeEach(async () => {
|
|
246
|
+
fixtureDir = await setupFixture('nested-locales');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
afterEach(async () => {
|
|
250
|
+
await cleanupFixture(fixtureDir);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should handle nested JSON structure', () => {
|
|
254
|
+
const result = runCli(['check'], { cwd: fixtureDir });
|
|
255
|
+
|
|
256
|
+
expect(result.exitCode).toBe(0);
|
|
257
|
+
expect(result.output).toContain('Locales directory');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should scan references with dot-notation keys', () => {
|
|
261
|
+
const result = runCli(['sync'], { cwd: fixtureDir });
|
|
262
|
+
|
|
263
|
+
expect(result.output).toContain('reference');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should output sync JSON with nested locale data', () => {
|
|
267
|
+
const result = runCli(['sync', '--json'], { cwd: fixtureDir });
|
|
268
|
+
const parsed = extractJson<{ references: unknown[]; filesScanned: number }>(result.stdout);
|
|
269
|
+
|
|
270
|
+
expect(parsed).toHaveProperty('references');
|
|
271
|
+
expect(Array.isArray(parsed.references)).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('backup and restore workflow', () => {
|
|
276
|
+
let fixtureDir: string;
|
|
277
|
+
|
|
278
|
+
beforeEach(async () => {
|
|
279
|
+
fixtureDir = await setupFixture('basic-react');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
afterEach(async () => {
|
|
283
|
+
await cleanupFixture(fixtureDir);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should create backup when writing with prune', async () => {
|
|
287
|
+
// Add an unused key to locales
|
|
288
|
+
const localeFile = path.join(fixtureDir, 'locales', 'en.json');
|
|
289
|
+
const locale = JSON.parse(await fs.readFile(localeFile, 'utf8'));
|
|
290
|
+
locale['unused.key.for.testing'] = 'Unused value';
|
|
291
|
+
await fs.writeFile(localeFile, JSON.stringify(locale, null, 2));
|
|
292
|
+
|
|
293
|
+
// Run sync with --write --prune --yes (skips confirmation)
|
|
294
|
+
const result = runCli(['sync', '--write', '--prune', '--yes'], { cwd: fixtureDir });
|
|
295
|
+
|
|
296
|
+
// Should mention backup (either text or emoji)
|
|
297
|
+
const mentionsBackup = result.output.includes('backup') || result.output.includes('📦');
|
|
298
|
+
expect(mentionsBackup).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should list backups after creating one', async () => {
|
|
302
|
+
// Add an unused key to locales
|
|
303
|
+
const localeFile = path.join(fixtureDir, 'locales', 'en.json');
|
|
304
|
+
const locale = JSON.parse(await fs.readFile(localeFile, 'utf8'));
|
|
305
|
+
locale['unused.key.for.testing'] = 'Unused value';
|
|
306
|
+
await fs.writeFile(localeFile, JSON.stringify(locale, null, 2));
|
|
307
|
+
|
|
308
|
+
// Create a backup
|
|
309
|
+
runCli(['sync', '--write', '--prune', '--yes'], { cwd: fixtureDir });
|
|
310
|
+
|
|
311
|
+
// List backups
|
|
312
|
+
const listResult = runCli(['backup-list'], { cwd: fixtureDir });
|
|
313
|
+
|
|
314
|
+
// Should find the backup or show no backups message
|
|
315
|
+
expect(listResult.output).toMatch(/backup|No backups found/i);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('dry-run default behavior', () => {
|
|
320
|
+
let fixtureDir: string;
|
|
321
|
+
|
|
322
|
+
beforeEach(async () => {
|
|
323
|
+
fixtureDir = await setupFixture('basic-react');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
afterEach(async () => {
|
|
327
|
+
await cleanupFixture(fixtureDir);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('sync should not modify files by default', async () => {
|
|
331
|
+
// Get original content
|
|
332
|
+
const localeFile = path.join(fixtureDir, 'locales', 'en.json');
|
|
333
|
+
const originalContent = await fs.readFile(localeFile, 'utf8');
|
|
334
|
+
|
|
335
|
+
// Run sync without --write
|
|
336
|
+
runCli(['sync'], { cwd: fixtureDir });
|
|
337
|
+
|
|
338
|
+
// Content should be unchanged
|
|
339
|
+
const afterContent = await fs.readFile(localeFile, 'utf8');
|
|
340
|
+
expect(afterContent).toBe(originalContent);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('transform should not modify files by default', async () => {
|
|
344
|
+
// Get original content
|
|
345
|
+
const sourceFile = path.join(fixtureDir, 'src', 'App.tsx');
|
|
346
|
+
const originalContent = await fs.readFile(sourceFile, 'utf8');
|
|
347
|
+
|
|
348
|
+
// Run transform without --write
|
|
349
|
+
runCli(['transform'], { cwd: fixtureDir });
|
|
350
|
+
|
|
351
|
+
// Content should be unchanged
|
|
352
|
+
const afterContent = await fs.readFile(sourceFile, 'utf8');
|
|
353
|
+
expect(afterContent).toBe(originalContent);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe('--assume flag for dynamic keys', () => {
|
|
358
|
+
let fixtureDir: string;
|
|
359
|
+
|
|
360
|
+
beforeEach(async () => {
|
|
361
|
+
fixtureDir = await setupFixture('basic-react');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
afterEach(async () => {
|
|
365
|
+
await cleanupFixture(fixtureDir);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('should accept assumed keys via --assume flag', async () => {
|
|
369
|
+
// Add keys to locale that exist but might be flagged as unused
|
|
370
|
+
const localeFile = path.join(fixtureDir, 'locales', 'en.json');
|
|
371
|
+
const locale = JSON.parse(await fs.readFile(localeFile, 'utf8'));
|
|
372
|
+
locale['dynamic.runtime.key'] = 'Dynamic value loaded at runtime';
|
|
373
|
+
await fs.writeFile(localeFile, JSON.stringify(locale, null, 2));
|
|
374
|
+
|
|
375
|
+
// Run sync without --assume - key should show as unused
|
|
376
|
+
const resultWithoutAssume = runCli(['sync', '--json'], { cwd: fixtureDir });
|
|
377
|
+
const parsedWithout = extractJson<{ unusedKeys: { key: string }[] }>(resultWithoutAssume.stdout);
|
|
378
|
+
expect(parsedWithout.unusedKeys.some(k => k.key === 'dynamic.runtime.key')).toBe(true);
|
|
379
|
+
|
|
380
|
+
// Run sync with --assume - key should NOT show as unused
|
|
381
|
+
const resultWithAssume = runCli(['sync', '--json', '--assume', 'dynamic.runtime.key'], { cwd: fixtureDir });
|
|
382
|
+
const parsedWith = extractJson<{ unusedKeys: { key: string }[]; assumedKeys: string[] }>(resultWithAssume.stdout);
|
|
383
|
+
expect(parsedWith.assumedKeys).toContain('dynamic.runtime.key');
|
|
384
|
+
expect(parsedWith.unusedKeys.some(k => k.key === 'dynamic.runtime.key')).toBe(false);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('should accept multiple assumed keys', async () => {
|
|
388
|
+
// Add keys to locale
|
|
389
|
+
const localeFile = path.join(fixtureDir, 'locales', 'en.json');
|
|
390
|
+
const locale = JSON.parse(await fs.readFile(localeFile, 'utf8'));
|
|
391
|
+
locale['dynamic.key.one'] = 'First dynamic key';
|
|
392
|
+
locale['dynamic.key.two'] = 'Second dynamic key';
|
|
393
|
+
await fs.writeFile(localeFile, JSON.stringify(locale, null, 2));
|
|
394
|
+
|
|
395
|
+
// Run sync with multiple --assume values
|
|
396
|
+
const result = runCli(['sync', '--json', '--assume', 'dynamic.key.one,dynamic.key.two'], { cwd: fixtureDir });
|
|
397
|
+
const parsed = extractJson<{ assumedKeys: string[] }>(result.stdout);
|
|
398
|
+
|
|
399
|
+
expect(parsed.assumedKeys).toContain('dynamic.key.one');
|
|
400
|
+
expect(parsed.assumedKeys).toContain('dynamic.key.two');
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe('--invalidate-cache flag', () => {
|
|
405
|
+
let fixtureDir: string;
|
|
406
|
+
|
|
407
|
+
beforeEach(async () => {
|
|
408
|
+
fixtureDir = await setupFixture('basic-react');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
afterEach(async () => {
|
|
412
|
+
await cleanupFixture(fixtureDir);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('should accept --invalidate-cache flag on sync', () => {
|
|
416
|
+
const result = runCli(['sync', '--invalidate-cache'], { cwd: fixtureDir });
|
|
417
|
+
expect(result.exitCode).toBe(0);
|
|
418
|
+
expect(result.output).toContain('Scanned');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('should accept --invalidate-cache flag on check', () => {
|
|
422
|
+
const result = runCli(['check', '--invalidate-cache'], { cwd: fixtureDir });
|
|
423
|
+
expect(result.exitCode).toBe(0);
|
|
424
|
+
expect(result.output).toContain('Locales directory');
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe('rename-keys bulk operation', () => {
|
|
429
|
+
let fixtureDir: string;
|
|
430
|
+
|
|
431
|
+
beforeEach(async () => {
|
|
432
|
+
fixtureDir = await setupFixture('basic-react');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
afterEach(async () => {
|
|
436
|
+
await cleanupFixture(fixtureDir);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('should perform bulk rename with mapping file', async () => {
|
|
440
|
+
// Create a mapping file
|
|
441
|
+
const mappingFile = path.join(fixtureDir, 'rename-map.json');
|
|
442
|
+
const mapping = {
|
|
443
|
+
'common.welcome': 'home.greeting',
|
|
444
|
+
'common.logout': 'auth.logout',
|
|
445
|
+
};
|
|
446
|
+
await fs.writeFile(mappingFile, JSON.stringify(mapping, null, 2));
|
|
447
|
+
|
|
448
|
+
// Run rename-keys with the mapping
|
|
449
|
+
const result = runCli(['rename-keys', '--map', 'rename-map.json'], { cwd: fixtureDir });
|
|
450
|
+
|
|
451
|
+
expect(result.output).toContain('DRY RUN');
|
|
452
|
+
// Should process the mappings (even if no actual references exist in fixture)
|
|
453
|
+
expect(result.output).toMatch(/Updated \d+ occurrence/i);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('should accept array format mapping file', async () => {
|
|
457
|
+
// Create a mapping file with array format
|
|
458
|
+
const mappingFile = path.join(fixtureDir, 'rename-map.json');
|
|
459
|
+
const mapping = [
|
|
460
|
+
{ from: 'old.key.one', to: 'new.key.one' },
|
|
461
|
+
{ from: 'old.key.two', to: 'new.key.two' },
|
|
462
|
+
];
|
|
463
|
+
await fs.writeFile(mappingFile, JSON.stringify(mapping, null, 2));
|
|
464
|
+
|
|
465
|
+
// Run rename-keys with the mapping
|
|
466
|
+
const result = runCli(['rename-keys', '--map', 'rename-map.json'], { cwd: fixtureDir });
|
|
467
|
+
|
|
468
|
+
expect(result.exitCode).toBe(0);
|
|
469
|
+
expect(result.output).toContain('DRY RUN');
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('should error on missing mapping file', () => {
|
|
473
|
+
const result = runCli(['rename-keys', '--map', 'nonexistent.json'], { cwd: fixtureDir });
|
|
474
|
+
|
|
475
|
+
expect(result.exitCode).not.toBe(0);
|
|
476
|
+
expect(result.output).toContain('not found');
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
});
|