i18nsmith 0.1.9 → 0.2.1
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/rename.d.ts.map +1 -1
- package/dist/commands/review.d.ts +4 -0
- package/dist/commands/review.d.ts.map +1 -0
- package/dist/commands/review.test.d.ts +2 -0
- package/dist/commands/review.test.d.ts.map +1 -0
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/transform.d.ts.map +1 -1
- package/dist/commands/translate/index.d.ts.map +1 -1
- package/dist/commands/translate/types.d.ts +2 -0
- package/dist/commands/translate/types.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1240 -387
- package/dist/test-helpers/ensure-cli-built.d.ts +2 -0
- package/dist/test-helpers/ensure-cli-built.d.ts.map +1 -0
- package/dist/utils/preview.d.ts +13 -0
- package/dist/utils/preview.d.ts.map +1 -0
- package/dist/utils/preview.test.d.ts +2 -0
- package/dist/utils/preview.test.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/commands/debug-patterns.test.ts +6 -1
- package/src/commands/rename.ts +35 -3
- package/src/commands/review.test.ts +12 -0
- package/src/commands/review.ts +228 -0
- package/src/commands/sync-seed.test.ts +9 -3
- package/src/commands/sync.ts +104 -10
- package/src/commands/transform.ts +114 -6
- package/src/commands/translate/index.ts +23 -3
- package/src/commands/translate/types.ts +2 -0
- package/src/commands/translate.test.ts +9 -3
- package/src/e2e.test.ts +47 -11
- package/src/index.ts +2 -0
- package/src/integration.test.ts +12 -1
- package/src/test-helpers/ensure-cli-built.ts +32 -0
- package/src/utils/preview.test.ts +94 -0
- package/src/utils/preview.ts +126 -0
|
@@ -4,8 +4,9 @@ import chalk from 'chalk';
|
|
|
4
4
|
import type { Command } from 'commander';
|
|
5
5
|
import { loadConfigWithMeta } from '@i18nsmith/core';
|
|
6
6
|
import { Transformer } from '@i18nsmith/transformer';
|
|
7
|
-
import type { TransformSummary } from '@i18nsmith/transformer';
|
|
7
|
+
import type { TransformProgress, TransformSummary } from '@i18nsmith/transformer';
|
|
8
8
|
import { printLocaleDiffs, writeLocaleDiffPatches } from '../utils/diff-utils.js';
|
|
9
|
+
import { applyPreviewFile, writePreviewFile } from '../utils/preview.js';
|
|
9
10
|
|
|
10
11
|
interface TransformOptions {
|
|
11
12
|
config?: string;
|
|
@@ -17,6 +18,8 @@ interface TransformOptions {
|
|
|
17
18
|
diff?: boolean;
|
|
18
19
|
patchDir?: string;
|
|
19
20
|
migrateTextKeys?: boolean;
|
|
21
|
+
previewOutput?: string;
|
|
22
|
+
applyPreview?: string;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
const collectTargetPatterns = (value: string | string[], previous: string[]) => {
|
|
@@ -29,10 +32,24 @@ const collectTargetPatterns = (value: string | string[], previous: string[]) =>
|
|
|
29
32
|
};
|
|
30
33
|
|
|
31
34
|
function printTransformSummary(summary: TransformSummary) {
|
|
35
|
+
const counts = summary.candidates.reduce(
|
|
36
|
+
(acc, c) => {
|
|
37
|
+
acc.total += 1;
|
|
38
|
+
if (c.status === 'applied') acc.applied += 1;
|
|
39
|
+
else if (c.status === 'pending') acc.pending += 1;
|
|
40
|
+
else if (c.status === 'duplicate') acc.duplicate += 1;
|
|
41
|
+
else if (c.status === 'existing') acc.existing += 1;
|
|
42
|
+
else if (c.status === 'skipped') acc.skipped += 1;
|
|
43
|
+
return acc;
|
|
44
|
+
},
|
|
45
|
+
{ total: 0, applied: 0, pending: 0, duplicate: 0, existing: 0, skipped: 0 }
|
|
46
|
+
);
|
|
47
|
+
|
|
32
48
|
console.log(
|
|
33
49
|
chalk.green(
|
|
34
50
|
`Scanned ${summary.filesScanned} file${summary.filesScanned === 1 ? '' : 's'}; ` +
|
|
35
|
-
`${
|
|
51
|
+
`${counts.total} candidate${counts.total === 1 ? '' : 's'} found ` +
|
|
52
|
+
`(applied: ${counts.applied}, pending: ${counts.pending}, duplicates: ${counts.duplicate}, existing: ${counts.existing}, skipped: ${counts.skipped}).`
|
|
36
53
|
)
|
|
37
54
|
);
|
|
38
55
|
|
|
@@ -50,6 +67,17 @@ function printTransformSummary(summary: TransformSummary) {
|
|
|
50
67
|
|
|
51
68
|
console.table(preview);
|
|
52
69
|
|
|
70
|
+
const pending = summary.candidates.filter((candidate) => candidate.status === 'pending').length;
|
|
71
|
+
if (pending > 0) {
|
|
72
|
+
console.log(
|
|
73
|
+
chalk.yellow(
|
|
74
|
+
`\n${pending} candidate${pending === 1 ? '' : 's'} still pending. ` +
|
|
75
|
+
`This can happen when candidates are filtered for safety (e.g., not in a React scope). ` +
|
|
76
|
+
`Re-run with --write after reviewing skipped reasons if you want to keep iterating.`
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
53
81
|
if (summary.filesChanged.length) {
|
|
54
82
|
console.log(chalk.blue(`Files changed (${summary.filesChanged.length}):`));
|
|
55
83
|
summary.filesChanged.forEach((file) => console.log(` • ${file}`));
|
|
@@ -70,6 +98,59 @@ function printTransformSummary(summary: TransformSummary) {
|
|
|
70
98
|
}
|
|
71
99
|
}
|
|
72
100
|
|
|
101
|
+
function createProgressLogger() {
|
|
102
|
+
let lastPercent = -1;
|
|
103
|
+
let lastLogTime = 0;
|
|
104
|
+
let pendingCarriageReturn = false;
|
|
105
|
+
|
|
106
|
+
const writeLine = (line: string, final = false) => {
|
|
107
|
+
if (process.stdout.isTTY) {
|
|
108
|
+
process.stdout.write(`\r${line}`);
|
|
109
|
+
pendingCarriageReturn = !final;
|
|
110
|
+
if (final) {
|
|
111
|
+
process.stdout.write('\n');
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
console.log(line);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
emit(progress: TransformProgress) {
|
|
120
|
+
if (progress.stage !== 'apply' || progress.total === 0) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const shouldLog =
|
|
126
|
+
progress.processed === progress.total ||
|
|
127
|
+
progress.percent === 0 ||
|
|
128
|
+
progress.percent >= lastPercent + 2 ||
|
|
129
|
+
now - lastLogTime > 1500;
|
|
130
|
+
|
|
131
|
+
if (!shouldLog) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
lastPercent = progress.percent;
|
|
136
|
+
lastLogTime = now;
|
|
137
|
+
const line =
|
|
138
|
+
`Applying transforms ${progress.processed}/${progress.total} (${progress.percent}%)` +
|
|
139
|
+
` | applied: ${progress.applied ?? 0}` +
|
|
140
|
+
` | skipped: ${progress.skipped ?? 0}` +
|
|
141
|
+
(progress.errors ? ` | errors: ${progress.errors}` : '') +
|
|
142
|
+
` | remaining: ${progress.remaining ?? progress.total - progress.processed}`;
|
|
143
|
+
writeLine(line, progress.processed === progress.total);
|
|
144
|
+
},
|
|
145
|
+
flush() {
|
|
146
|
+
if (pendingCarriageReturn && process.stdout.isTTY) {
|
|
147
|
+
process.stdout.write('\n');
|
|
148
|
+
pendingCarriageReturn = false;
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
73
154
|
export function registerTransform(program: Command) {
|
|
74
155
|
program
|
|
75
156
|
.command('transform')
|
|
@@ -83,11 +164,30 @@ export function registerTransform(program: Command) {
|
|
|
83
164
|
.option('--patch-dir <path>', 'Write locale diffs to .patch files in the specified directory')
|
|
84
165
|
.option('--target <pattern...>', 'Limit scanning to specific files or glob patterns', collectTargetPatterns, [])
|
|
85
166
|
.option('--migrate-text-keys', 'Migrate existing t("Text") calls to structured keys')
|
|
167
|
+
.option('--preview-output <path>', 'Write preview summary (JSON) to a file (implies dry-run)')
|
|
168
|
+
.option('--apply-preview <path>', 'Apply a previously saved transform preview JSON file safely')
|
|
86
169
|
.action(async (options: TransformOptions) => {
|
|
170
|
+
if (options.applyPreview) {
|
|
171
|
+
await applyPreviewFile('transform', options.applyPreview);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
87
175
|
const diffEnabled = Boolean(options.diff || options.patchDir);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
);
|
|
176
|
+
const previewMode = Boolean(options.previewOutput);
|
|
177
|
+
const diffRequested = diffEnabled || previewMode;
|
|
178
|
+
const writeEnabled = Boolean(options.write) && !previewMode;
|
|
179
|
+
|
|
180
|
+
if (previewMode && options.write) {
|
|
181
|
+
console.log(chalk.yellow('Preview requested; ignoring --write and running in dry-run mode.'));
|
|
182
|
+
}
|
|
183
|
+
options.write = writeEnabled;
|
|
184
|
+
|
|
185
|
+
const banner = previewMode
|
|
186
|
+
? 'Generating transform preview...'
|
|
187
|
+
: writeEnabled
|
|
188
|
+
? 'Running transform (write mode)...'
|
|
189
|
+
: 'Planning transform (dry-run)...';
|
|
190
|
+
console.log(chalk.blue(banner));
|
|
91
191
|
|
|
92
192
|
try {
|
|
93
193
|
const { config, projectRoot, configPath } = await loadConfigWithMeta(options.config);
|
|
@@ -100,12 +200,20 @@ export function registerTransform(program: Command) {
|
|
|
100
200
|
}
|
|
101
201
|
|
|
102
202
|
const transformer = new Transformer(config, { workspaceRoot: projectRoot });
|
|
203
|
+
const progressLogger = createProgressLogger();
|
|
103
204
|
const summary = await transformer.run({
|
|
104
205
|
write: options.write,
|
|
105
206
|
targets: options.target,
|
|
106
|
-
diff:
|
|
207
|
+
diff: diffRequested,
|
|
107
208
|
migrateTextKeys: options.migrateTextKeys,
|
|
209
|
+
onProgress: progressLogger.emit,
|
|
108
210
|
});
|
|
211
|
+
progressLogger.flush();
|
|
212
|
+
|
|
213
|
+
if (previewMode && options.previewOutput) {
|
|
214
|
+
const savedPath = await writePreviewFile('transform', summary, options.previewOutput);
|
|
215
|
+
console.log(chalk.green(`Preview written to ${path.relative(process.cwd(), savedPath)}`));
|
|
216
|
+
}
|
|
109
217
|
|
|
110
218
|
if (options.report) {
|
|
111
219
|
const outputPath = path.resolve(process.cwd(), options.report);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import inquirer from 'inquirer';
|
|
7
|
+
import path from 'path';
|
|
7
8
|
import type { Command } from 'commander';
|
|
8
9
|
import {
|
|
9
10
|
DEFAULT_PLACEHOLDER_FORMATS,
|
|
@@ -19,6 +20,7 @@ import type { TranslateCommandOptions, TranslateSummary, ProviderSettings } from
|
|
|
19
20
|
import { emitTranslateOutput, maybePrintEstimate } from './reporter.js';
|
|
20
21
|
import { handleCsvExport, handleCsvImport } from './csv-handler.js';
|
|
21
22
|
import { executeTranslations } from './executor.js';
|
|
23
|
+
import { applyPreviewFile, writePreviewFile } from '../../utils/preview.js';
|
|
22
24
|
|
|
23
25
|
// Re-export types for external use
|
|
24
26
|
export * from './types.js';
|
|
@@ -98,7 +100,14 @@ export function registerTranslate(program: Command): void {
|
|
|
98
100
|
.option('--strict-placeholders', 'Fail if translated output has placeholder mismatches (for CI)', false)
|
|
99
101
|
.option('--export <path>', 'Export missing translations to a CSV file for external translation')
|
|
100
102
|
.option('--import <path>', 'Import translations from a CSV file and merge into locale files')
|
|
103
|
+
.option('--preview-output <path>', 'Write preview summary (JSON) to a file (implies dry-run)')
|
|
104
|
+
.option('--apply-preview <path>', 'Apply a previously saved translate preview JSON file safely')
|
|
101
105
|
.action(async (options: TranslateCommandOptions) => {
|
|
106
|
+
if (options.applyPreview) {
|
|
107
|
+
await applyPreviewFile('translate', options.applyPreview, ['--yes']);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
102
111
|
// Handle CSV export mode
|
|
103
112
|
if (options.export) {
|
|
104
113
|
await handleCsvExport(options);
|
|
@@ -111,9 +120,14 @@ export function registerTranslate(program: Command): void {
|
|
|
111
120
|
return;
|
|
112
121
|
}
|
|
113
122
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
)
|
|
123
|
+
const previewMode = Boolean(options.previewOutput);
|
|
124
|
+
const writeEnabled = Boolean(options.write) && !previewMode;
|
|
125
|
+
if (previewMode && options.write) {
|
|
126
|
+
console.log(chalk.yellow('Preview requested; ignoring --write and running in dry-run mode.'));
|
|
127
|
+
}
|
|
128
|
+
options.write = writeEnabled;
|
|
129
|
+
|
|
130
|
+
console.log(chalk.blue(writeEnabled ? 'Translating locale files...' : 'Planning translations (dry-run)...'));
|
|
117
131
|
|
|
118
132
|
try {
|
|
119
133
|
const config = await loadConfig(options.config);
|
|
@@ -142,6 +156,12 @@ export function registerTranslate(program: Command): void {
|
|
|
142
156
|
};
|
|
143
157
|
|
|
144
158
|
if (!options.write) {
|
|
159
|
+
if (previewMode && options.previewOutput) {
|
|
160
|
+
const savedPath = await writePreviewFile('translate', summary, options.previewOutput);
|
|
161
|
+
console.log(chalk.green(`Preview written to ${path.relative(process.cwd(), savedPath)}`));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
if (options.estimate && providerSettings.name !== 'manual') {
|
|
146
166
|
await maybePrintEstimate(plan, providerSettings);
|
|
147
167
|
}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest';
|
|
2
2
|
import fs from 'fs/promises';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import { execSync } from 'child_process';
|
|
6
|
+
import { ensureCliBuilt } from '../test-helpers/ensure-cli-built';
|
|
7
|
+
|
|
8
|
+
const CLI_PATH = path.resolve(__dirname, '..', '..', 'dist', 'index.js');
|
|
6
9
|
|
|
7
10
|
describe('translate command', () => {
|
|
8
11
|
let tempDir: string;
|
|
9
12
|
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
await ensureCliBuilt(CLI_PATH);
|
|
15
|
+
});
|
|
16
|
+
|
|
10
17
|
beforeEach(async () => {
|
|
11
18
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18nsmith-translate-test-'));
|
|
12
19
|
|
|
@@ -55,9 +62,8 @@ export function App() {
|
|
|
55
62
|
});
|
|
56
63
|
|
|
57
64
|
const runCli = (args: string): string => {
|
|
58
|
-
const cliPath = path.resolve(__dirname, '..', '..', 'dist', 'index.js');
|
|
59
65
|
try {
|
|
60
|
-
return execSync(`node ${
|
|
66
|
+
return execSync(`node ${CLI_PATH} ${args}`, {
|
|
61
67
|
cwd: tempDir,
|
|
62
68
|
encoding: 'utf8',
|
|
63
69
|
stdio: ['pipe', 'pipe', 'pipe'],
|
package/src/e2e.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import path from 'path';
|
|
|
9
9
|
import os from 'os';
|
|
10
10
|
import { spawnSync } from 'child_process';
|
|
11
11
|
import { fileURLToPath } from 'url';
|
|
12
|
+
import { ensureCliBuilt } from './test-helpers/ensure-cli-built';
|
|
12
13
|
|
|
13
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
15
|
const __dirname = path.dirname(__filename);
|
|
@@ -84,17 +85,8 @@ function extractJson<T>(output: string): T {
|
|
|
84
85
|
return JSON.parse(jsonMatch[0]);
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
beforeAll(() => {
|
|
88
|
-
|
|
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
|
-
}
|
|
88
|
+
beforeAll(async () => {
|
|
89
|
+
await ensureCliBuilt(CLI_PATH);
|
|
98
90
|
});
|
|
99
91
|
|
|
100
92
|
describe('E2E Fixture Tests', () => {
|
|
@@ -152,6 +144,50 @@ describe('E2E Fixture Tests', () => {
|
|
|
152
144
|
|
|
153
145
|
expect(parsed).toHaveProperty('diagnostics');
|
|
154
146
|
});
|
|
147
|
+
|
|
148
|
+
it('applies preview selections to add missing keys and prune unused keys', async () => {
|
|
149
|
+
const appFile = path.join(fixtureDir, 'src', 'App.tsx');
|
|
150
|
+
const localeFile = path.join(fixtureDir, 'locales', 'en.json');
|
|
151
|
+
const previewDir = path.join(fixtureDir, '.i18nsmith', 'previews');
|
|
152
|
+
const previewPath = path.join(previewDir, 'integration-preview.json');
|
|
153
|
+
const selectionPath = path.join(previewDir, 'integration-selection.json');
|
|
154
|
+
|
|
155
|
+
const appContent = await fs.readFile(appFile, 'utf8');
|
|
156
|
+
const injected = appContent.replace(
|
|
157
|
+
'</div>',
|
|
158
|
+
" <p>{t('integration.preview-missing')}</p>\n </div>"
|
|
159
|
+
);
|
|
160
|
+
await fs.writeFile(appFile, injected, 'utf8');
|
|
161
|
+
|
|
162
|
+
const locale = JSON.parse(await fs.readFile(localeFile, 'utf8'));
|
|
163
|
+
locale['unused.preview.key'] = 'Preview-only key';
|
|
164
|
+
await fs.writeFile(localeFile, JSON.stringify(locale, null, 2));
|
|
165
|
+
|
|
166
|
+
const previewResult = runCli(['sync', '--preview-output', previewPath], { cwd: fixtureDir });
|
|
167
|
+
expect(previewResult.exitCode).toBe(0);
|
|
168
|
+
|
|
169
|
+
const payload = JSON.parse(await fs.readFile(previewPath, 'utf8')) as {
|
|
170
|
+
summary: { missingKeys: { key: string }[]; unusedKeys: { key: string }[] };
|
|
171
|
+
};
|
|
172
|
+
const selection = {
|
|
173
|
+
missing: payload.summary.missingKeys.map((entry) => entry.key),
|
|
174
|
+
unused: payload.summary.unusedKeys.map((entry) => entry.key),
|
|
175
|
+
};
|
|
176
|
+
expect(selection.missing).toContain('integration.preview-missing');
|
|
177
|
+
expect(selection.unused).toContain('unused.preview.key');
|
|
178
|
+
await fs.mkdir(previewDir, { recursive: true });
|
|
179
|
+
await fs.writeFile(selectionPath, JSON.stringify(selection, null, 2));
|
|
180
|
+
|
|
181
|
+
const applyResult = runCli(
|
|
182
|
+
['sync', '--apply-preview', previewPath, '--selection-file', selectionPath, '--prune', '--yes'],
|
|
183
|
+
{ cwd: fixtureDir }
|
|
184
|
+
);
|
|
185
|
+
expect(applyResult.exitCode).toBe(0);
|
|
186
|
+
|
|
187
|
+
const finalLocale = JSON.parse(await fs.readFile(localeFile, 'utf8'));
|
|
188
|
+
expect(finalLocale).toHaveProperty('integration.preview-missing');
|
|
189
|
+
expect(finalLocale).not.toHaveProperty('unused.preview.key');
|
|
190
|
+
});
|
|
155
191
|
});
|
|
156
192
|
|
|
157
193
|
describe('suspicious-keys fixture', () => {
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { registerBackup } from './commands/backup.js';
|
|
|
15
15
|
import { registerRename } from './commands/rename.js';
|
|
16
16
|
import { registerInstallHooks } from './commands/install-hooks.js';
|
|
17
17
|
import { registerConfig } from './commands/config.js';
|
|
18
|
+
import { registerReview } from './commands/review.js';
|
|
18
19
|
|
|
19
20
|
export const program = new Command();
|
|
20
21
|
|
|
@@ -38,6 +39,7 @@ registerBackup(program);
|
|
|
38
39
|
registerRename(program);
|
|
39
40
|
registerInstallHooks(program);
|
|
40
41
|
registerConfig(program);
|
|
42
|
+
registerReview(program);
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
program.parse();
|
package/src/integration.test.ts
CHANGED
|
@@ -3,17 +3,19 @@
|
|
|
3
3
|
* These tests run the actual CLI commands against real file systems
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest';
|
|
7
7
|
import fs from 'fs/promises';
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import os from 'os';
|
|
10
10
|
import { spawnSync } from 'child_process';
|
|
11
11
|
import { fileURLToPath } from 'url';
|
|
12
|
+
import { ensureCliBuilt } from './test-helpers/ensure-cli-built';
|
|
12
13
|
|
|
13
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
15
|
const __dirname = path.dirname(__filename);
|
|
15
16
|
|
|
16
17
|
// Get the correct CLI path regardless of where tests are run from
|
|
18
|
+
// During tests, __dirname points to src/, so we go up one level to find dist/
|
|
17
19
|
const CLI_PATH = path.resolve(__dirname, '../dist/index.js');
|
|
18
20
|
|
|
19
21
|
// Helper to run CLI commands
|
|
@@ -33,6 +35,11 @@ function runCli(
|
|
|
33
35
|
},
|
|
34
36
|
});
|
|
35
37
|
|
|
38
|
+
// Log errors for debugging
|
|
39
|
+
if (result.error) {
|
|
40
|
+
console.error('CLI execution error:', result.error);
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
const stdout = result.stdout ?? '';
|
|
37
44
|
const stderr = result.stderr ?? '';
|
|
38
45
|
|
|
@@ -56,6 +63,10 @@ function extractJson<T>(output: string): T {
|
|
|
56
63
|
describe('CLI Integration Tests', () => {
|
|
57
64
|
let tmpDir: string;
|
|
58
65
|
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
await ensureCliBuilt(CLI_PATH);
|
|
68
|
+
});
|
|
69
|
+
|
|
59
70
|
beforeEach(async () => {
|
|
60
71
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18nsmith-cli-integration-'));
|
|
61
72
|
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { stat } from 'fs/promises';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_ATTEMPTS = 20;
|
|
4
|
+
const DEFAULT_DELAY_MS = 300;
|
|
5
|
+
const MIN_FILE_SIZE_BYTES = 1024;
|
|
6
|
+
|
|
7
|
+
function sleep(ms: number) {
|
|
8
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function ensureCliBuilt(cliPath: string): Promise<void> {
|
|
12
|
+
const maxAttempts = Number(process.env.I18NSMITH_TEST_CLI_ATTEMPTS ?? DEFAULT_MAX_ATTEMPTS);
|
|
13
|
+
const delayMs = Number(process.env.I18NSMITH_TEST_CLI_DELAY_MS ?? DEFAULT_DELAY_MS);
|
|
14
|
+
|
|
15
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
16
|
+
try {
|
|
17
|
+
const stats = await stat(cliPath);
|
|
18
|
+
if (stats.size >= MIN_FILE_SIZE_BYTES) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
// ignore and retry
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await sleep(delayMs);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
throw new Error(
|
|
29
|
+
`CLI not found at ${cliPath} after ${maxAttempts} attempts. ` +
|
|
30
|
+
"Ensure 'pnpm build' completes successfully before running tests."
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
|
|
10
|
+
const mockSpawn = vi.fn();
|
|
11
|
+
|
|
12
|
+
vi.mock('child_process', () => ({
|
|
13
|
+
spawn: (...args: Parameters<typeof mockSpawn>) => mockSpawn(...args),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
async function writePreviewFixture(args: string[]): Promise<string> {
|
|
17
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-test-'));
|
|
18
|
+
const previewPath = path.join(tempDir, 'sync-preview.json');
|
|
19
|
+
const payload = {
|
|
20
|
+
type: 'sync-preview',
|
|
21
|
+
version: 1,
|
|
22
|
+
command: 'i18nsmith sync --preview-output tmp.json',
|
|
23
|
+
args,
|
|
24
|
+
timestamp: new Date().toISOString(),
|
|
25
|
+
summary: { missingKeys: [], unusedKeys: [] },
|
|
26
|
+
};
|
|
27
|
+
await fs.writeFile(previewPath, JSON.stringify(payload, null, 2), 'utf8');
|
|
28
|
+
return previewPath;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function mockSuccessfulSpawn() {
|
|
32
|
+
mockSpawn.mockImplementation(() => {
|
|
33
|
+
const child = {
|
|
34
|
+
on(event: string, handler: (code?: number) => void) {
|
|
35
|
+
if (event === 'exit') {
|
|
36
|
+
setImmediate(() => handler(0));
|
|
37
|
+
}
|
|
38
|
+
return child;
|
|
39
|
+
},
|
|
40
|
+
} as unknown as ReturnType<typeof mockSpawn>;
|
|
41
|
+
return child;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('applyPreviewFile', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
mockSpawn.mockReset();
|
|
48
|
+
mockSuccessfulSpawn();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
vi.restoreAllMocks();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('replays preview with --write and extra args (selection + prune)', async () => {
|
|
56
|
+
const previewPath = await writePreviewFixture([
|
|
57
|
+
'sync',
|
|
58
|
+
'--target',
|
|
59
|
+
'src/app/**',
|
|
60
|
+
'--preview-output',
|
|
61
|
+
'tmp.json',
|
|
62
|
+
]);
|
|
63
|
+
const selectionFile = path.join(path.dirname(previewPath), 'selection.json');
|
|
64
|
+
|
|
65
|
+
const { applyPreviewFile } = await import('./preview.js');
|
|
66
|
+
|
|
67
|
+
await applyPreviewFile('sync', previewPath, ['--selection-file', selectionFile, '--prune']);
|
|
68
|
+
|
|
69
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
70
|
+
const [, spawnedArgs] = mockSpawn.mock.calls[0];
|
|
71
|
+
const [, ...forwardedArgs] = spawnedArgs as string[];
|
|
72
|
+
expect(forwardedArgs).toEqual([
|
|
73
|
+
'sync',
|
|
74
|
+
'--target',
|
|
75
|
+
'src/app/**',
|
|
76
|
+
'--write',
|
|
77
|
+
'--selection-file',
|
|
78
|
+
selectionFile,
|
|
79
|
+
'--prune',
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('does not duplicate --write when already present in preview args', async () => {
|
|
84
|
+
const previewPath = await writePreviewFixture(['sync', '--write', '--json']);
|
|
85
|
+
const { applyPreviewFile } = await import('./preview.js');
|
|
86
|
+
|
|
87
|
+
await applyPreviewFile('sync', previewPath);
|
|
88
|
+
|
|
89
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
90
|
+
const [, spawnedArgs] = mockSpawn.mock.calls[0];
|
|
91
|
+
const [, ...forwardedArgs] = spawnedArgs as string[];
|
|
92
|
+
expect(forwardedArgs).toEqual(['sync', '--write', '--json']);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
|
|
5
|
+
export interface PreviewPayload<TSummary> {
|
|
6
|
+
type: string;
|
|
7
|
+
version: number;
|
|
8
|
+
command: string;
|
|
9
|
+
args: string[];
|
|
10
|
+
timestamp: string;
|
|
11
|
+
summary: TSummary;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type PreviewKind = 'sync' | 'transform' | 'rename-key' | 'translate';
|
|
15
|
+
|
|
16
|
+
export async function writePreviewFile<TSummary>(
|
|
17
|
+
kind: PreviewKind,
|
|
18
|
+
summary: TSummary,
|
|
19
|
+
outputPath: string
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
const resolvedPath = path.resolve(process.cwd(), outputPath);
|
|
22
|
+
const payload: PreviewPayload<TSummary> = {
|
|
23
|
+
type: `${kind}-preview`,
|
|
24
|
+
version: 1,
|
|
25
|
+
command: buildCommandString(),
|
|
26
|
+
args: process.argv.slice(2),
|
|
27
|
+
timestamp: new Date().toISOString(),
|
|
28
|
+
summary,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
|
|
32
|
+
await fs.writeFile(resolvedPath, JSON.stringify(payload, null, 2), 'utf8');
|
|
33
|
+
return resolvedPath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function readPreviewFile<TSummary>(
|
|
37
|
+
expectedKind: PreviewKind,
|
|
38
|
+
previewPath: string
|
|
39
|
+
): Promise<PreviewPayload<TSummary>> {
|
|
40
|
+
const resolved = path.resolve(process.cwd(), previewPath);
|
|
41
|
+
const raw = await fs.readFile(resolved, 'utf8');
|
|
42
|
+
const payload = JSON.parse(raw) as PreviewPayload<TSummary>;
|
|
43
|
+
if (!payload?.type?.startsWith(`${expectedKind}-preview`)) {
|
|
44
|
+
throw new Error(`Preview kind mismatch. Expected ${expectedKind}, got ${payload?.type ?? 'unknown'}.`);
|
|
45
|
+
}
|
|
46
|
+
if (!Array.isArray(payload.args) || payload.args.length === 0) {
|
|
47
|
+
throw new Error('Preview file is missing recorded CLI arguments.');
|
|
48
|
+
}
|
|
49
|
+
return payload;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function applyPreviewFile(
|
|
53
|
+
expectedKind: PreviewKind,
|
|
54
|
+
previewPath: string,
|
|
55
|
+
extraArgs: string[] = []
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const payload = await readPreviewFile(expectedKind, previewPath);
|
|
58
|
+
const sanitizedArgs = sanitizePreviewArgs(payload.args);
|
|
59
|
+
const [command, ...rest] = sanitizedArgs;
|
|
60
|
+
if (!command) {
|
|
61
|
+
throw new Error('Preview file does not include the original command.');
|
|
62
|
+
}
|
|
63
|
+
if (command !== expectedKind) {
|
|
64
|
+
throw new Error(`Preview command mismatch. Expected ${expectedKind}, got ${command}.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const replayArgs = [command, ...rest];
|
|
68
|
+
if (!replayArgs.some((arg) => arg === '--write' || arg.startsWith('--write='))) {
|
|
69
|
+
replayArgs.push('--write');
|
|
70
|
+
}
|
|
71
|
+
for (const extra of extraArgs) {
|
|
72
|
+
if (!replayArgs.includes(extra)) {
|
|
73
|
+
replayArgs.push(extra);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log(`Applying preview from ${path.relative(process.cwd(), path.resolve(previewPath))}…`);
|
|
78
|
+
await spawnCli(replayArgs);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sanitizePreviewArgs(args: string[]): string[] {
|
|
82
|
+
const sanitized: string[] = [];
|
|
83
|
+
for (let i = 0; i < args.length; i++) {
|
|
84
|
+
const token = args[i];
|
|
85
|
+
if (token === '--preview-output') {
|
|
86
|
+
i += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (token?.startsWith('--preview-output=')) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
sanitized.push(token);
|
|
93
|
+
}
|
|
94
|
+
return sanitized;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function spawnCli(args: string[]): Promise<void> {
|
|
98
|
+
const entry = process.argv[1];
|
|
99
|
+
if (!entry) {
|
|
100
|
+
return Promise.reject(new Error('Unable to determine CLI entry point for preview apply.'));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const child = spawn(process.argv[0], [entry, ...args], {
|
|
105
|
+
stdio: 'inherit',
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
child.on('exit', (code) => {
|
|
109
|
+
if (code === 0) {
|
|
110
|
+
resolve();
|
|
111
|
+
} else {
|
|
112
|
+
reject(new Error(`Preview apply command exited with code ${code ?? 'unknown'}`));
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
child.on('error', (error) => {
|
|
117
|
+
reject(error);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildCommandString(): string {
|
|
123
|
+
// Ignore the node + script path, keep user arguments only
|
|
124
|
+
const args = process.argv.slice(2);
|
|
125
|
+
return args.length ? `i18nsmith ${args.join(' ')}` : 'i18nsmith';
|
|
126
|
+
}
|