Files
houseplan-card/scripts/test-chunk.mjs
T
Claude af62bd4c64 infra: local contour — pre-push gate:small by default, sandbox bootstrap, test:chunk
- .githooks/pre-push + scripts/pre-push-gate.mjs --hook: gate:small runs by
  default for issue/* branches with an executable diff; C/D-only diffs and
  non-issue refs are skipped, HP_PREPUSH_GATE=0 turns it off, =1 forces it.
  A non-HEAD push with an executable diff is rejected (the gate checks the
  working tree). The gate runs with every GIT_* variable removed: git gives
  hooks GIT_DIR (and GIT_CONFIG_PARAMETERS with the pusher's -c), and unit
  tests that `git init` temporary repositories otherwise write into the real
  one — seen on the first live run. The manual #343 mode is unchanged.
- scripts/sandbox-bootstrap.sh: idempotent worktree / deps / chromium (npm
  @sparticuz/chromium@152.0.0 + Playwright shims) / bundle / check steps,
  no owner paths or credentials.
- scripts/test-chunk.mjs, npm run test:chunk -- N/M: round-robin over
  test/*.test.mjs sorted by code point.
- Tests, seven mutants, docs/DEVELOPMENT.md «Локальный контур за 5 минут»,
  docs/TESTING.md. package.json changed → bundle rebuilt (fingerprint input).

Issue: #633
User-Visible: no
2026-09-24 05:00:45 +03:00

89 lines
5.0 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Полный набор юнитов частями (#633): `npm run test:chunk -- N/M`.
//
// Зачем. Одна bash-команда песочницы агента живёт ≈ 3 минуты, а `npm test`
// идёт около 2,5 — под нагрузкой параллельных сессий больше. Раньше набор резали
// руками (`ls | split -n l/6`), и каждая сессия изобретала своё деление.
// Здесь оно одно и детерминированное: одинаковые N/M на любой машине дают один и
// тот же список файлов, а M частей вместе — каждый файл ровно по одному разу.
//
// Правило деления — round-robin по отсортированному имени: файл с индексом i
// (0-based, сортировка по коду символов, не по локали) попадает в часть i % M + 1.
// Выбрано против деления по суммарному размеру: размер меняется с каждой
// правкой теста, и файл кочевал бы между частями — «упало в 3/6» переставало бы
// означать одно и то же вчера и сегодня. Round-robin сдвигает файлы только при
// добавлении или удалении теста, а части и так выходят близкими по длине, потому
// что соседние по имени тесты одной подсистемы разносятся по разным частям.
//
// npm run test:chunk -- 2/6 # сборка тестов + вторая шестая
// npm run test:chunk -- 2/6 --no-build # сборка уже сделана (прошлой частью)
// npm run test:chunk -- 2/6 --list # только напечатать файлы
//
// Сборка тестов (`tsc -p tsconfig.test.json && fix-test-build`) по умолчанию
// идёт в каждой части: часть обязана быть самодостаточной, иначе 3/6 после
// перезапуска песочницы читает несуществующий test-build/ и падает не по делу.
import { spawnSync } from 'node:child_process';
import { readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isMainModule, portableCommand } from './spawn-portable.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/** `"N/M"` → `{ index, total }`; бросает на неверном вводе, а не угадывает. */
export function parseChunk(spec) {
const match = /^(\d+)\/(\d+)$/.exec(String(spec ?? '').trim());
if (!match) throw new Error(`ожидается N/M (например 2/6), получено «${spec ?? ''}»`);
const index = Number(match[1]);
const total = Number(match[2]);
if (total < 1 || index < 1 || index > total) {
throw new Error(`часть ${index} из ${total} не существует: нужно 1 ≤ N ≤ M`);
}
return { index, total };
}
/** Тестовые файлы набора `npm test` (`test/*.test.mjs`), отсортированные по коду символов. */
export function listTestFiles(dir = join(ROOT, 'test')) {
return readdirSync(dir).filter((name) => name.endsWith('.test.mjs'))
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
.map((name) => `test/${name}`);
}
/** Часть `index` из `total`: round-robin по отсортированному списку. */
export function chunkFiles(files, index, total) {
const sorted = [...files].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
return sorted.filter((_, i) => i % total === index - 1);
}
function main(argv) {
const spec = argv.find((arg) => !arg.startsWith('--'));
let chunk;
try {
chunk = parseChunk(spec);
} catch (error) {
console.error(`test:chunk: ${error.message}\n npm run test:chunk -- 2/6 [--no-build] [--list]`);
return 2;
}
const files = chunkFiles(listTestFiles(), chunk.index, chunk.total);
console.log(`test:chunk ${chunk.index}/${chunk.total}: файлов ${files.length}`);
if (argv.includes('--list')) {
for (const file of files) console.log(` ${file}`);
return 0;
}
if (!files.length) return 0;
const run = (command, args) => {
const { cmd, shell } = portableCommand(command);
return spawnSync(cmd, args, { cwd: ROOT, stdio: 'inherit', shell }).status ?? 1;
};
if (!argv.includes('--no-build')) {
const built = run('npx', ['tsc', '-p', 'tsconfig.test.json']);
if (built !== 0) return built;
const fixed = run(process.execPath, ['scripts/fix-test-build.mjs']);
if (fixed !== 0) return fixed;
}
return run(process.execPath, ['--test', ...files]);
}
if (isMainModule(import.meta.url)) process.exitCode = main(process.argv.slice(2));