From 03894c861c37ceb3da891195dc7303b76c4caab2 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 2 Nov 2026 22:45:01 -0700 Subject: [PATCH 1/5] fix: profile fresh native JavaScript instead of loader transforms --- .github/workflows/core-profiles.yml | 2 +- docs/guides/testing.md | 6 +++-- scripts/profile-core.mjs | 38 +++++++++++++++++++++++------ tests/core-profiles.test.ts | 11 +++++++-- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/.github/workflows/core-profiles.yml b/.github/workflows/core-profiles.yml index a9a8481..7b1a5a9 100644 --- a/.github/workflows/core-profiles.yml +++ b/.github/workflows/core-profiles.yml @@ -20,7 +20,7 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - - run: node --expose-gc --import tsx scripts/profile-core.mjs artifacts/core-profiles + - run: node --expose-gc scripts/profile-core.mjs artifacts/core-profiles - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() with: diff --git a/docs/guides/testing.md b/docs/guides/testing.md index 1d1e24f..dd531ac 100644 --- a/docs/guides/testing.md +++ b/docs/guides/testing.md @@ -79,10 +79,12 @@ After running both corpus audits, add `--corpus` to compare both revisions in co Run the manual **Core workload profiles** workflow for isolated profiles of the current revision, or use Node 24 locally during a reserved quiet slot: ```sh -node --expose-gc --import tsx scripts/profile-core.mjs artifacts/core-profiles +node --expose-gc scripts/profile-core.mjs artifacts/core-profiles ``` -Choose a new output directory for each run. Exclusive directory creation prevents simultaneous runs from overwriting one another or earlier results. The output includes raw CPU profiles, compressed sampled-allocation profiles, completed-output hashes, source and lockfile hashes, and runtime metadata. The run fails if its source, lockfile or profiling script changes while it runs. Workloads separate parsing, parsing with HTML rendering, cached-AST rendering, and progressive updates using the existing fixtures. Progressive profiles consume each update without retaining earlier rendered strings; a separate trace hash checks every update outside profiling. +The script uses the installed project TypeScript compiler and build configuration to emit fresh JavaScript into the output directory before profiling. It leaves the working `dist` directory untouched and records the compiler version, build inputs and emitted module hashes. Compilation is outside profiling. Use plain Node without module loaders or preload hooks, since loader transforms can add costs absent from the shipped JavaScript. + +Choose a new output directory for each run. Exclusive directory creation prevents simultaneous runs from overwriting one another or earlier results. The output includes raw CPU profiles, compressed sampled-allocation profiles, completed-output hashes, source and lockfile hashes, and runtime metadata. The run fails if its source, build inputs, emitted JavaScript, lockfile or profiling script changes while it runs. Workloads separate parsing, parsing with HTML rendering, cached-AST rendering, and progressive updates using the existing fixtures. Progressive profiles consume each update without retaining earlier rendered strings; a separate trace hash checks every update outside profiling. These profiles identify leads, not throughput or startup improvements. CPU attribution is sampled separately for each workload. Sampled allocation includes collected objects and does not measure retained heap, RSS or peak memory. Confirm promising changes with repeated equivalent-work comparisons and unchanged size gates. diff --git a/scripts/profile-core.mjs b/scripts/profile-core.mjs index da80f2a..6bd98b6 100644 --- a/scripts/profile-core.mjs +++ b/scripts/profile-core.mjs @@ -2,15 +2,19 @@ import assert from 'node:assert/strict' import { Session } from 'node:inspector' import { createHash } from 'node:crypto' import { execFileSync } from 'node:child_process' -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { resolve, relative, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' +import { mkdir, readFile, writeFile, readdir } from 'node:fs/promises' +import { resolve, relative, dirname, sep } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' import { gzipSync } from 'node:zlib' const root = fileURLToPath(new URL('..', import.meta.url)) const directory = resolve(process.argv[2] || 'artifacts/core-profiles') const smoke = process.argv.includes('--smoke') assert.equal(typeof global.gc, 'function', 'Run Node with --expose-gc') +const preloadFlags = ['--import', '--loader', '--experimental-loader', '--require', '-r'] +const hasPreload = process.execArgv.some(arg => preloadFlags.some(flag => arg === flag || arg.startsWith(flag + '='))) + || /--(?:import|(?:experimental-)?loader|require)(?:=|\s|$)|(?:^|\s)-r(?:\s|$)/.test(process.env.NODE_OPTIONS ?? '') +assert.ok(!hasPreload, 'Run with plain Node, without module loaders or preload hooks') await mkdir(dirname(directory), { recursive: true }) try { await mkdir(directory) @@ -19,20 +23,33 @@ try { throw error } const sha = value => createHash('sha256').update(value).digest('hex') -async function snapshot() { +const runtimeDirectory = resolve(directory, 'runtime') +async function snapshot(includeRuntime = true) { const sourceFiles = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', 'src'], { cwd: root, encoding: 'utf8' }).trim().split('\n').filter(Boolean).sort() return { commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(), trackedDirtyState: execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], { cwd: root, encoding: 'utf8' }), programSHA256: sha(await readFile(fileURLToPath(import.meta.url))), lockfileSHA256: sha(await readFile(resolve(root, 'pnpm-lock.yaml'))), + buildInputs: Object.fromEntries(await Promise.all(['package.json', 'tsconfig.json', 'tsconfig.build.json'].map(async path => [path, sha(await readFile(resolve(root, path)))]))), + compilerVersion: JSON.parse(await readFile(resolve(root, 'node_modules/typescript/package.json'), 'utf8')).version, + ...(includeRuntime && { runtime: 'native-js', runtimeModules: Object.fromEntries(await Promise.all( + (await readdir(runtimeDirectory, { recursive: true })).filter(path => path.endsWith('.js')).sort() + .map(async path => [path, sha(await readFile(resolve(runtimeDirectory, path)))]), + )) }), sources: Object.fromEntries(await Promise.all(sourceFiles.map(async path => [path, sha(await readFile(resolve(root, path)))]))), } } +const beforeBuild = await snapshot(false) +execFileSync(process.execPath, [resolve(root, 'node_modules/typescript/bin/tsc'), '-p', resolve(root, 'tsconfig.build.json'), '--outDir', runtimeDirectory], { cwd: root, stdio: 'pipe' }) +await writeFile(resolve(runtimeDirectory, 'package.json'), '{"type":"module"}\n') +assert.deepEqual(await snapshot(false), beforeBuild, 'Build inputs changed while compiling') const frozen = await snapshot() await writeFile(resolve(directory, 'source.json'), JSON.stringify(frozen, null, 2)) const [{ parseMarkdown }, { renderHtml }, { streamingMarkdownExtension }] = await Promise.all([ - import('../src/parser.ts'), import('../src/html.ts'), import('../src/extensions/streaming.ts'), + import(pathToFileURL(resolve(runtimeDirectory, 'parser.js')).href), + import(pathToFileURL(resolve(runtimeDirectory, 'html.js')).href), + import(pathToFileURL(resolve(runtimeDirectory, 'extensions/streaming.js')).href), ]) const fixtures = [] for (const name of smoke ? ['small-doc.md'] : ['small-doc.md', 'prose-heavy.md', 'code-heavy.md', 'tables-lists.md', 'malformed.md', 'ai-response.md']) { @@ -74,7 +91,14 @@ for (const fixture of smoke ? [{ name: 'ai-prefixes', source: response.slice(0, const session = new Session() session.connect() const post = (method, params = {}) => new Promise((yes, no) => session.post(method, params, (error, result) => error ? no(error) : yes(result))) -const frame = value => ({ function: value.functionName || '(anonymous)', url: value.url?.startsWith('file:') ? relative(root, fileURLToPath(value.url)) : value.url, line: value.lineNumber }) +const frame = value => { + let url = value.url + if (url?.startsWith('file:')) { + const path = fileURLToPath(url) + url = path.startsWith(runtimeDirectory + sep) ? 'runtime/' + relative(runtimeDirectory, path).split(sep).join('/') : relative(root, path).split(sep).join('/') + } + return { function: value.functionName || '(anonymous)', url, line: value.lineNumber } +} const consume = output => { sink += typeof output === 'string' ? output.length : output.children.length } const rows = [] try { @@ -128,7 +152,7 @@ const receipt = { schemaVersion: 1, smoke, ...frozen, sourceStable: true, node: process.version, versions: process.versions, rows, sink, recordedAt: new Date().toISOString(), - limitations: 'Discovery profiles, not throughput or startup comparisons. CPU attribution is sampled exclusive time per workload, never add percentages across workloads. Allocation is sampled including collected objects, not retained heap, RSS, peak memory or an exact allocation count. TS source instrumentation and inspector overhead can differ from production bundles. Repeat promising leads with equivalent completed-work controls before changing production.', + limitations: 'Discovery profiles, not throughput or startup comparisons. CPU attribution is sampled exclusive time per workload, never add percentages across workloads. Allocation is sampled including collected objects, not retained heap, RSS, peak memory or an exact allocation count. The runtime directory is compiled from the frozen checkout before sampling, without TS loaders. Compilation is outside profiling. Inspector overhead can still differ from production execution. Repeat promising leads with equivalent completed-work controls before changing production.', } await writeFile(resolve(directory, 'summary.json'), JSON.stringify(receipt, null, 2)) console.log(JSON.stringify({ commit: receipt.commit, workloads: rows.length, directory, smoke })) diff --git a/tests/core-profiles.test.ts b/tests/core-profiles.test.ts index d3981ef..9e547e1 100644 --- a/tests/core-profiles.test.ts +++ b/tests/core-profiles.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { execFileSync } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -9,16 +10,21 @@ it('retains readable CPU/allocation profiles and completed-output metadata', () const root = mkdtempSync(join(tmpdir(), 'markdown-profile-')) const directory = join(root, 'profiles') try { - execFileSync(process.execPath, ['--expose-gc', '--import', 'tsx', 'scripts/profile-core.mjs', directory, '--smoke'], { timeout: 15000, stdio: 'pipe' }) + execFileSync(process.execPath, ['--expose-gc', 'scripts/profile-core.mjs', directory, '--smoke'], { timeout: 15000, stdio: 'pipe' }) const receipt = JSON.parse(readFileSync(join(directory, 'summary.json'), 'utf8')) expect(receipt.smoke).toBe(true) expect(receipt.sourceStable).toBe(true) + expect(receipt.runtime).toBe('native-js') + expect(receipt.compilerVersion).toMatch(/^\d+\.\d+\.\d+/) + const parserBytes = readFileSync(join(directory, 'runtime/parser.js')) + expect(receipt.runtimeModules['parser.js']).toBe(createHash('sha256').update(parserBytes).digest('hex')) + expect(() => execFileSync(process.execPath, ['--expose-gc', '--import', 'tsx', 'scripts/profile-core.mjs', join(root, 'instrumented'), '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('without module loaders or preload hooks') expect(receipt.programSHA256).toMatch(/^[a-f0-9]{64}$/) expect(receipt.sources).toHaveProperty("src/parser.ts") expect(receipt.rows).toHaveLength(4) expect(receipt.commit).toMatch(/^[a-f0-9]{40}$/) const preservedSummary = readFileSync(join(directory, 'summary.json'), 'utf8') - expect(() => execFileSync(process.execPath, ['--expose-gc', '--import', 'tsx', 'scripts/profile-core.mjs', directory, '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('Output directory already exists') + expect(() => execFileSync(process.execPath, ['--expose-gc', 'scripts/profile-core.mjs', directory, '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('Output directory already exists') expect(readFileSync(join(directory, 'summary.json'), 'utf8')).toBe(preservedSummary) for (const row of receipt.rows) { expect(row.cpuCompletedOperations).toBeGreaterThan(0) @@ -27,6 +33,7 @@ it('retains readable CPU/allocation profiles and completed-output metadata', () if (row.name.endsWith('-stream')) expect(row.streamTraceSHA256).toMatch(/^[a-f0-9]{64}$/) const cpu = JSON.parse(readFileSync(join(directory, row.name + '.cpuprofile'), 'utf8')) expect(cpu.nodes.length).toBeGreaterThan(0) + expect(cpu.nodes.some((node: { callFrame: { functionName: string; url?: string } }) => node.callFrame.functionName === '__name' && node.callFrame.url?.includes('/runtime/'))).toBe(false) expect(cpu.samples.length).toBe(cpu.timeDeltas.length) const heap = JSON.parse(gunzipSync(readFileSync(join(directory, row.name + '.heapprofile.json.gz'))).toString()) expect(heap.head.callFrame).toBeDefined() From a920d8242703d3e3d4684dd6cc6f5878ab71ae89 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 2 Nov 2026 22:50:06 -0700 Subject: [PATCH 2/5] test: reject compact Node preload flags in native profiler --- scripts/profile-core.mjs | 4 ++-- tests/core-profiles.test.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/profile-core.mjs b/scripts/profile-core.mjs index 6bd98b6..3558685 100644 --- a/scripts/profile-core.mjs +++ b/scripts/profile-core.mjs @@ -12,8 +12,8 @@ const directory = resolve(process.argv[2] || 'artifacts/core-profiles') const smoke = process.argv.includes('--smoke') assert.equal(typeof global.gc, 'function', 'Run Node with --expose-gc') const preloadFlags = ['--import', '--loader', '--experimental-loader', '--require', '-r'] -const hasPreload = process.execArgv.some(arg => preloadFlags.some(flag => arg === flag || arg.startsWith(flag + '='))) - || /--(?:import|(?:experimental-)?loader|require)(?:=|\s|$)|(?:^|\s)-r(?:\s|$)/.test(process.env.NODE_OPTIONS ?? '') +const hasPreload = process.execArgv.some(arg => arg.startsWith('-r') || preloadFlags.some(flag => arg === flag || arg.startsWith(flag + '='))) + || /--(?:import|(?:experimental-)?loader|require)(?:=|\s|$)|(?:^|\s)-r/.test(process.env.NODE_OPTIONS ?? '') assert.ok(!hasPreload, 'Run with plain Node, without module loaders or preload hooks') await mkdir(dirname(directory), { recursive: true }) try { diff --git a/tests/core-profiles.test.ts b/tests/core-profiles.test.ts index 9e547e1..c9c5b41 100644 --- a/tests/core-profiles.test.ts +++ b/tests/core-profiles.test.ts @@ -19,6 +19,7 @@ it('retains readable CPU/allocation profiles and completed-output metadata', () const parserBytes = readFileSync(join(directory, 'runtime/parser.js')) expect(receipt.runtimeModules['parser.js']).toBe(createHash('sha256').update(parserBytes).digest('hex')) expect(() => execFileSync(process.execPath, ['--expose-gc', '--import', 'tsx', 'scripts/profile-core.mjs', join(root, 'instrumented'), '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('without module loaders or preload hooks') + expect(() => execFileSync(process.execPath, ['--expose-gc', '-rnode:fs', 'scripts/profile-core.mjs', join(root, 'preloaded'), '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('without module loaders or preload hooks') expect(receipt.programSHA256).toMatch(/^[a-f0-9]{64}$/) expect(receipt.sources).toHaveProperty("src/parser.ts") expect(receipt.rows).toHaveLength(4) From 08d73b052bd7ee88b312030286a105e3cefe7422 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 2 Nov 2026 22:54:29 -0700 Subject: [PATCH 3/5] test: use supported Node preload option in profiler regression --- scripts/profile-core.mjs | 4 ++-- tests/core-profiles.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/profile-core.mjs b/scripts/profile-core.mjs index 3558685..6bd98b6 100644 --- a/scripts/profile-core.mjs +++ b/scripts/profile-core.mjs @@ -12,8 +12,8 @@ const directory = resolve(process.argv[2] || 'artifacts/core-profiles') const smoke = process.argv.includes('--smoke') assert.equal(typeof global.gc, 'function', 'Run Node with --expose-gc') const preloadFlags = ['--import', '--loader', '--experimental-loader', '--require', '-r'] -const hasPreload = process.execArgv.some(arg => arg.startsWith('-r') || preloadFlags.some(flag => arg === flag || arg.startsWith(flag + '='))) - || /--(?:import|(?:experimental-)?loader|require)(?:=|\s|$)|(?:^|\s)-r/.test(process.env.NODE_OPTIONS ?? '') +const hasPreload = process.execArgv.some(arg => preloadFlags.some(flag => arg === flag || arg.startsWith(flag + '='))) + || /--(?:import|(?:experimental-)?loader|require)(?:=|\s|$)|(?:^|\s)-r(?:\s|$)/.test(process.env.NODE_OPTIONS ?? '') assert.ok(!hasPreload, 'Run with plain Node, without module loaders or preload hooks') await mkdir(dirname(directory), { recursive: true }) try { diff --git a/tests/core-profiles.test.ts b/tests/core-profiles.test.ts index c9c5b41..7d07a6b 100644 --- a/tests/core-profiles.test.ts +++ b/tests/core-profiles.test.ts @@ -19,7 +19,7 @@ it('retains readable CPU/allocation profiles and completed-output metadata', () const parserBytes = readFileSync(join(directory, 'runtime/parser.js')) expect(receipt.runtimeModules['parser.js']).toBe(createHash('sha256').update(parserBytes).digest('hex')) expect(() => execFileSync(process.execPath, ['--expose-gc', '--import', 'tsx', 'scripts/profile-core.mjs', join(root, 'instrumented'), '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('without module loaders or preload hooks') - expect(() => execFileSync(process.execPath, ['--expose-gc', '-rnode:fs', 'scripts/profile-core.mjs', join(root, 'preloaded'), '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('without module loaders or preload hooks') + expect(() => execFileSync(process.execPath, ['--expose-gc', '-r', 'node:fs', 'scripts/profile-core.mjs', join(root, 'preloaded'), '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('without module loaders or preload hooks') expect(receipt.programSHA256).toMatch(/^[a-f0-9]{64}$/) expect(receipt.sources).toHaveProperty("src/parser.ts") expect(receipt.rows).toHaveLength(4) From 16c71869975fb19371e7aa34eed84c1fa6229a49 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 2 Nov 2026 23:08:44 -0700 Subject: [PATCH 4/5] fix: tokenize quoted Node environment options before profiling --- scripts/profile-core.mjs | 24 ++++++++++++++++++++++-- tests/core-profiles.test.ts | 4 ++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/scripts/profile-core.mjs b/scripts/profile-core.mjs index 6bd98b6..f47a1eb 100644 --- a/scripts/profile-core.mjs +++ b/scripts/profile-core.mjs @@ -11,9 +11,29 @@ const root = fileURLToPath(new URL('..', import.meta.url)) const directory = resolve(process.argv[2] || 'artifacts/core-profiles') const smoke = process.argv.includes('--smoke') assert.equal(typeof global.gc, 'function', 'Run Node with --expose-gc') +// NODE_OPTIONS uses spaces and double quotes, with backslash escapes inside quotes. +function nodeOptionsArguments(value) { + const args = [] + let token = '', quoted = false + for (let index = 0; index < value.length; index++) { + const char = value[index] + if (char === '\\' && quoted) { + assert.ok(index + 1 < value.length, 'Invalid NODE_OPTIONS escape') + token += value[++index] + } else if (char === '"') { + quoted = !quoted + } else if (char === ' ' && !quoted) { + if (token) args.push(token) + token = '' + } else token += char + } + assert.ok(!quoted, 'Unterminated NODE_OPTIONS quote') + if (token) args.push(token) + return args +} const preloadFlags = ['--import', '--loader', '--experimental-loader', '--require', '-r'] -const hasPreload = process.execArgv.some(arg => preloadFlags.some(flag => arg === flag || arg.startsWith(flag + '='))) - || /--(?:import|(?:experimental-)?loader|require)(?:=|\s|$)|(?:^|\s)-r(?:\s|$)/.test(process.env.NODE_OPTIONS ?? '') +const nodeArguments = [...process.execArgv, ...nodeOptionsArguments(process.env.NODE_OPTIONS ?? '')] +const hasPreload = nodeArguments.some(arg => preloadFlags.some(flag => arg === flag || arg.startsWith(flag + '='))) assert.ok(!hasPreload, 'Run with plain Node, without module loaders or preload hooks') await mkdir(dirname(directory), { recursive: true }) try { diff --git a/tests/core-profiles.test.ts b/tests/core-profiles.test.ts index 7d07a6b..53db8ae 100644 --- a/tests/core-profiles.test.ts +++ b/tests/core-profiles.test.ts @@ -20,6 +20,10 @@ it('retains readable CPU/allocation profiles and completed-output metadata', () expect(receipt.runtimeModules['parser.js']).toBe(createHash('sha256').update(parserBytes).digest('hex')) expect(() => execFileSync(process.execPath, ['--expose-gc', '--import', 'tsx', 'scripts/profile-core.mjs', join(root, 'instrumented'), '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('without module loaders or preload hooks') expect(() => execFileSync(process.execPath, ['--expose-gc', '-r', 'node:fs', 'scripts/profile-core.mjs', join(root, 'preloaded'), '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('without module loaders or preload hooks') + for (const options of ['"--require" node:fs', '--"require"=node:fs', String.raw`"--requ\ire" node:fs`]) { + expect(() => execFileSync(process.execPath, ['--expose-gc', 'scripts/profile-core.mjs', join(root, 'environment-preload'), '--smoke'], { timeout: 15000, stdio: 'pipe', env: { ...process.env, NODE_OPTIONS: options } })).toThrow('without module loaders or preload hooks') + } + expect(() => execFileSync(process.execPath, ['--expose-gc', 'scripts/profile-core.mjs', directory, '--smoke'], { timeout: 15000, stdio: 'pipe', env: { ...process.env, NODE_OPTIONS: '--diagnostic-dir="/tmp/a --require-looking path"' } })).toThrow('Output directory already exists') expect(receipt.programSHA256).toMatch(/^[a-f0-9]{64}$/) expect(receipt.sources).toHaveProperty("src/parser.ts") expect(receipt.rows).toHaveLength(4) From dd884534b147c470a29e202922783f7aef9264a7 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 2 Nov 2026 23:17:46 -0700 Subject: [PATCH 5/5] fix: normalize Node loader option aliases before profiling --- scripts/profile-core.mjs | 5 ++++- tests/core-profiles.test.ts | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/profile-core.mjs b/scripts/profile-core.mjs index f47a1eb..94f9bcf 100644 --- a/scripts/profile-core.mjs +++ b/scripts/profile-core.mjs @@ -33,7 +33,10 @@ function nodeOptionsArguments(value) { } const preloadFlags = ['--import', '--loader', '--experimental-loader', '--require', '-r'] const nodeArguments = [...process.execArgv, ...nodeOptionsArguments(process.env.NODE_OPTIONS ?? '')] -const hasPreload = nodeArguments.some(arg => preloadFlags.some(flag => arg === flag || arg.startsWith(flag + '='))) +const hasPreload = nodeArguments.some(arg => { + const name = arg.startsWith('--') ? arg.split('=', 1)[0].replaceAll('_', '-') : arg + return preloadFlags.includes(name) +}) assert.ok(!hasPreload, 'Run with plain Node, without module loaders or preload hooks') await mkdir(dirname(directory), { recursive: true }) try { diff --git a/tests/core-profiles.test.ts b/tests/core-profiles.test.ts index 53db8ae..e9f372a 100644 --- a/tests/core-profiles.test.ts +++ b/tests/core-profiles.test.ts @@ -20,7 +20,8 @@ it('retains readable CPU/allocation profiles and completed-output metadata', () expect(receipt.runtimeModules['parser.js']).toBe(createHash('sha256').update(parserBytes).digest('hex')) expect(() => execFileSync(process.execPath, ['--expose-gc', '--import', 'tsx', 'scripts/profile-core.mjs', join(root, 'instrumented'), '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('without module loaders or preload hooks') expect(() => execFileSync(process.execPath, ['--expose-gc', '-r', 'node:fs', 'scripts/profile-core.mjs', join(root, 'preloaded'), '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('without module loaders or preload hooks') - for (const options of ['"--require" node:fs', '--"require"=node:fs', String.raw`"--requ\ire" node:fs`]) { + expect(() => execFileSync(process.execPath, ['--expose-gc', '--experimental_loader=node:fs', 'scripts/profile-core.mjs', join(root, 'loader-alias'), '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('without module loaders or preload hooks') + for (const options of ['"--require" node:fs', '--"require"=node:fs', String.raw`"--requ\ire" node:fs`, '"--experimental_loader" node:fs']) { expect(() => execFileSync(process.execPath, ['--expose-gc', 'scripts/profile-core.mjs', join(root, 'environment-preload'), '--smoke'], { timeout: 15000, stdio: 'pipe', env: { ...process.env, NODE_OPTIONS: options } })).toThrow('without module loaders or preload hooks') } expect(() => execFileSync(process.execPath, ['--expose-gc', 'scripts/profile-core.mjs', directory, '--smoke'], { timeout: 15000, stdio: 'pipe', env: { ...process.env, NODE_OPTIONS: '--diagnostic-dir="/tmp/a --require-looking path"' } })).toThrow('Output directory already exists')