/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; import * as esbuild from 'esbuild'; import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; import { type RawSourceMap, SourceMapConsumer } from 'source-map'; import { nlsPlugin, createNLSCollector, finalizeNLS, postProcessNLS } from '../nls-plugin.ts'; import { adjustSourceMap } from '../private-to-property.ts'; // analyzeLocalizeCalls requires the import path to end with `/nls` const NLS_STUB = [ 'export function localize(key: string, message: string, ...args: any[]): string {', '\treturn message;', '}', 'export function localize2(key: string, message: string, ...args: any[]): { value: string; original: string } {', '\treturn { value: message, original: message };', '}', ].join('\n'); interface BundleResult { js: string; mapJson: RawSourceMap; map: SourceMapConsumer; cleanup: () => void; } /** * Helper: create a temp directory with source files, bundle with NLS, and return * the generated JS + parsed source map. The NLS stub is automatically placed at * `vs/nls.ts` so test files can import from `../vs/nls` (when placed in `test/`). */ async function bundleWithNLS( files: Record, entryPoint: string, opts?: { postProcess?: boolean; minify?: boolean } ): Promise { const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nls-sm-test-')); const srcDir = path.join(tmpDir, 'src'); const outDir = path.join(tmpDir, 'out'); await fs.promises.mkdir(srcDir, { recursive: true }); await fs.promises.mkdir(outDir, { recursive: true }); // Write source files (always include the NLS stub at vs/nls.ts) const allFiles = { 'vs/nls.ts': NLS_STUB, ...files }; for (const [name, content] of Object.entries(allFiles)) { const filePath = path.join(srcDir, name); await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); await fs.promises.writeFile(filePath, content); } const collector = createNLSCollector(); const result = await esbuild.build({ entryPoints: [path.join(srcDir, entryPoint)], outfile: path.join(outDir, entryPoint.replace(/\.ts$/, '.js')), bundle: true, format: 'esm', platform: 'neutral', target: ['es2024'], packages: 'external', sourcemap: 'linked', sourcesContent: true, minify: opts?.minify ?? false, write: false, plugins: [ nlsPlugin({ baseDir: srcDir, collector }), ], tsconfigRaw: JSON.stringify({ compilerOptions: { experimentalDecorators: true, useDefineForClassFields: false } }), logLevel: 'warning', }); let jsContent = ''; let mapContent = ''; for (const file of result.outputFiles!) { if (file.path.endsWith('.js')) { jsContent = file.text; } else if (file.path.endsWith('.map')) { mapContent = file.text; } } // Optionally apply NLS post-processing (replaces placeholders with indices) if (opts?.postProcess) { const nlsResult = await finalizeNLS(collector, outDir); const preNLSCode = jsContent; const nlsProcessed = postProcessNLS(jsContent, nlsResult.indexMap, false); jsContent = nlsProcessed.code; // Adjust source map for NLS edits if (nlsProcessed.edits.length > 0) { const mapJson = JSON.parse(mapContent); const adjusted = adjustSourceMap(mapJson, preNLSCode, nlsProcessed.edits); mapContent = JSON.stringify(adjusted); } } assert.ok(jsContent, 'Expected JS output'); assert.ok(mapContent, 'Expected source map output'); const mapJson = JSON.parse(mapContent); const map = new SourceMapConsumer(mapJson); const cleanup = () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }; return { js: jsContent, mapJson, map, cleanup }; } /** * Find the 1-based line number in `text` that contains `needle`. */ function findLine(text: string, needle: string): number { const lines = text.split('\n'); for (let i = 0; i < lines.length; i++) { if (lines[i].includes(needle)) { return i + 1; // 1-based } } throw new Error(`Could not find "${needle}" in text`); } /** * Find the 0-based column of `needle` within the line that contains it. */ function findColumn(text: string, needle: string): number { const lines = text.split('\n'); for (const line of lines) { const col = line.indexOf(needle); if (col !== -1) { return col; } } throw new Error(`Could not find "${needle}" in text`); } suite('NLS plugin source maps', () => { test('NLS plugin transforms localize calls into placeholders', async () => { const source = [ 'import { localize } from "../vs/nls";', 'export const msg = localize("testKey", "Test Message");', ].join('\n'); const { js, cleanup } = await bundleWithNLS( { 'test/verify.ts': source }, 'test/verify.ts', ); try { assert.ok(js.includes('%%NLS:'), 'Bundle should contain %%NLS: placeholder.\nActual JS (first 500 chars):\n' + js.substring(0, 500)); } finally { cleanup(); } }); test('file without localize calls has correct source map', async () => { const source = [ 'export function add(a: number, b: number): number {', '\treturn a + b;', '}', ].join('\n'); const { js, map, cleanup } = await bundleWithNLS( { 'simple.ts': source }, 'simple.ts', ); try { const bundleLine = findLine(js, 'return a + b'); const bundleCol = findColumn(js, 'return a + b'); const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); assert.ok(pos.source, 'Should have source'); assert.strictEqual(pos.line, 2, 'Should map to line 2 of original'); } finally { cleanup(); } }); test('sourcesContent should contain original source, not NLS-transformed', async () => { const source = [ 'import { localize } from "../vs/nls";', 'export const msg = localize("myKey", "Hello World");', 'export function greet(): string {', '\treturn msg;', '}', ].join('\n'); const { mapJson, cleanup } = await bundleWithNLS( { 'test/greeting.ts': source }, 'test/greeting.ts', ); try { const sourcesContent: string[] = mapJson.sourcesContent ?? []; const sources: string[] = mapJson.sources ?? []; const greetingIdx = sources.findIndex((s: string) => s.includes('greeting')); assert.ok(greetingIdx >= 0, 'Should find greeting.ts in sources'); const greetingContent = sourcesContent[greetingIdx]; assert.ok(greetingContent, 'Should have sourcesContent for greeting.ts'); assert.ok(!greetingContent.includes('%%NLS:'), 'sourcesContent should NOT contain NLS placeholder.\nActual:\n' + greetingContent); assert.ok(greetingContent.includes('localize("myKey", "Hello World")'), 'sourcesContent should contain the exact original localize call.\nActual:\n' + greetingContent); } finally { cleanup(); } }); test('NLS-affected nested file keeps a non-duplicated source path', async () => { const source = [ 'import { localize } from "../../vs/nls";', 'export const msg = localize("myKey", "Hello World");', ].join('\n'); const { mapJson, cleanup } = await bundleWithNLS( { 'nested/deep/file.ts': source }, 'nested/deep/file.ts', ); try { const sources: string[] = mapJson.sources ?? []; const nestedSource = sources.find((s: string) => s.endsWith('/nested/deep/file.ts')); assert.ok(nestedSource, 'Should find nested/deep/file.ts in sources'); assert.ok(!nestedSource.includes('/nested/deep/nested/deep/file.ts'), `Source path should not duplicate directory segments. Actual: ${nestedSource}`); } finally { cleanup(); } }); test('line mapping correct for code after localize calls', async () => { const source = [ 'import { localize } from "../vs/nls";', // 1 'const label = localize("key1", "A long message");', // 2 'const label2 = localize("key2", "Another message");', // 3 'export function computeResult(x: number): number {', // 4 '\treturn x * 42;', // 5 '}', // 6 ].join('\n'); const { js, map, cleanup } = await bundleWithNLS( { 'test/multi.ts': source }, 'test/multi.ts', ); try { const bundleLine = findLine(js, 'return x * 42'); const bundleCol = findColumn(js, 'return x * 42'); const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); assert.ok(pos.source, 'Should have source'); assert.strictEqual(pos.line, 5, 'Should map back to line 5'); } finally { cleanup(); } }); test('column mapping for code on same line after localize call', async () => { // The NLS placeholder is longer than the original key, so column offsets // for tokens AFTER the localize call on the same line will drift if // source map mappings point to the NLS-transformed source positions. const source = [ 'import { localize } from "../vs/nls";', 'const x = localize("k", "m"); const z = "FINDME"; export { x, z };', ].join('\n'); const { js, map, cleanup } = await bundleWithNLS( { 'test/coldrift.ts': source }, 'test/coldrift.ts', ); try { assert.ok(js.includes('%%NLS:'), 'Bundle should contain NLS placeholders'); const bundleLine = findLine(js, 'FINDME'); const bundleCol = findColumn(js, '"FINDME"'); const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); assert.ok(pos.source, 'Should have source'); assert.strictEqual(pos.line, 2, 'Should map to line 2'); // The original column of "FINDME" in the source const originalCol = findColumn(source, '"FINDME"'); // The mapped column should match the ORIGINAL source positions. // Allow drift from TS->JS transform (const->var, export removal, etc.) // but NOT the large NLS placeholder drift (~100+ chars) from before the fix. const columnDrift = Math.abs(pos.column! - originalCol); assert.ok(columnDrift <= 20, `Column should be close to original. Expected ~${originalCol}, got ${pos.column} (drift: ${columnDrift}). ` + `A drift > 20 indicates the NLS placeholder shift leaked into the source map.`); } finally { cleanup(); } }); test('class with localize - method positions map correctly', async () => { const source = [ 'import { localize } from "../vs/nls";', // 1 '', // 2 'export class MyWidget {', // 3 '\tprivate readonly label = localize("widgetLabel", "My Cool Widget");', // 4 '', // 5 '\tconstructor(private readonly name: string) {}', // 6 '', // 7 '\tgetDescription(): string {', // 8 '\t\treturn this.name + ": " + this.label;', // 9 '\t}', // 10 '', // 11 '\tdispose(): void {', // 12 '\t\tconsole.log("disposed");', // 13 '\t}', // 14 '}', // 15 ].join('\n'); const { js, map, cleanup } = await bundleWithNLS( { 'test/widget.ts': source }, 'test/widget.ts', ); try { const bundleLine = findLine(js, '"disposed"'); const bundleCol = findColumn(js, 'console.log'); const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); assert.ok(pos.source, 'Should have source'); assert.strictEqual(pos.line, 13, 'Should map dispose method body to line 13'); } finally { cleanup(); } }); test('many localize calls - line mappings remain correct', async () => { const source = [ 'import { localize } from "../vs/nls";', // 1 '', // 2 'const a = localize("a", "Alpha");', // 3 'const b = localize("b", "Bravo with a longer message");', // 4 'const c = localize("c", "Charlie");', // 5 'const d = localize("d", "Delta is the fourth");', // 6 'const e = localize("e", "Echo");', // 7 '', // 8 'export function getAll(): string {', // 9 '\treturn [a, b, c, d, e].join(", ");', // 10 '}', // 11 ].join('\n'); const { js, map, cleanup } = await bundleWithNLS( { 'test/many.ts': source }, 'test/many.ts', ); try { const bundleLine = findLine(js, '.join(", ")'); const bundleCol = findColumn(js, '.join(", ")'); const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); assert.ok(pos.source, 'Should have source'); assert.strictEqual(pos.line, 10, 'Should map join() back to line 10'); } finally { cleanup(); } }); test('post-processed NLS - source map still has original content', async () => { const source = [ 'import { localize } from "../vs/nls";', 'export const msg = localize("greeting", "Hello World");', ].join('\n'); const { js, mapJson, cleanup } = await bundleWithNLS( { 'test/post.ts': source }, 'test/post.ts', { postProcess: true } ); try { assert.ok(!js.includes('%%NLS:'), 'JS should not contain NLS placeholders after post-processing'); const sources: string[] = mapJson.sources ?? []; const postIdx = sources.findIndex((s: string) => s.includes('post')); assert.ok(postIdx >= 0, 'Should find post.ts in sources'); const postContent = (mapJson.sourcesContent ?? [])[postIdx]; assert.ok(postContent, 'Should have sourcesContent for post.ts'); assert.ok(postContent.includes('localize("greeting"'), 'sourcesContent should still contain original localize("greeting") call'); assert.ok(!postContent.includes('%%NLS:'), 'sourcesContent should not contain NLS placeholders'); } finally { cleanup(); } }); test('post-processed NLS - column mappings correct after placeholder replacement', async () => { // NLS placeholders like "%%NLS:test/drift#k%%" are much longer than their // replacements (e.g. "0"). Without source map adjustment the columns for // tokens AFTER the replacement drift by the cumulative length delta. const source = [ 'import { localize } from "../vs/nls";', // 1 'export const a = localize("k1", "Alpha"); export const MARKER = "FINDME";', // 2 ].join('\n'); const { js, map, cleanup } = await bundleWithNLS( { 'test/drift.ts': source }, 'test/drift.ts', { postProcess: true } ); try { assert.ok(!js.includes('%%NLS:'), 'Placeholders should be replaced'); const bundleLine = findLine(js, 'FINDME'); const bundleCol = findColumn(js, '"FINDME"'); const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); assert.ok(pos.source, 'Should have source'); assert.strictEqual(pos.line, 2, 'Should map to line 2'); const originalCol = findColumn(source, '"FINDME"'); const columnDrift = Math.abs(pos.column! - originalCol); assert.ok(columnDrift <= 20, `Column drift after NLS post-processing should be small. ` + `Expected ~${originalCol}, got ${pos.column} (drift: ${columnDrift}). ` + `Large drift means postProcessNLS edits were not applied to the source map.`); } finally { cleanup(); } }); test('minified bundle with NLS - end-to-end column mapping', async () => { // With minification, the entire output is (roughly) on one line. // Multiple NLS replacements compound their column shifts. A function // defined after several localize() calls must still map correctly. const source = [ 'import { localize } from "../vs/nls";', // 1 '', // 2 'export const a = localize("k1", "Alpha message");', // 3 'export const b = localize("k2", "Bravo message that is quite long");', // 4 'export const c = localize("k3", "Charlie");', // 5 'export const d = localize("k4", "Delta is the fourth letter");', // 6 '', // 7 'export function computeResult(x: number): number {', // 8 '\treturn x * 42;', // 9 '}', // 10 ].join('\n'); const { js, map, cleanup } = await bundleWithNLS( { 'test/minified.ts': source }, 'test/minified.ts', { postProcess: true, minify: true } ); try { assert.ok(!js.includes('%%NLS:'), 'Placeholders should be replaced'); // Find the computeResult function in the minified output. // esbuild minifies `x * 42` and may rename the parameter, so // search for `*42` which survives both minification and renaming. const needle = '*42'; const bundleLine = findLine(js, needle); const bundleCol = findColumn(js, needle); const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); assert.ok(pos.source, 'Should have source for minified mapping'); assert.strictEqual(pos.line, 9, `Should map "*42" back to line 9. Got line ${pos.line}.`); } finally { cleanup(); } }); });