diff --git a/scripts/test/n13-race.mjs b/scripts/test/n13-race.mjs index 40e9e77..dd113d6 100644 --- a/scripts/test/n13-race.mjs +++ b/scripts/test/n13-race.mjs @@ -18,6 +18,7 @@ import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import path from 'node:path'; import pg from 'pg'; +import { baseEnv, nodeWithTsx } from './node-commands.mjs'; const repoRoot = path.resolve(import.meta.dirname, '../..'); const args = process.argv.slice(2); @@ -77,7 +78,7 @@ runNode( const dbEnv = parseEnvFile(path.join(secretsDir, 'db.env')); rmSync(secretsDir, { recursive: true, force: true }); runNode(['scripts/test/migrate-roundtrip.mjs', 'up'], { - PATH: process.env.PATH ?? '', + ...baseEnv(), DATABASE_MIGRATION_URL: dbEnv.DATABASE_MIGRATION_URL, }); const migrator = new pg.Client({ connectionString: dbEnv.DATABASE_MIGRATION_URL }); @@ -99,10 +100,11 @@ for (const line of runNode(['scripts/generate-vapid-keys.mjs'], process.env).spl } const port = await freePort(); const log = createWriteStream(path.join(repoRoot, `tests/e2e/evidence/server-n13-${mode}.txt`)); -const child = spawn(path.join(repoRoot, 'node_modules/.bin/tsx'), ['src/server.ts'], { +const server = nodeWithTsx(['src/server.ts']); +const child = spawn(server.command, server.args, { cwd: repoRoot, env: { - PATH: process.env.PATH ?? '', + ...baseEnv(), NODE_ENV: 'test', HOST: '127.0.0.1', PORT: String(port), diff --git a/scripts/test/node-commands.d.mts b/scripts/test/node-commands.d.mts new file mode 100644 index 0000000..2a6a98c --- /dev/null +++ b/scripts/test/node-commands.d.mts @@ -0,0 +1,9 @@ +export type Tool = 'tsx' | 'vitest' | 'prettier'; +export declare function toolEntry(tool: Tool): string; +export declare function nodeTool(tool: Tool, args?: string[]): { command: string; args: string[] }; +export declare function nodeWithTsx(args?: string[]): { command: string; args: string[] }; +export declare function baseEnv( + platform?: NodeJS.Platform, + env?: NodeJS.ProcessEnv, +): Record; +export declare const repoRoot: string; diff --git a/scripts/test/node-commands.mjs b/scripts/test/node-commands.mjs new file mode 100644 index 0000000..3998fae --- /dev/null +++ b/scripts/test/node-commands.mjs @@ -0,0 +1,76 @@ +// Запуск инструментов из node_modules без .cmd-обёрток и без npx. +// Под Windows `node_modules/.bin/tsx` и `npx` — это .cmd-файлы: spawn() без shell +// падает с ENOENT. Поэтому всегда запускаем текущий node (process.execPath) с +// JS-точкой входа пакета, найденной через require.resolve. +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); +const require = createRequire(path.join(repoRoot, 'package.json')); + +function packageBin(name, binName = name) { + const manifestPath = require.resolve(`${name}/package.json`); + const manifest = require(manifestPath); + const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[binName]; + if (!bin) throw new Error(`${name}: в package.json нет bin "${binName}"`); + return path.join(path.dirname(manifestPath), bin); +} + +/** Абсолютный путь к JS-точке входа CLI (не .cmd, не .bin). */ +export function toolEntry(tool) { + switch (tool) { + case 'tsx': + return require.resolve('tsx/cli'); + case 'vitest': + return packageBin('vitest'); + case 'prettier': + return packageBin('prettier'); + default: + throw new Error(`неизвестный инструмент: ${tool}`); + } +} + +/** Команда для spawn(command, args): всегда process.execPath + JS-файл CLI. */ +export function nodeTool(tool, args = []) { + return { command: process.execPath, args: [toolEntry(tool), ...args] }; +} + +/** + * Запуск TypeScript-файла (например, src/server.ts) в ОДНОМ процессе node с + * загрузчиком tsx (`node --import tsx`). CLI tsx запускает дочерний node, и под + * Windows kill() родителя (TerminateProcess) оставил бы сервер-сироту на порту. + */ +export function nodeWithTsx(args = []) { + const loader = pathToFileURL(require.resolve('tsx')).href; + return { command: process.execPath, args: ['--import', loader, ...args] }; +} + +/** + * Минимальное окружение дочернего процесса: PATH и (под Windows) системные + * переменные, без которых node не может работать с сетью/криптографией + * (SystemRoot, WINDIR, TEMP и т. п.). Секреты родителя не наследуются. + */ +export function baseEnv(platform = process.platform, env = process.env) { + const keys = ['PATH']; + if (platform === 'win32') { + keys.push( + 'SystemRoot', + 'WINDIR', + 'TEMP', + 'TMP', + 'USERPROFILE', + 'LOCALAPPDATA', + 'APPDATA', + 'ComSpec', + 'PATHEXT', + ); + } + const result = {}; + for (const key of keys) { + if (env[key] !== undefined) result[key] = env[key]; + } + return result; +} + +export { repoRoot }; diff --git a/scripts/test/run-e2e.mjs b/scripts/test/run-e2e.mjs index 22949ec..6d4f92e 100644 --- a/scripts/test/run-e2e.mjs +++ b/scripts/test/run-e2e.mjs @@ -10,6 +10,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { createWriteStream, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; +import { nodeTool } from './node-commands.mjs'; const repoRoot = path.resolve(import.meta.dirname, '../..'); const evidence = path.join(repoRoot, 'tests/e2e/evidence'); @@ -20,7 +21,6 @@ const logPath = path.join(evidence, 'e2e-run.txt'); const extra = process.argv.slice(2).filter((arg) => !arg.startsWith('--no-known-defects')); const vitestArgs = [ - 'vitest', 'run', '--config', 'tests/e2e/vitest.e2e.config.ts', @@ -30,9 +30,10 @@ const vitestArgs = [ ...extra, ]; const log = createWriteStream(logPath); -log.write(`$ npx ${vitestArgs.join(' ')}\n`); +const vitest = nodeTool('vitest', vitestArgs); +log.write(`$ node ${vitestArgs.join(' ')}\n`); const exitCode = await new Promise((resolve) => { - const child = spawn('npx', vitestArgs, { cwd: repoRoot, env: process.env }); + const child = spawn(vitest.command, vitest.args, { cwd: repoRoot, env: process.env }); for (const stream of [child.stdout, child.stderr]) { stream.on('data', (chunk) => { process.stdout.write(chunk); @@ -108,10 +109,8 @@ const table = ['| ID | Цвет | Зелёных/всего | Вердикт |', writeFileSync(path.join(evidence, 'e2e-table.md'), `${table}\n`); // Сгенерированные json/md приводятся к prettier, чтобы `npm run format:check` не // краснел из-за evidence (tests/e2e/evidence не в .prettierignore). -spawnSync('npx', ['prettier', '--write', jsonPath, path.join(evidence, 'e2e-table.md')], { - cwd: repoRoot, - stdio: 'ignore', -}); +const prettier = nodeTool('prettier', ['--write', jsonPath, path.join(evidence, 'e2e-table.md')]); +spawnSync(prettier.command, prettier.args, { cwd: repoRoot, stdio: 'ignore' }); console.log(`\n[run-e2e] vitest exit=${exitCode}\n${table}`); if (strict) { diff --git a/tests/e2e/harness/global-setup.ts b/tests/e2e/harness/global-setup.ts index 5258209..55ca631 100644 --- a/tests/e2e/harness/global-setup.ts +++ b/tests/e2e/harness/global-setup.ts @@ -7,6 +7,7 @@ import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import path from 'node:path'; import type { TestProject } from 'vitest/node'; +import { baseEnv, nodeWithTsx } from '../../../scripts/test/node-commands.mjs'; import { randomUlid, type E2EContext } from './context.js'; const repoRoot = path.resolve(import.meta.dirname, '../../..'); @@ -63,11 +64,11 @@ async function startServer(name: string, env: Record): Promise { + it.each(['tsx', 'vitest', 'prettier'] as const)('%s: process.execPath + JS-файл', (tool) => { + const command = nodeTool(tool, ['--version']); + expect(command.command).toBe(process.execPath); + const entry = command.args[0]!; + expect(entry).not.toMatch(forbidden); + expect(entry).toMatch(/\.(mjs|cjs|js)$/); + expect(path.isAbsolute(entry)).toBe(true); + expect(existsSync(entry)).toBe(true); + }); + + it('сервер: один процесс node с загрузчиком tsx', () => { + const command = nodeWithTsx(['src/server.ts']); + expect(command.command).toBe(process.execPath); + expect(command.args[0]).toBe('--import'); + expect(command.args[1]).toMatch(/^file:\/\/.*\.mjs$/); + expect(command.args.join(' ')).not.toMatch(forbidden); + expect(command.args.at(-1)).toBe('src/server.ts'); + }); + + it('под win32 дочерний процесс получает SystemRoot/TEMP, а секреты не наследуются', () => { + const env = baseEnv('win32', { + PATH: 'C:\\Windows', + SystemRoot: 'C:\\Windows', + TEMP: 'C:\\Temp', + DATABASE_URL: 'secret-must-not-leak', + }); + expect(env).toMatchObject({ PATH: 'C:\\Windows', SystemRoot: 'C:\\Windows', TEMP: 'C:\\Temp' }); + expect(env).not.toHaveProperty('DATABASE_URL'); + expect(baseEnv('linux', { PATH: '/bin', SystemRoot: 'x' })).toEqual({ PATH: '/bin' }); + }); + + it.each([ + 'scripts/test/run-e2e.mjs', + 'scripts/test/n13-race.mjs', + 'tests/e2e/harness/global-setup.ts', + ])('%s не вызывает npx и node_modules/.bin', (file) => { + const source = readFileSync(path.join(repoRoot, file), 'utf8'); + expect(source).not.toMatch(/spawn(Sync)?\(\s*['"`]npx/); + expect(source).not.toMatch(/node_modules\/\.bin/); + }); +});