test(e2e): запуск vitest/tsx/prettier без .cmd-обёрток (Windows)

Зачем: проект разрабатывается под Windows, а стенд там не стартовал:
spawn('npx') и spawn('node_modules/.bin/tsx') без shell падают с ENOENT,
потому что в Windows это .cmd-файлы (приёмка владельца, TASK-GROK-2 п.1).

Что: scripts/test/node-commands.mjs находит JS-точки входа через
require.resolve (tsx/cli, vitest.mjs, prettier.cjs) и всегда запускает их
текущим node (process.execPath). Сервер запускается одним процессом
`node --import tsx src/server.ts`: CLI tsx порождает дочерний node, и под
Windows kill() родителя оставил бы сервер-сироту на порту. baseEnv()
передаёт дочерним процессам PATH и под win32 системные переменные
(SystemRoot, TEMP и др.), без которых node на Windows не работает с сетью,
но не наследует секреты родителя.

Проверка на Linux: tests/e2e/harness/node-commands.test.ts (входит в
npm test) — команды начинаются с process.execPath, без .cmd/.bin/npx,
файлы существуют; исходники стенда не вызывают npx и node_modules/.bin.
Реальный прогон на Windows 11 — не проверено (нет Windows-раннера).
This commit is contained in:
grok 2026-09-26 15:19:47 +07:00
parent 537b784da3
commit 0aaa2f4d2d
6 changed files with 152 additions and 14 deletions

View file

@ -18,6 +18,7 @@ import { createServer } from 'node:net';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import pg from 'pg'; import pg from 'pg';
import { baseEnv, nodeWithTsx } from './node-commands.mjs';
const repoRoot = path.resolve(import.meta.dirname, '../..'); const repoRoot = path.resolve(import.meta.dirname, '../..');
const args = process.argv.slice(2); const args = process.argv.slice(2);
@ -77,7 +78,7 @@ runNode(
const dbEnv = parseEnvFile(path.join(secretsDir, 'db.env')); const dbEnv = parseEnvFile(path.join(secretsDir, 'db.env'));
rmSync(secretsDir, { recursive: true, force: true }); rmSync(secretsDir, { recursive: true, force: true });
runNode(['scripts/test/migrate-roundtrip.mjs', 'up'], { runNode(['scripts/test/migrate-roundtrip.mjs', 'up'], {
PATH: process.env.PATH ?? '', ...baseEnv(),
DATABASE_MIGRATION_URL: dbEnv.DATABASE_MIGRATION_URL, DATABASE_MIGRATION_URL: dbEnv.DATABASE_MIGRATION_URL,
}); });
const migrator = new pg.Client({ connectionString: 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 port = await freePort();
const log = createWriteStream(path.join(repoRoot, `tests/e2e/evidence/server-n13-${mode}.txt`)); 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, cwd: repoRoot,
env: { env: {
PATH: process.env.PATH ?? '', ...baseEnv(),
NODE_ENV: 'test', NODE_ENV: 'test',
HOST: '127.0.0.1', HOST: '127.0.0.1',
PORT: String(port), PORT: String(port),

View file

@ -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<string, string>;
export declare const repoRoot: string;

View file

@ -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 };

View file

@ -10,6 +10,7 @@
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { createWriteStream, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { createWriteStream, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { nodeTool } from './node-commands.mjs';
const repoRoot = path.resolve(import.meta.dirname, '../..'); const repoRoot = path.resolve(import.meta.dirname, '../..');
const evidence = path.join(repoRoot, 'tests/e2e/evidence'); 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 extra = process.argv.slice(2).filter((arg) => !arg.startsWith('--no-known-defects'));
const vitestArgs = [ const vitestArgs = [
'vitest',
'run', 'run',
'--config', '--config',
'tests/e2e/vitest.e2e.config.ts', 'tests/e2e/vitest.e2e.config.ts',
@ -30,9 +30,10 @@ const vitestArgs = [
...extra, ...extra,
]; ];
const log = createWriteStream(logPath); const log = createWriteStream(logPath);
log.write(`$ npx ${vitestArgs.join(' ')}\n`); const vitest = nodeTool('vitest', vitestArgs);
log.write(`$ node <vitest.mjs> ${vitestArgs.join(' ')}\n`);
const exitCode = await new Promise((resolve) => { 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]) { for (const stream of [child.stdout, child.stderr]) {
stream.on('data', (chunk) => { stream.on('data', (chunk) => {
process.stdout.write(chunk); process.stdout.write(chunk);
@ -108,10 +109,8 @@ const table = ['| ID | Цвет | Зелёных/всего | Вердикт |',
writeFileSync(path.join(evidence, 'e2e-table.md'), `${table}\n`); writeFileSync(path.join(evidence, 'e2e-table.md'), `${table}\n`);
// Сгенерированные json/md приводятся к prettier, чтобы `npm run format:check` не // Сгенерированные json/md приводятся к prettier, чтобы `npm run format:check` не
// краснел из-за evidence (tests/e2e/evidence не в .prettierignore). // краснел из-за evidence (tests/e2e/evidence не в .prettierignore).
spawnSync('npx', ['prettier', '--write', jsonPath, path.join(evidence, 'e2e-table.md')], { const prettier = nodeTool('prettier', ['--write', jsonPath, path.join(evidence, 'e2e-table.md')]);
cwd: repoRoot, spawnSync(prettier.command, prettier.args, { cwd: repoRoot, stdio: 'ignore' });
stdio: 'ignore',
});
console.log(`\n[run-e2e] vitest exit=${exitCode}\n${table}`); console.log(`\n[run-e2e] vitest exit=${exitCode}\n${table}`);
if (strict) { if (strict) {

View file

@ -7,6 +7,7 @@ import { createServer } from 'node:net';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import type { TestProject } from 'vitest/node'; import type { TestProject } from 'vitest/node';
import { baseEnv, nodeWithTsx } from '../../../scripts/test/node-commands.mjs';
import { randomUlid, type E2EContext } from './context.js'; import { randomUlid, type E2EContext } from './context.js';
const repoRoot = path.resolve(import.meta.dirname, '../../..'); const repoRoot = path.resolve(import.meta.dirname, '../../..');
@ -63,11 +64,11 @@ async function startServer(name: string, env: Record<string, string>): Promise<S
const port = await freePort(); const port = await freePort();
const logPath = path.join(evidenceDir, `server-${name}.txt`); const logPath = path.join(evidenceDir, `server-${name}.txt`);
const log = createWriteStream(logPath, { flags: 'w' }); const log = createWriteStream(logPath, { flags: 'w' });
const tsx = path.join(repoRoot, 'node_modules/.bin/tsx'); const server = nodeWithTsx(['src/server.ts']);
const child = spawn(tsx, ['src/server.ts'], { const child = spawn(server.command, server.args, {
cwd: repoRoot, cwd: repoRoot,
env: { env: {
PATH: process.env.PATH ?? '', ...baseEnv(),
NODE_ENV: 'test', NODE_ENV: 'test',
HOST: '127.0.0.1', HOST: '127.0.0.1',
PORT: String(port), PORT: String(port),
@ -127,7 +128,7 @@ export default async function setup(project: TestProject) {
const migrationMode = process.env.E2E_MIGRATIONS ?? 'up'; const migrationMode = process.env.E2E_MIGRATIONS ?? 'up';
runNode( runNode(
['scripts/test/migrate-roundtrip.mjs', migrationMode === 'roundtrip' ? 'roundtrip' : 'up'], ['scripts/test/migrate-roundtrip.mjs', migrationMode === 'roundtrip' ? 'roundtrip' : 'up'],
{ PATH: process.env.PATH ?? '', DATABASE_MIGRATION_URL: migrationUrl }, { ...baseEnv(), DATABASE_MIGRATION_URL: migrationUrl },
`migrations (${migrationMode}) as krona_migrator`, `migrations (${migrationMode}) as krona_migrator`,
); );

View file

@ -0,0 +1,51 @@
// Проверяемо на Linux: стенд запускает инструменты так, что под Windows не нужен
// .cmd-обёрточный файл (spawn без shell падает на них с ENOENT).
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { baseEnv, nodeTool, nodeWithTsx, repoRoot } from '../../../scripts/test/node-commands.mjs';
const forbidden = /\.cmd\b|[\\/]\.bin[\\/]|^npx$|^npm$/i;
describe('[HARNESS-WIN] запуск инструментов без .cmd-обёрток', () => {
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/);
});
});