krona-staff/tests/e2e/harness/global-setup.ts
grok 0aaa2f4d2d 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-раннера).
2026-09-26 15:19:47 +07:00

191 lines
7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Глобальная подготовка e2e: реальная PostgreSQL 17, миграции ролью мигратора
// (без SUPERUSER), реальный процесс сервера под ролью приложения, случайные секреты.
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { createWriteStream, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
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, '../../..');
const evidenceDir = path.join(repoRoot, 'tests/e2e/evidence');
function parseEnvFile(file: string): Record<string, string> {
const result: Record<string, string> = {};
for (const line of readFileSync(file, 'utf8').split('\n')) {
const index = line.indexOf('=');
if (index > 0) result[line.slice(0, index)] = line.slice(index + 1);
}
return result;
}
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
function runNode(args: string[], env: NodeJS.ProcessEnv, label: string) {
const result = spawnSync(process.execPath, args, { cwd: repoRoot, env, encoding: 'utf8' });
const output = `${result.stdout}${result.stderr}`;
console.log(`[e2e] ${label}:\n${output.trim()}`);
if (result.status !== 0) throw new Error(`${label} failed with exit ${result.status}`);
return output;
}
function vapidEnv(): Record<string, string> {
const output = spawnSync(process.execPath, ['scripts/generate-vapid-keys.mjs'], {
cwd: repoRoot,
encoding: 'utf8',
}).stdout;
const env: Record<string, string> = {};
for (const line of output.split('\n')) {
const index = line.indexOf('=');
if (index > 0) env[line.slice(0, index)] = line.slice(index + 1);
}
return env;
}
interface StartedServer {
child: ChildProcess;
baseUrl: string;
}
async function startServer(name: string, env: Record<string, string>): Promise<StartedServer> {
const port = await freePort();
const logPath = path.join(evidenceDir, `server-${name}.txt`);
const log = createWriteStream(logPath, { flags: 'w' });
const server = nodeWithTsx(['src/server.ts']);
const child = spawn(server.command, server.args, {
cwd: repoRoot,
env: {
...baseEnv(),
NODE_ENV: 'test',
HOST: '127.0.0.1',
PORT: String(port),
LOG_LEVEL: 'info',
STAFF_RUNTIME_MODE: 'standalone',
...env,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout?.pipe(log);
child.stderr?.pipe(log);
const baseUrl = `http://127.0.0.1:${port}`;
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`server ${name} exited early with ${child.exitCode}; see ${logPath}`);
}
try {
const response = await fetch(`${baseUrl}/api/v1/staff/ready`);
if (response.status === 200) {
console.log(
`[e2e] server ${name} ready at ${baseUrl} (log: ${path.relative(repoRoot, logPath)})`,
);
return { child, baseUrl };
}
} catch {
// сервер ещё не слушает порт
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
child.kill('SIGKILL');
throw new Error(`server ${name} did not become ready; see ${logPath}`);
}
export default async function setup(project: TestProject) {
mkdirSync(evidenceDir, { recursive: true });
const adminUrl = process.env.E2E_PG_ADMIN_URL;
if (!adminUrl) {
throw new Error(
'E2E_PG_ADMIN_URL не задан: нужен bootstrap-админ PostgreSQL 17 (только для создания ролей и БД)',
);
}
const database = process.env.E2E_DATABASE_NAME ?? 'krona_staff_e2e';
const secretsDir = mkdtempSync(path.join(tmpdir(), 'krona-e2e-'));
const envFile = path.join(secretsDir, 'db.env');
runNode(
['scripts/test/provision-pg.mjs', '--out', envFile, '--db', database],
{ ...process.env, E2E_PG_ADMIN_URL: adminUrl },
'provision',
);
const dbEnv = parseEnvFile(envFile);
rmSync(secretsDir, { recursive: true, force: true });
const migrationUrl = dbEnv.DATABASE_MIGRATION_URL;
const appUrl = dbEnv.DATABASE_URL;
if (!migrationUrl || !appUrl) throw new Error('provision did not produce connection URLs');
const migrationMode = process.env.E2E_MIGRATIONS ?? 'up';
runNode(
['scripts/test/migrate-roundtrip.mjs', migrationMode === 'roundtrip' ? 'roundtrip' : 'up'],
{ ...baseEnv(), DATABASE_MIGRATION_URL: migrationUrl },
`migrations (${migrationMode}) as krona_migrator`,
);
const tenantId = randomUlid();
const actorId = randomUlid();
const terminalActorId = randomUlid();
const managerToken = randomBytes(32).toString('hex');
const terminalToken = randomBytes(32).toString('hex');
const common = {
DATABASE_URL: appUrl,
STANDALONE_TENANT_ID: tenantId,
STANDALONE_ACTOR_ID: actorId,
...vapidEnv(),
};
const secured = await startServer('secured', {
...common,
STANDALONE_ACCESS: 'employee',
STANDALONE_MANAGER_TOKEN: managerToken,
STANDALONE_TERMINAL_TOKEN: terminalToken,
STANDALONE_TERMINAL_ACTOR_ID: terminalActorId,
});
// Второй процесс без токенов (как корневой compose.yaml), тот же tenant и БД: для F2/K-сценариев.
const open = await startServer('no-tokens', {
...common,
STANDALONE_ACCESS: 'manager',
});
// Третий процесс с собственными токенами только для F4 (перебор входа): если rate limit
// появится, он не должен блокировать вход в остальных сценариях с того же IP.
const rateLimited = await startServer('rate-limit', {
...common,
STANDALONE_ACCESS: 'employee',
STANDALONE_MANAGER_TOKEN: randomBytes(32).toString('hex'),
STANDALONE_TERMINAL_TOKEN: randomBytes(32).toString('hex'),
});
const context: E2EContext = {
baseUrl: secured.baseUrl,
openBaseUrl: open.baseUrl,
rateLimitBaseUrl: rateLimited.baseUrl,
managerToken,
terminalToken,
tenantId,
actorId,
terminalActorId,
appDatabaseUrl: appUrl,
migrationDatabaseUrl: migrationUrl,
};
project.provide('e2e', context);
return async () => {
for (const server of [secured, open, rateLimited]) {
server.child.kill('SIGTERM');
}
await new Promise((resolve) => setTimeout(resolve, 500));
for (const server of [secured, open, rateLimited]) {
if (server.child.exitCode === null) server.child.kill('SIGKILL');
}
};
}