Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/build/next/test/nls-sourcemap.test.ts
13383 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { suite, test } from 'node:test';
8
import * as esbuild from 'esbuild';
9
import * as path from 'path';
10
import * as fs from 'fs';
11
import * as os from 'os';
12
import { type RawSourceMap, SourceMapConsumer } from 'source-map';
13
import { nlsPlugin, createNLSCollector, finalizeNLS, postProcessNLS } from '../nls-plugin.ts';
14
import { adjustSourceMap } from '../private-to-property.ts';
15
16
// analyzeLocalizeCalls requires the import path to end with `/nls`
17
const NLS_STUB = [
18
'export function localize(key: string, message: string, ...args: any[]): string {',
19
'\treturn message;',
20
'}',
21
'export function localize2(key: string, message: string, ...args: any[]): { value: string; original: string } {',
22
'\treturn { value: message, original: message };',
23
'}',
24
].join('\n');
25
26
interface BundleResult {
27
js: string;
28
mapJson: RawSourceMap;
29
map: SourceMapConsumer;
30
cleanup: () => void;
31
}
32
33
/**
34
* Helper: create a temp directory with source files, bundle with NLS, and return
35
* the generated JS + parsed source map. The NLS stub is automatically placed at
36
* `vs/nls.ts` so test files can import from `../vs/nls` (when placed in `test/`).
37
*/
38
async function bundleWithNLS(
39
files: Record<string, string>,
40
entryPoint: string,
41
opts?: { postProcess?: boolean; minify?: boolean }
42
): Promise<BundleResult> {
43
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nls-sm-test-'));
44
const srcDir = path.join(tmpDir, 'src');
45
const outDir = path.join(tmpDir, 'out');
46
await fs.promises.mkdir(srcDir, { recursive: true });
47
await fs.promises.mkdir(outDir, { recursive: true });
48
49
// Write source files (always include the NLS stub at vs/nls.ts)
50
const allFiles = { 'vs/nls.ts': NLS_STUB, ...files };
51
for (const [name, content] of Object.entries(allFiles)) {
52
const filePath = path.join(srcDir, name);
53
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
54
await fs.promises.writeFile(filePath, content);
55
}
56
57
const collector = createNLSCollector();
58
59
const result = await esbuild.build({
60
entryPoints: [path.join(srcDir, entryPoint)],
61
outfile: path.join(outDir, entryPoint.replace(/\.ts$/, '.js')),
62
bundle: true,
63
format: 'esm',
64
platform: 'neutral',
65
target: ['es2024'],
66
packages: 'external',
67
sourcemap: 'linked',
68
sourcesContent: true,
69
minify: opts?.minify ?? false,
70
write: false,
71
plugins: [
72
nlsPlugin({ baseDir: srcDir, collector }),
73
],
74
tsconfigRaw: JSON.stringify({
75
compilerOptions: {
76
experimentalDecorators: true,
77
useDefineForClassFields: false
78
}
79
}),
80
logLevel: 'warning',
81
});
82
83
let jsContent = '';
84
let mapContent = '';
85
86
for (const file of result.outputFiles!) {
87
if (file.path.endsWith('.js')) {
88
jsContent = file.text;
89
} else if (file.path.endsWith('.map')) {
90
mapContent = file.text;
91
}
92
}
93
94
// Optionally apply NLS post-processing (replaces placeholders with indices)
95
if (opts?.postProcess) {
96
const nlsResult = await finalizeNLS(collector, outDir);
97
const preNLSCode = jsContent;
98
const nlsProcessed = postProcessNLS(jsContent, nlsResult.indexMap, false);
99
jsContent = nlsProcessed.code;
100
101
// Adjust source map for NLS edits
102
if (nlsProcessed.edits.length > 0) {
103
const mapJson = JSON.parse(mapContent);
104
const adjusted = adjustSourceMap(mapJson, preNLSCode, nlsProcessed.edits);
105
mapContent = JSON.stringify(adjusted);
106
}
107
}
108
109
assert.ok(jsContent, 'Expected JS output');
110
assert.ok(mapContent, 'Expected source map output');
111
112
const mapJson = JSON.parse(mapContent);
113
const map = new SourceMapConsumer(mapJson);
114
const cleanup = () => {
115
fs.rmSync(tmpDir, { recursive: true, force: true });
116
};
117
118
return { js: jsContent, mapJson, map, cleanup };
119
}
120
121
/**
122
* Find the 1-based line number in `text` that contains `needle`.
123
*/
124
function findLine(text: string, needle: string): number {
125
const lines = text.split('\n');
126
for (let i = 0; i < lines.length; i++) {
127
if (lines[i].includes(needle)) {
128
return i + 1; // 1-based
129
}
130
}
131
throw new Error(`Could not find "${needle}" in text`);
132
}
133
134
/**
135
* Find the 0-based column of `needle` within the line that contains it.
136
*/
137
function findColumn(text: string, needle: string): number {
138
const lines = text.split('\n');
139
for (const line of lines) {
140
const col = line.indexOf(needle);
141
if (col !== -1) {
142
return col;
143
}
144
}
145
throw new Error(`Could not find "${needle}" in text`);
146
}
147
148
suite('NLS plugin source maps', () => {
149
150
test('NLS plugin transforms localize calls into placeholders', async () => {
151
const source = [
152
'import { localize } from "../vs/nls";',
153
'export const msg = localize("testKey", "Test Message");',
154
].join('\n');
155
156
const { js, cleanup } = await bundleWithNLS(
157
{ 'test/verify.ts': source },
158
'test/verify.ts',
159
);
160
161
try {
162
assert.ok(js.includes('%%NLS:'),
163
'Bundle should contain %%NLS: placeholder.\nActual JS (first 500 chars):\n' + js.substring(0, 500));
164
} finally {
165
cleanup();
166
}
167
});
168
169
test('file without localize calls has correct source map', async () => {
170
const source = [
171
'export function add(a: number, b: number): number {',
172
'\treturn a + b;',
173
'}',
174
].join('\n');
175
176
const { js, map, cleanup } = await bundleWithNLS(
177
{ 'simple.ts': source },
178
'simple.ts',
179
);
180
181
try {
182
const bundleLine = findLine(js, 'return a + b');
183
const bundleCol = findColumn(js, 'return a + b');
184
const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });
185
assert.ok(pos.source, 'Should have source');
186
assert.strictEqual(pos.line, 2, 'Should map to line 2 of original');
187
} finally {
188
cleanup();
189
}
190
});
191
192
test('sourcesContent should contain original source, not NLS-transformed', async () => {
193
const source = [
194
'import { localize } from "../vs/nls";',
195
'export const msg = localize("myKey", "Hello World");',
196
'export function greet(): string {',
197
'\treturn msg;',
198
'}',
199
].join('\n');
200
201
const { mapJson, cleanup } = await bundleWithNLS(
202
{ 'test/greeting.ts': source },
203
'test/greeting.ts',
204
);
205
206
try {
207
const sourcesContent: string[] = mapJson.sourcesContent ?? [];
208
const sources: string[] = mapJson.sources ?? [];
209
const greetingIdx = sources.findIndex((s: string) => s.includes('greeting'));
210
assert.ok(greetingIdx >= 0, 'Should find greeting.ts in sources');
211
212
const greetingContent = sourcesContent[greetingIdx];
213
assert.ok(greetingContent, 'Should have sourcesContent for greeting.ts');
214
215
assert.ok(!greetingContent.includes('%%NLS:'),
216
'sourcesContent should NOT contain NLS placeholder.\nActual:\n' + greetingContent);
217
assert.ok(greetingContent.includes('localize("myKey", "Hello World")'),
218
'sourcesContent should contain the exact original localize call.\nActual:\n' + greetingContent);
219
} finally {
220
cleanup();
221
}
222
});
223
224
test('NLS-affected nested file keeps a non-duplicated source path', async () => {
225
const source = [
226
'import { localize } from "../../vs/nls";',
227
'export const msg = localize("myKey", "Hello World");',
228
].join('\n');
229
230
const { mapJson, cleanup } = await bundleWithNLS(
231
{ 'nested/deep/file.ts': source },
232
'nested/deep/file.ts',
233
);
234
235
try {
236
const sources: string[] = mapJson.sources ?? [];
237
const nestedSource = sources.find((s: string) => s.endsWith('/nested/deep/file.ts'));
238
assert.ok(nestedSource, 'Should find nested/deep/file.ts in sources');
239
assert.ok(!nestedSource.includes('/nested/deep/nested/deep/file.ts'),
240
`Source path should not duplicate directory segments. Actual: ${nestedSource}`);
241
} finally {
242
cleanup();
243
}
244
});
245
246
test('line mapping correct for code after localize calls', async () => {
247
const source = [
248
'import { localize } from "../vs/nls";', // 1
249
'const label = localize("key1", "A long message");', // 2
250
'const label2 = localize("key2", "Another message");', // 3
251
'export function computeResult(x: number): number {', // 4
252
'\treturn x * 42;', // 5
253
'}', // 6
254
].join('\n');
255
256
const { js, map, cleanup } = await bundleWithNLS(
257
{ 'test/multi.ts': source },
258
'test/multi.ts',
259
);
260
261
try {
262
const bundleLine = findLine(js, 'return x * 42');
263
const bundleCol = findColumn(js, 'return x * 42');
264
const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });
265
assert.ok(pos.source, 'Should have source');
266
assert.strictEqual(pos.line, 5, 'Should map back to line 5');
267
} finally {
268
cleanup();
269
}
270
});
271
272
test('column mapping for code on same line after localize call', async () => {
273
// The NLS placeholder is longer than the original key, so column offsets
274
// for tokens AFTER the localize call on the same line will drift if
275
// source map mappings point to the NLS-transformed source positions.
276
const source = [
277
'import { localize } from "../vs/nls";',
278
'const x = localize("k", "m"); const z = "FINDME"; export { x, z };',
279
].join('\n');
280
281
const { js, map, cleanup } = await bundleWithNLS(
282
{ 'test/coldrift.ts': source },
283
'test/coldrift.ts',
284
);
285
286
try {
287
assert.ok(js.includes('%%NLS:'), 'Bundle should contain NLS placeholders');
288
289
const bundleLine = findLine(js, 'FINDME');
290
const bundleCol = findColumn(js, '"FINDME"');
291
const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });
292
293
assert.ok(pos.source, 'Should have source');
294
assert.strictEqual(pos.line, 2, 'Should map to line 2');
295
296
// The original column of "FINDME" in the source
297
const originalCol = findColumn(source, '"FINDME"');
298
299
// The mapped column should match the ORIGINAL source positions.
300
// Allow drift from TS->JS transform (const->var, export removal, etc.)
301
// but NOT the large NLS placeholder drift (~100+ chars) from before the fix.
302
const columnDrift = Math.abs(pos.column! - originalCol);
303
assert.ok(columnDrift <= 20,
304
`Column should be close to original. Expected ~${originalCol}, got ${pos.column} (drift: ${columnDrift}). ` +
305
`A drift > 20 indicates the NLS placeholder shift leaked into the source map.`);
306
} finally {
307
cleanup();
308
}
309
});
310
311
test('class with localize - method positions map correctly', async () => {
312
const source = [
313
'import { localize } from "../vs/nls";', // 1
314
'', // 2
315
'export class MyWidget {', // 3
316
'\tprivate readonly label = localize("widgetLabel", "My Cool Widget");', // 4
317
'', // 5
318
'\tconstructor(private readonly name: string) {}', // 6
319
'', // 7
320
'\tgetDescription(): string {', // 8
321
'\t\treturn this.name + ": " + this.label;', // 9
322
'\t}', // 10
323
'', // 11
324
'\tdispose(): void {', // 12
325
'\t\tconsole.log("disposed");', // 13
326
'\t}', // 14
327
'}', // 15
328
].join('\n');
329
330
const { js, map, cleanup } = await bundleWithNLS(
331
{ 'test/widget.ts': source },
332
'test/widget.ts',
333
);
334
335
try {
336
const bundleLine = findLine(js, '"disposed"');
337
const bundleCol = findColumn(js, 'console.log');
338
const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });
339
assert.ok(pos.source, 'Should have source');
340
assert.strictEqual(pos.line, 13, 'Should map dispose method body to line 13');
341
} finally {
342
cleanup();
343
}
344
});
345
346
test('many localize calls - line mappings remain correct', async () => {
347
const source = [
348
'import { localize } from "../vs/nls";', // 1
349
'', // 2
350
'const a = localize("a", "Alpha");', // 3
351
'const b = localize("b", "Bravo with a longer message");', // 4
352
'const c = localize("c", "Charlie");', // 5
353
'const d = localize("d", "Delta is the fourth");', // 6
354
'const e = localize("e", "Echo");', // 7
355
'', // 8
356
'export function getAll(): string {', // 9
357
'\treturn [a, b, c, d, e].join(", ");', // 10
358
'}', // 11
359
].join('\n');
360
361
const { js, map, cleanup } = await bundleWithNLS(
362
{ 'test/many.ts': source },
363
'test/many.ts',
364
);
365
366
try {
367
const bundleLine = findLine(js, '.join(", ")');
368
const bundleCol = findColumn(js, '.join(", ")');
369
const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });
370
assert.ok(pos.source, 'Should have source');
371
assert.strictEqual(pos.line, 10, 'Should map join() back to line 10');
372
} finally {
373
cleanup();
374
}
375
});
376
377
test('post-processed NLS - source map still has original content', async () => {
378
const source = [
379
'import { localize } from "../vs/nls";',
380
'export const msg = localize("greeting", "Hello World");',
381
].join('\n');
382
383
const { js, mapJson, cleanup } = await bundleWithNLS(
384
{ 'test/post.ts': source },
385
'test/post.ts',
386
{ postProcess: true }
387
);
388
389
try {
390
assert.ok(!js.includes('%%NLS:'), 'JS should not contain NLS placeholders after post-processing');
391
392
const sources: string[] = mapJson.sources ?? [];
393
const postIdx = sources.findIndex((s: string) => s.includes('post'));
394
assert.ok(postIdx >= 0, 'Should find post.ts in sources');
395
396
const postContent = (mapJson.sourcesContent ?? [])[postIdx];
397
assert.ok(postContent, 'Should have sourcesContent for post.ts');
398
399
assert.ok(postContent.includes('localize("greeting"'),
400
'sourcesContent should still contain original localize("greeting") call');
401
assert.ok(!postContent.includes('%%NLS:'),
402
'sourcesContent should not contain NLS placeholders');
403
} finally {
404
cleanup();
405
}
406
});
407
408
test('post-processed NLS - column mappings correct after placeholder replacement', async () => {
409
// NLS placeholders like "%%NLS:test/drift#k%%" are much longer than their
410
// replacements (e.g. "0"). Without source map adjustment the columns for
411
// tokens AFTER the replacement drift by the cumulative length delta.
412
const source = [
413
'import { localize } from "../vs/nls";', // 1
414
'export const a = localize("k1", "Alpha"); export const MARKER = "FINDME";', // 2
415
].join('\n');
416
417
const { js, map, cleanup } = await bundleWithNLS(
418
{ 'test/drift.ts': source },
419
'test/drift.ts',
420
{ postProcess: true }
421
);
422
423
try {
424
assert.ok(!js.includes('%%NLS:'), 'Placeholders should be replaced');
425
426
const bundleLine = findLine(js, 'FINDME');
427
const bundleCol = findColumn(js, '"FINDME"');
428
const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });
429
430
assert.ok(pos.source, 'Should have source');
431
assert.strictEqual(pos.line, 2, 'Should map to line 2');
432
433
const originalCol = findColumn(source, '"FINDME"');
434
const columnDrift = Math.abs(pos.column! - originalCol);
435
assert.ok(columnDrift <= 20,
436
`Column drift after NLS post-processing should be small. ` +
437
`Expected ~${originalCol}, got ${pos.column} (drift: ${columnDrift}). ` +
438
`Large drift means postProcessNLS edits were not applied to the source map.`);
439
} finally {
440
cleanup();
441
}
442
});
443
444
test('minified bundle with NLS - end-to-end column mapping', async () => {
445
// With minification, the entire output is (roughly) on one line.
446
// Multiple NLS replacements compound their column shifts. A function
447
// defined after several localize() calls must still map correctly.
448
const source = [
449
'import { localize } from "../vs/nls";', // 1
450
'', // 2
451
'export const a = localize("k1", "Alpha message");', // 3
452
'export const b = localize("k2", "Bravo message that is quite long");', // 4
453
'export const c = localize("k3", "Charlie");', // 5
454
'export const d = localize("k4", "Delta is the fourth letter");', // 6
455
'', // 7
456
'export function computeResult(x: number): number {', // 8
457
'\treturn x * 42;', // 9
458
'}', // 10
459
].join('\n');
460
461
const { js, map, cleanup } = await bundleWithNLS(
462
{ 'test/minified.ts': source },
463
'test/minified.ts',
464
{ postProcess: true, minify: true }
465
);
466
467
try {
468
assert.ok(!js.includes('%%NLS:'), 'Placeholders should be replaced');
469
470
// Find the computeResult function in the minified output.
471
// esbuild minifies `x * 42` and may rename the parameter, so
472
// search for `*42` which survives both minification and renaming.
473
const needle = '*42';
474
const bundleLine = findLine(js, needle);
475
const bundleCol = findColumn(js, needle);
476
const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });
477
478
assert.ok(pos.source, 'Should have source for minified mapping');
479
assert.strictEqual(pos.line, 9,
480
`Should map "*42" back to line 9. Got line ${pos.line}.`);
481
} finally {
482
cleanup();
483
}
484
});
485
});
486
487