Path: blob/main/build/next/test/nls-sourcemap.test.ts
13383 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { suite, test } from 'node:test';7import * as esbuild from 'esbuild';8import * as path from 'path';9import * as fs from 'fs';10import * as os from 'os';11import { type RawSourceMap, SourceMapConsumer } from 'source-map';12import { nlsPlugin, createNLSCollector, finalizeNLS, postProcessNLS } from '../nls-plugin.ts';13import { adjustSourceMap } from '../private-to-property.ts';1415// analyzeLocalizeCalls requires the import path to end with `/nls`16const NLS_STUB = [17'export function localize(key: string, message: string, ...args: any[]): string {',18'\treturn message;',19'}',20'export function localize2(key: string, message: string, ...args: any[]): { value: string; original: string } {',21'\treturn { value: message, original: message };',22'}',23].join('\n');2425interface BundleResult {26js: string;27mapJson: RawSourceMap;28map: SourceMapConsumer;29cleanup: () => void;30}3132/**33* Helper: create a temp directory with source files, bundle with NLS, and return34* the generated JS + parsed source map. The NLS stub is automatically placed at35* `vs/nls.ts` so test files can import from `../vs/nls` (when placed in `test/`).36*/37async function bundleWithNLS(38files: Record<string, string>,39entryPoint: string,40opts?: { postProcess?: boolean; minify?: boolean }41): Promise<BundleResult> {42const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nls-sm-test-'));43const srcDir = path.join(tmpDir, 'src');44const outDir = path.join(tmpDir, 'out');45await fs.promises.mkdir(srcDir, { recursive: true });46await fs.promises.mkdir(outDir, { recursive: true });4748// Write source files (always include the NLS stub at vs/nls.ts)49const allFiles = { 'vs/nls.ts': NLS_STUB, ...files };50for (const [name, content] of Object.entries(allFiles)) {51const filePath = path.join(srcDir, name);52await fs.promises.mkdir(path.dirname(filePath), { recursive: true });53await fs.promises.writeFile(filePath, content);54}5556const collector = createNLSCollector();5758const result = await esbuild.build({59entryPoints: [path.join(srcDir, entryPoint)],60outfile: path.join(outDir, entryPoint.replace(/\.ts$/, '.js')),61bundle: true,62format: 'esm',63platform: 'neutral',64target: ['es2024'],65packages: 'external',66sourcemap: 'linked',67sourcesContent: true,68minify: opts?.minify ?? false,69write: false,70plugins: [71nlsPlugin({ baseDir: srcDir, collector }),72],73tsconfigRaw: JSON.stringify({74compilerOptions: {75experimentalDecorators: true,76useDefineForClassFields: false77}78}),79logLevel: 'warning',80});8182let jsContent = '';83let mapContent = '';8485for (const file of result.outputFiles!) {86if (file.path.endsWith('.js')) {87jsContent = file.text;88} else if (file.path.endsWith('.map')) {89mapContent = file.text;90}91}9293// Optionally apply NLS post-processing (replaces placeholders with indices)94if (opts?.postProcess) {95const nlsResult = await finalizeNLS(collector, outDir);96const preNLSCode = jsContent;97const nlsProcessed = postProcessNLS(jsContent, nlsResult.indexMap, false);98jsContent = nlsProcessed.code;99100// Adjust source map for NLS edits101if (nlsProcessed.edits.length > 0) {102const mapJson = JSON.parse(mapContent);103const adjusted = adjustSourceMap(mapJson, preNLSCode, nlsProcessed.edits);104mapContent = JSON.stringify(adjusted);105}106}107108assert.ok(jsContent, 'Expected JS output');109assert.ok(mapContent, 'Expected source map output');110111const mapJson = JSON.parse(mapContent);112const map = new SourceMapConsumer(mapJson);113const cleanup = () => {114fs.rmSync(tmpDir, { recursive: true, force: true });115};116117return { js: jsContent, mapJson, map, cleanup };118}119120/**121* Find the 1-based line number in `text` that contains `needle`.122*/123function findLine(text: string, needle: string): number {124const lines = text.split('\n');125for (let i = 0; i < lines.length; i++) {126if (lines[i].includes(needle)) {127return i + 1; // 1-based128}129}130throw new Error(`Could not find "${needle}" in text`);131}132133/**134* Find the 0-based column of `needle` within the line that contains it.135*/136function findColumn(text: string, needle: string): number {137const lines = text.split('\n');138for (const line of lines) {139const col = line.indexOf(needle);140if (col !== -1) {141return col;142}143}144throw new Error(`Could not find "${needle}" in text`);145}146147suite('NLS plugin source maps', () => {148149test('NLS plugin transforms localize calls into placeholders', async () => {150const source = [151'import { localize } from "../vs/nls";',152'export const msg = localize("testKey", "Test Message");',153].join('\n');154155const { js, cleanup } = await bundleWithNLS(156{ 'test/verify.ts': source },157'test/verify.ts',158);159160try {161assert.ok(js.includes('%%NLS:'),162'Bundle should contain %%NLS: placeholder.\nActual JS (first 500 chars):\n' + js.substring(0, 500));163} finally {164cleanup();165}166});167168test('file without localize calls has correct source map', async () => {169const source = [170'export function add(a: number, b: number): number {',171'\treturn a + b;',172'}',173].join('\n');174175const { js, map, cleanup } = await bundleWithNLS(176{ 'simple.ts': source },177'simple.ts',178);179180try {181const bundleLine = findLine(js, 'return a + b');182const bundleCol = findColumn(js, 'return a + b');183const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });184assert.ok(pos.source, 'Should have source');185assert.strictEqual(pos.line, 2, 'Should map to line 2 of original');186} finally {187cleanup();188}189});190191test('sourcesContent should contain original source, not NLS-transformed', async () => {192const source = [193'import { localize } from "../vs/nls";',194'export const msg = localize("myKey", "Hello World");',195'export function greet(): string {',196'\treturn msg;',197'}',198].join('\n');199200const { mapJson, cleanup } = await bundleWithNLS(201{ 'test/greeting.ts': source },202'test/greeting.ts',203);204205try {206const sourcesContent: string[] = mapJson.sourcesContent ?? [];207const sources: string[] = mapJson.sources ?? [];208const greetingIdx = sources.findIndex((s: string) => s.includes('greeting'));209assert.ok(greetingIdx >= 0, 'Should find greeting.ts in sources');210211const greetingContent = sourcesContent[greetingIdx];212assert.ok(greetingContent, 'Should have sourcesContent for greeting.ts');213214assert.ok(!greetingContent.includes('%%NLS:'),215'sourcesContent should NOT contain NLS placeholder.\nActual:\n' + greetingContent);216assert.ok(greetingContent.includes('localize("myKey", "Hello World")'),217'sourcesContent should contain the exact original localize call.\nActual:\n' + greetingContent);218} finally {219cleanup();220}221});222223test('NLS-affected nested file keeps a non-duplicated source path', async () => {224const source = [225'import { localize } from "../../vs/nls";',226'export const msg = localize("myKey", "Hello World");',227].join('\n');228229const { mapJson, cleanup } = await bundleWithNLS(230{ 'nested/deep/file.ts': source },231'nested/deep/file.ts',232);233234try {235const sources: string[] = mapJson.sources ?? [];236const nestedSource = sources.find((s: string) => s.endsWith('/nested/deep/file.ts'));237assert.ok(nestedSource, 'Should find nested/deep/file.ts in sources');238assert.ok(!nestedSource.includes('/nested/deep/nested/deep/file.ts'),239`Source path should not duplicate directory segments. Actual: ${nestedSource}`);240} finally {241cleanup();242}243});244245test('line mapping correct for code after localize calls', async () => {246const source = [247'import { localize } from "../vs/nls";', // 1248'const label = localize("key1", "A long message");', // 2249'const label2 = localize("key2", "Another message");', // 3250'export function computeResult(x: number): number {', // 4251'\treturn x * 42;', // 5252'}', // 6253].join('\n');254255const { js, map, cleanup } = await bundleWithNLS(256{ 'test/multi.ts': source },257'test/multi.ts',258);259260try {261const bundleLine = findLine(js, 'return x * 42');262const bundleCol = findColumn(js, 'return x * 42');263const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });264assert.ok(pos.source, 'Should have source');265assert.strictEqual(pos.line, 5, 'Should map back to line 5');266} finally {267cleanup();268}269});270271test('column mapping for code on same line after localize call', async () => {272// The NLS placeholder is longer than the original key, so column offsets273// for tokens AFTER the localize call on the same line will drift if274// source map mappings point to the NLS-transformed source positions.275const source = [276'import { localize } from "../vs/nls";',277'const x = localize("k", "m"); const z = "FINDME"; export { x, z };',278].join('\n');279280const { js, map, cleanup } = await bundleWithNLS(281{ 'test/coldrift.ts': source },282'test/coldrift.ts',283);284285try {286assert.ok(js.includes('%%NLS:'), 'Bundle should contain NLS placeholders');287288const bundleLine = findLine(js, 'FINDME');289const bundleCol = findColumn(js, '"FINDME"');290const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });291292assert.ok(pos.source, 'Should have source');293assert.strictEqual(pos.line, 2, 'Should map to line 2');294295// The original column of "FINDME" in the source296const originalCol = findColumn(source, '"FINDME"');297298// The mapped column should match the ORIGINAL source positions.299// Allow drift from TS->JS transform (const->var, export removal, etc.)300// but NOT the large NLS placeholder drift (~100+ chars) from before the fix.301const columnDrift = Math.abs(pos.column! - originalCol);302assert.ok(columnDrift <= 20,303`Column should be close to original. Expected ~${originalCol}, got ${pos.column} (drift: ${columnDrift}). ` +304`A drift > 20 indicates the NLS placeholder shift leaked into the source map.`);305} finally {306cleanup();307}308});309310test('class with localize - method positions map correctly', async () => {311const source = [312'import { localize } from "../vs/nls";', // 1313'', // 2314'export class MyWidget {', // 3315'\tprivate readonly label = localize("widgetLabel", "My Cool Widget");', // 4316'', // 5317'\tconstructor(private readonly name: string) {}', // 6318'', // 7319'\tgetDescription(): string {', // 8320'\t\treturn this.name + ": " + this.label;', // 9321'\t}', // 10322'', // 11323'\tdispose(): void {', // 12324'\t\tconsole.log("disposed");', // 13325'\t}', // 14326'}', // 15327].join('\n');328329const { js, map, cleanup } = await bundleWithNLS(330{ 'test/widget.ts': source },331'test/widget.ts',332);333334try {335const bundleLine = findLine(js, '"disposed"');336const bundleCol = findColumn(js, 'console.log');337const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });338assert.ok(pos.source, 'Should have source');339assert.strictEqual(pos.line, 13, 'Should map dispose method body to line 13');340} finally {341cleanup();342}343});344345test('many localize calls - line mappings remain correct', async () => {346const source = [347'import { localize } from "../vs/nls";', // 1348'', // 2349'const a = localize("a", "Alpha");', // 3350'const b = localize("b", "Bravo with a longer message");', // 4351'const c = localize("c", "Charlie");', // 5352'const d = localize("d", "Delta is the fourth");', // 6353'const e = localize("e", "Echo");', // 7354'', // 8355'export function getAll(): string {', // 9356'\treturn [a, b, c, d, e].join(", ");', // 10357'}', // 11358].join('\n');359360const { js, map, cleanup } = await bundleWithNLS(361{ 'test/many.ts': source },362'test/many.ts',363);364365try {366const bundleLine = findLine(js, '.join(", ")');367const bundleCol = findColumn(js, '.join(", ")');368const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });369assert.ok(pos.source, 'Should have source');370assert.strictEqual(pos.line, 10, 'Should map join() back to line 10');371} finally {372cleanup();373}374});375376test('post-processed NLS - source map still has original content', async () => {377const source = [378'import { localize } from "../vs/nls";',379'export const msg = localize("greeting", "Hello World");',380].join('\n');381382const { js, mapJson, cleanup } = await bundleWithNLS(383{ 'test/post.ts': source },384'test/post.ts',385{ postProcess: true }386);387388try {389assert.ok(!js.includes('%%NLS:'), 'JS should not contain NLS placeholders after post-processing');390391const sources: string[] = mapJson.sources ?? [];392const postIdx = sources.findIndex((s: string) => s.includes('post'));393assert.ok(postIdx >= 0, 'Should find post.ts in sources');394395const postContent = (mapJson.sourcesContent ?? [])[postIdx];396assert.ok(postContent, 'Should have sourcesContent for post.ts');397398assert.ok(postContent.includes('localize("greeting"'),399'sourcesContent should still contain original localize("greeting") call');400assert.ok(!postContent.includes('%%NLS:'),401'sourcesContent should not contain NLS placeholders');402} finally {403cleanup();404}405});406407test('post-processed NLS - column mappings correct after placeholder replacement', async () => {408// NLS placeholders like "%%NLS:test/drift#k%%" are much longer than their409// replacements (e.g. "0"). Without source map adjustment the columns for410// tokens AFTER the replacement drift by the cumulative length delta.411const source = [412'import { localize } from "../vs/nls";', // 1413'export const a = localize("k1", "Alpha"); export const MARKER = "FINDME";', // 2414].join('\n');415416const { js, map, cleanup } = await bundleWithNLS(417{ 'test/drift.ts': source },418'test/drift.ts',419{ postProcess: true }420);421422try {423assert.ok(!js.includes('%%NLS:'), 'Placeholders should be replaced');424425const bundleLine = findLine(js, 'FINDME');426const bundleCol = findColumn(js, '"FINDME"');427const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });428429assert.ok(pos.source, 'Should have source');430assert.strictEqual(pos.line, 2, 'Should map to line 2');431432const originalCol = findColumn(source, '"FINDME"');433const columnDrift = Math.abs(pos.column! - originalCol);434assert.ok(columnDrift <= 20,435`Column drift after NLS post-processing should be small. ` +436`Expected ~${originalCol}, got ${pos.column} (drift: ${columnDrift}). ` +437`Large drift means postProcessNLS edits were not applied to the source map.`);438} finally {439cleanup();440}441});442443test('minified bundle with NLS - end-to-end column mapping', async () => {444// With minification, the entire output is (roughly) on one line.445// Multiple NLS replacements compound their column shifts. A function446// defined after several localize() calls must still map correctly.447const source = [448'import { localize } from "../vs/nls";', // 1449'', // 2450'export const a = localize("k1", "Alpha message");', // 3451'export const b = localize("k2", "Bravo message that is quite long");', // 4452'export const c = localize("k3", "Charlie");', // 5453'export const d = localize("k4", "Delta is the fourth letter");', // 6454'', // 7455'export function computeResult(x: number): number {', // 8456'\treturn x * 42;', // 9457'}', // 10458].join('\n');459460const { js, map, cleanup } = await bundleWithNLS(461{ 'test/minified.ts': source },462'test/minified.ts',463{ postProcess: true, minify: true }464);465466try {467assert.ok(!js.includes('%%NLS:'), 'Placeholders should be replaced');468469// Find the computeResult function in the minified output.470// esbuild minifies `x * 42` and may rename the parameter, so471// search for `*42` which survives both minification and renaming.472const needle = '*42';473const bundleLine = findLine(js, needle);474const bundleCol = findColumn(js, needle);475const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });476477assert.ok(pos.source, 'Should have source for minified mapping');478assert.strictEqual(pos.line, 9,479`Should map "*42" back to line 9. Got line ${pos.line}.`);480} finally {481cleanup();482}483});484});485486487