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:
parent
537b784da3
commit
0aaa2f4d2d
6 changed files with 152 additions and 14 deletions
|
|
@ -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),
|
||||||
|
|
|
||||||
9
scripts/test/node-commands.d.mts
Normal file
9
scripts/test/node-commands.d.mts
Normal 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;
|
||||||
76
scripts/test/node-commands.mjs
Normal file
76
scripts/test/node-commands.mjs
Normal 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 };
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
51
tests/e2e/harness/node-commands.test.ts
Normal file
51
tests/e2e/harness/node-commands.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue