mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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
This commit is contained in:
@@ -68,7 +68,6 @@ export const NOT_AN_INPUT = [
|
||||
['demo/downgrade_open_passage.mjs', 'ручной инструмент миграции фикстур'],
|
||||
['scripts/dev/**', 'локальные утилиты разработчика'],
|
||||
['scripts/install-hooks.mjs', 'установка git-хуков при npm ci'],
|
||||
['scripts/pre-push-gate.mjs', 'локальный pre-push набор (HP_PREPUSH_GATE), в CI не исполняется'],
|
||||
['scripts/golden-accept.mjs', 'приёмка эталонов человеком, после прогона (#344)'],
|
||||
['scripts/golden-container.mjs', 'локальная съёмка в пиновом образе (#334), ручной запуск'],
|
||||
['scripts/inventory.mjs', 'отчёт для аудита, не гейт'],
|
||||
|
||||
@@ -10148,6 +10148,85 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' if (body !== null) continue; // mutant: only the file counts',
|
||||
}],
|
||||
},
|
||||
// #633: локальный контур — хук pre-push гонит gate:small, деление юнитов на части.
|
||||
{
|
||||
id: 'pre-push-hook-ignores-red-gate-small',
|
||||
guard: 'node --test test/pre-push-gate.test.mjs',
|
||||
because: 'хук, который печатает «красный» и всё равно пускает push, — ровно то, что #633 AC1 '
|
||||
+ 'запрещает: ошибка gate:small снова всплывает в Validate через полчаса',
|
||||
patches: [{
|
||||
file: 'scripts/pre-push-gate.mjs',
|
||||
find: ' const code = runGate(plan.base);',
|
||||
replace: ' const code = runGate(plan.base) && 0; // mutant: red gate passes',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'pre-push-gate-inherits-git-dir',
|
||||
guard: 'node --test test/pre-push-gate.test.mjs',
|
||||
because: 'хук git запускает набор с GIT_DIR репозитория; юниты с временными репозиториями '
|
||||
+ 'тогда пишут в настоящий — bare-флаг, коммиты фикстур, ветка dev перезаписана (#633, '
|
||||
+ 'первый живой прогон)',
|
||||
patches: [{
|
||||
file: 'scripts/pre-push-gate.mjs',
|
||||
find: " { cwd: ROOT, stdio: ['ignore', 'inherit', 'inherit'], shell, env: gateEnv(env) }).status ?? 1;",
|
||||
replace: " { cwd: ROOT, stdio: ['ignore', 'inherit', 'inherit'], shell, env }).status ?? 1;",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'pre-push-shell-drops-gate-status',
|
||||
guard: 'node --test test/pre-push-gate.test.mjs',
|
||||
because: 'pre-push-gate.mjs может вернуть 1, но решает код выхода самого .githooks/pre-push: '
|
||||
+ 'потерянный статус в шелле пропускает красный gate:small (#633 AC1)',
|
||||
patches: [{
|
||||
file: '.githooks/pre-push',
|
||||
find: ' gate_status=1\n',
|
||||
replace: ' gate_status=0\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'pre-push-hook-misses-issue-branches',
|
||||
guard: 'node --test test/pre-push-gate.test.mjs',
|
||||
because: 'префикс веток задач — единственное, что включает набор по умолчанию; опечатка в нём '
|
||||
+ 'молча возвращает opt-in, который #633 отменил',
|
||||
patches: [{
|
||||
file: 'scripts/pre-push-gate.mjs',
|
||||
find: " && (mode === 'force' || ref.remoteRef.startsWith('refs/heads/issue/')));",
|
||||
replace: " && (mode === 'force' || ref.remoteRef.startsWith('refs/heads/issues/')));",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'pre-push-docs-filter-swallows-executable-diff',
|
||||
guard: 'node --test test/pre-push-gate.test.mjs',
|
||||
because: 'пропуск «только C/D» должен требовать, чтобы ВСЕ файлы были документами; some вместо '
|
||||
+ 'every пропускает код, приложенный к одной строке документации (#633)',
|
||||
patches: [{
|
||||
file: 'scripts/pre-push-gate.mjs',
|
||||
find: " return files.length > 0 && files.every((file) => ['C', 'D'].includes(classify(file)));",
|
||||
replace: " return files.length > 0 && files.some((file) => ['C', 'D'].includes(classify(file)));",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'pre-push-hook-judges-foreign-tree',
|
||||
guard: 'node --test test/pre-push-gate.test.mjs',
|
||||
because: 'gate:small проверяет рабочее дерево; пуш не-HEAD ветки без отказа получает зелёный '
|
||||
+ 'вердикт чужому коду (#633)',
|
||||
patches: [{
|
||||
file: 'scripts/pre-push-gate.mjs',
|
||||
find: ' const foreign = needing.filter((ref) => ref.localSha !== headSha);',
|
||||
replace: ' const foreign = needing.filter(() => false); // mutant: any tree will do',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'test-chunk-drops-last-file',
|
||||
guard: 'node --test test/test-chunk.test.mjs',
|
||||
because: 'деление, теряющее файл, даёт зелёные части при непрогнанном тесте — ровно то, от '
|
||||
+ 'чего #633 AC3 требует «каждый файл ровно в одной части»',
|
||||
patches: [{
|
||||
file: 'scripts/test-chunk.mjs',
|
||||
find: ' return sorted.filter((_, i) => i % total === index - 1);',
|
||||
replace: ' return sorted.filter((_, i) => i % total === index - 1 && i < sorted.length - 1);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'review-returns-task-on-cancelled-dispatch',
|
||||
guard: 'node --test test/ci-proof.test.mjs test/validate-gate.test.mjs',
|
||||
|
||||
+295
-134
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Локальный набор гейтов перед пушем (#343).
|
||||
* Локальный набор гейтов перед пушем (#343) и режим хука (#633).
|
||||
*
|
||||
* node scripts/pre-push-gate.mjs --hook # из .githooks/pre-push, строки git на stdin
|
||||
* node scripts/pre-push-gate.mjs
|
||||
* node scripts/pre-push-gate.mjs --base origin/dev --head HEAD
|
||||
* node scripts/pre-push-gate.mjs --no-smokes --no-mutants
|
||||
@@ -24,166 +25,326 @@
|
||||
* ночное расписание (#513); здесь только то, что укладывается в минуты и
|
||||
* ловит 90% возвратов.
|
||||
*
|
||||
* Режим хука (#633). `.githooks/pre-push` передаёт сюда строки, которые git
|
||||
* подаёт хуку, и для каждой ветки `issue/*` решает, гнать ли `npm run gate:small`
|
||||
* (`scripts/gate-small.mjs`). По умолчанию — гнать: ошибка, которую набор ловит за
|
||||
* минуты, иначе всплывает в Validate и конвейере через полчаса. Не гонится:
|
||||
* · `HP_PREPUSH_GATE=0` — явное выключение (`=1` — наоборот, для любой ветки и
|
||||
* любого диффа);
|
||||
* · ветка не `issue/*` (dev, main, теги, удаление) — там свой контур;
|
||||
* · дифф ветки от merge-base с origin/dev — только класс C/D (документы ревью,
|
||||
* changelog, бандл): набору нечего в нём проверить.
|
||||
* Набор проверяет рабочее дерево, поэтому пушиться должен HEAD. Ветка с
|
||||
* исполняемым диффом, которая не HEAD, отклоняется — иначе зелёный вердикт был
|
||||
* бы вынесен чужому дереву; обход назван в сообщении.
|
||||
*
|
||||
* Бандл не собирается: `bundle-sync.mjs` раскладывает закоммиченный `dist`, а
|
||||
* свежесть проверяет сам продукт — `assertFreshDemoBundle` внутри каждого смока
|
||||
* сверяет вшитый отпечаток с исходниками дерева и скажет, если нужна пересборка.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { rebaseAdvice } from './branch-state.mjs';
|
||||
import { classify } from './process-gate.mjs';
|
||||
import { isMainModule, portableCommand } from './spawn-portable.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const argv = process.argv.slice(2);
|
||||
const flag = (name) => argv.includes(`--${name}`);
|
||||
const value = (name, fallback) => {
|
||||
const found = argv.find((item) => item.startsWith(`--${name}=`));
|
||||
if (found) return found.slice(name.length + 3);
|
||||
const index = argv.indexOf(`--${name}`);
|
||||
return index >= 0 && argv[index + 1] && !argv[index + 1].startsWith('--')
|
||||
? argv[index + 1] : fallback;
|
||||
const ZERO_SHA = /^0{40}$/;
|
||||
|
||||
// ---- режим хука (#633) ----------------------------------------------------
|
||||
|
||||
/** Строки, которые git подаёт pre-push: `<local ref> <local sha> <remote ref> <remote sha>`. */
|
||||
export function parsePushLines(text) {
|
||||
return String(text ?? '').split('\n').map((line) => line.trim()).filter(Boolean)
|
||||
.map((line) => {
|
||||
const [localRef, localSha, remoteRef, remoteSha] = line.split(/\s+/);
|
||||
return { localRef, localSha, remoteRef, remoteSha };
|
||||
})
|
||||
.filter((ref) => ref.localSha && ref.remoteRef);
|
||||
}
|
||||
|
||||
/** `off` — явно выключен, `force` — для любой ветки и диффа, `default` — ветки issue/*. */
|
||||
export function gateMode(env = process.env) {
|
||||
const raw = String(env.HP_PREPUSH_GATE ?? '').trim().toLowerCase();
|
||||
if (['0', 'off', 'false', 'no'].includes(raw)) return 'off';
|
||||
if (['1', 'on', 'true', 'yes'].includes(raw)) return 'force';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/** Ветки, которые набор вообще рассматривает: не удаление, не тег, `issue/*` (или любая при force). */
|
||||
export function candidateRefs(refs, mode) {
|
||||
return refs.filter((ref) => !ZERO_SHA.test(ref.localSha)
|
||||
&& ref.remoteRef.startsWith('refs/heads/')
|
||||
&& (mode === 'force' || ref.remoteRef.startsWith('refs/heads/issue/')));
|
||||
}
|
||||
|
||||
/** Дифф только из документации и сгенерированного (C/D) — набору нечего проверять. */
|
||||
export function isDocsOrGeneratedOnly(files) {
|
||||
return files.length > 0 && files.every((file) => ['C', 'D'].includes(classify(file)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Решение хука. Чистая функция: git передаётся через `mergeBase` и `changedFiles`.
|
||||
*
|
||||
* @returns {{ action: 'skip' | 'run' | 'reject', reason: string, base?: string, ref?: string }}
|
||||
*/
|
||||
export function planHook({ mode, refs, headSha, mergeBase, changedFiles }) {
|
||||
if (mode === 'off') return { action: 'skip', reason: 'HP_PREPUSH_GATE=0 — gate:small выключен явно' };
|
||||
const candidates = candidateRefs(refs, mode);
|
||||
if (!candidates.length) {
|
||||
return { action: 'skip', reason: 'в пуше нет веток issue/* — gate:small не требуется (HP_PREPUSH_GATE=1 включит для любой)' };
|
||||
}
|
||||
const needing = [];
|
||||
const notes = [];
|
||||
for (const ref of candidates) {
|
||||
const base = mergeBase(ref.localSha);
|
||||
const files = base ? changedFiles(base, ref.localSha) : null;
|
||||
if (mode !== 'force' && files && !files.length) {
|
||||
notes.push(`${ref.remoteRef}: диффа от origin/dev нет`);
|
||||
continue;
|
||||
}
|
||||
if (mode !== 'force' && files && isDocsOrGeneratedOnly(files)) {
|
||||
notes.push(`${ref.remoteRef}: дифф только класс C/D (${files.length} файл.)`);
|
||||
continue;
|
||||
}
|
||||
needing.push({ ...ref, base: base || 'origin/dev' });
|
||||
}
|
||||
if (!needing.length) return { action: 'skip', reason: `${notes.join('; ')} — gate:small не требуется` };
|
||||
const foreign = needing.filter((ref) => ref.localSha !== headSha);
|
||||
if (foreign.length) {
|
||||
return {
|
||||
action: 'reject',
|
||||
reason: `пушится не HEAD: ${foreign.map((ref) => `${ref.localRef} (${ref.localSha.slice(0, 12)})`).join(', ')}.`
|
||||
+ ' gate:small проверяет рабочее дерево, а оно не совпадает с пушимым коммитом.'
|
||||
+ ' Переключитесь на ветку и повторите push, либо осознанно: HP_PREPUSH_GATE=0 git push …',
|
||||
};
|
||||
}
|
||||
const [first] = needing;
|
||||
return { action: 'run', ref: first.remoteRef, base: first.base, reason: `${first.remoteRef}, база ${first.base.slice(0, 12)}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Окружение набора без переменных git (#633). Хук git запускает с `GIT_DIR`,
|
||||
* указывающим на репозиторий, а в `GIT_CONFIG_PARAMETERS` — все `-c` пушащего
|
||||
* (включая credential helper). Юниты создают временные репозитории через
|
||||
* `git init` / `git -C tmp …`, и с унаследованным `GIT_DIR` каждая такая
|
||||
* команда пишет в НАСТОЯЩИЙ репозиторий: переинициализация как bare, коммиты
|
||||
* фикстур на ветку, переименование ветки в dev, user.name в общий config — всё
|
||||
* это случилось на первом живом прогоне. Набор обязан видеть то же окружение,
|
||||
* что и при ручном `npm run gate:small`, поэтому все GIT_* снимаются.
|
||||
*/
|
||||
export function gateEnv(env = process.env) {
|
||||
return Object.fromEntries(Object.entries(env).filter(([key]) => !/^GIT_/i.test(key)));
|
||||
}
|
||||
|
||||
const gitOut = (args) => {
|
||||
const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
|
||||
return result.status === 0 ? result.stdout.trim() : null;
|
||||
};
|
||||
|
||||
const base = value('base', 'origin/dev');
|
||||
const head = value('head', 'HEAD');
|
||||
const maxSmokes = Number(value('max-smokes', 6));
|
||||
const maxMutants = Number(value('max-mutants', 2));
|
||||
/** Режим хука целиком; возвращает код выхода. `runGate(base)` — код выхода набора. */
|
||||
export function runHook({
|
||||
stdin,
|
||||
env = process.env,
|
||||
log = (line) => console.error(line),
|
||||
git = gitOut,
|
||||
runGate = (base) => {
|
||||
const { cmd, shell } = portableCommand(process.execPath);
|
||||
return spawnSync(cmd, [resolve(ROOT, 'scripts/gate-small.mjs'), `--base=${base}`],
|
||||
{ cwd: ROOT, stdio: ['ignore', 'inherit', 'inherit'], shell, env: gateEnv(env) }).status ?? 1;
|
||||
},
|
||||
} = {}) {
|
||||
const plan = planHook({
|
||||
mode: gateMode(env),
|
||||
refs: parsePushLines(stdin),
|
||||
headSha: git(['rev-parse', 'HEAD']),
|
||||
mergeBase: (sha) => git(['merge-base', sha, 'refs/remotes/origin/dev']),
|
||||
changedFiles: (base, sha) => {
|
||||
const out = git(['diff', '--name-only', base, sha]);
|
||||
return out === null ? null : out.split('\n').filter(Boolean);
|
||||
},
|
||||
});
|
||||
if (plan.action === 'skip') {
|
||||
log(`pre-push-gate: ${plan.reason}`);
|
||||
return 0;
|
||||
}
|
||||
if (plan.action === 'reject') {
|
||||
log(`pre-push-gate: push отклонён — ${plan.reason}`);
|
||||
return 1;
|
||||
}
|
||||
log(`pre-push-gate: ${plan.reason} — прогоняю npm run gate:small (выключить: HP_PREPUSH_GATE=0)`);
|
||||
const code = runGate(plan.base);
|
||||
if (code !== 0) {
|
||||
log('pre-push-gate: gate:small красный — push отклонён. Починить и повторить;'
|
||||
+ ' осознанный обход: HP_PREPUSH_GATE=0 git push … (Validate найдёт то же самое позже)');
|
||||
return 1;
|
||||
}
|
||||
log('pre-push-gate: gate:small зелёный');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const run = (label, command, args, options = {}) => {
|
||||
const started = Date.now();
|
||||
process.stdout.write(`\n── ${label}\n ${command} ${args.join(' ')}\n`);
|
||||
const result = spawnSync(command, args, { cwd: ROOT, stdio: 'inherit', ...options });
|
||||
const seconds = ((Date.now() - started) / 1000).toFixed(0);
|
||||
const ok = result.status === 0;
|
||||
process.stdout.write(` ${ok ? 'ok' : 'ПРОВАЛ'} · ${seconds} с\n`);
|
||||
return { label, ok, seconds: Number(seconds), command: `${command} ${args.join(' ')}` };
|
||||
};
|
||||
// ---- ручной набор (#343) --------------------------------------------------
|
||||
|
||||
const capture = (command, args) => spawnSync(command, args, { cwd: ROOT, encoding: 'utf8' });
|
||||
function manualGate(argv) {
|
||||
const flag = (name) => argv.includes(`--${name}`);
|
||||
const value = (name, fallback) => {
|
||||
const found = argv.find((item) => item.startsWith(`--${name}=`));
|
||||
if (found) return found.slice(name.length + 3);
|
||||
const index = argv.indexOf(`--${name}`);
|
||||
return index >= 0 && argv[index + 1] && !argv[index + 1].startsWith('--')
|
||||
? argv[index + 1] : fallback;
|
||||
};
|
||||
|
||||
const steps = [];
|
||||
const skipped = [];
|
||||
const base = value('base', 'origin/dev');
|
||||
const head = value('head', 'HEAD');
|
||||
const maxSmokes = Number(value('max-smokes', 6));
|
||||
const maxMutants = Number(value('max-mutants', 2));
|
||||
|
||||
// ---- приведена ли ветка к dev ---------------------------------------------
|
||||
// Конвейер ребейзит сам (#257), но после ребейза разбор становится полным, а не
|
||||
// по дельте (§7.2), и конфликт всплывает в комментарии через сорок минут вместо
|
||||
// машины автора. Поэтому предупреждение, а не гейт: гейтом остаётся конвейер.
|
||||
if (!flag('no-rebase-check')) {
|
||||
const fetched = capture('git', ['fetch', '-q', 'origin', base.replace(/^origin\//, '')]);
|
||||
if (fetched.status !== 0) {
|
||||
skipped.push(`проверка отставания от ${base} — git fetch не удался`);
|
||||
} else {
|
||||
const ancestor = capture('git', ['merge-base', '--is-ancestor', base, head]);
|
||||
if (ancestor.status === 0) {
|
||||
console.log(`\n── Ветка\n содержит весь ${base}, ребейз не нужен`);
|
||||
const run = (label, command, args, options = {}) => {
|
||||
const started = Date.now();
|
||||
process.stdout.write(`\n── ${label}\n ${command} ${args.join(' ')}\n`);
|
||||
const { cmd, shell } = portableCommand(command);
|
||||
const result = spawnSync(cmd, args, { cwd: ROOT, stdio: 'inherit', shell, ...options });
|
||||
const seconds = ((Date.now() - started) / 1000).toFixed(0);
|
||||
const ok = result.status === 0;
|
||||
process.stdout.write(` ${ok ? 'ok' : 'ПРОВАЛ'} · ${seconds} с\n`);
|
||||
return { label, ok, seconds: Number(seconds), command: `${command} ${args.join(' ')}` };
|
||||
};
|
||||
|
||||
const capture = (command, args) => spawnSync(command, args, { cwd: ROOT, encoding: 'utf8' });
|
||||
|
||||
const steps = [];
|
||||
const skipped = [];
|
||||
|
||||
// ---- приведена ли ветка к dev ---------------------------------------------
|
||||
// Конвейер ребейзит сам (#257), но после ребейза разбор становится полным, а не
|
||||
// по дельте (§7.2), и конфликт всплывает в комментарии через сорок минут вместо
|
||||
// машины автора. Поэтому предупреждение, а не гейт: гейтом остаётся конвейер.
|
||||
if (!flag('no-rebase-check')) {
|
||||
const fetched = capture('git', ['fetch', '-q', 'origin', base.replace(/^origin\//, '')]);
|
||||
if (fetched.status !== 0) {
|
||||
skipped.push(`проверка отставания от ${base} — git fetch не удался`);
|
||||
} else {
|
||||
const counted = capture('git', ['rev-list', '--count', `${head}..${base}`]);
|
||||
const advice = rebaseAdvice({ behind: Number(counted.stdout.trim()), base });
|
||||
if (advice) skipped.push(advice);
|
||||
const ancestor = capture('git', ['merge-base', '--is-ancestor', base, head]);
|
||||
if (ancestor.status === 0) {
|
||||
console.log(`\n── Ветка\n содержит весь ${base}, ребейз не нужен`);
|
||||
} else {
|
||||
const counted = capture('git', ['rev-list', '--count', `${head}..${base}`]);
|
||||
const advice = rebaseAdvice({ behind: Number(counted.stdout.trim()), base });
|
||||
if (advice) skipped.push(advice);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- что тронуто ----------------------------------------------------------
|
||||
const diff = capture('git', ['diff', '--name-only', `${base}..${head}`]);
|
||||
if (diff.status !== 0) {
|
||||
console.error(`git diff ${base}..${head} не удался:\n${diff.stderr}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const changed = diff.stdout.split('\n').filter(Boolean);
|
||||
console.log(`Диапазон ${base}..${head}: файлов ${changed.length}`);
|
||||
for (const file of changed.slice(0, 20)) console.log(` ${file}`);
|
||||
if (changed.length > 20) console.log(` и ещё ${changed.length - 20}`);
|
||||
if (!changed.length) {
|
||||
console.log('\nДиффа нет — проверять нечего.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ---- типы и юниты: всегда -------------------------------------------------
|
||||
steps.push(run('Типы', 'npx', ['tsc', '--noEmit']));
|
||||
steps.push(run('Юнит-тесты', 'npm', ['test']));
|
||||
|
||||
// ---- смоки по диффу -------------------------------------------------------
|
||||
if (flag('no-smokes')) {
|
||||
skipped.push('смоки — запрошено --no-smokes');
|
||||
} else {
|
||||
const select = capture('node', ['scripts/smoke-select.mjs', '--base', base, '--head', head, '--json']);
|
||||
let picked = [];
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(select.stdout || '{}');
|
||||
// Прямое совпадение и зарегистрированная связь — разные виды ответа, но для
|
||||
// прогона равноправны. Третий вид, `unproven`, смоков не даёт и обязан
|
||||
// прозвучать отдельно: это не «проверять нечего» (#241).
|
||||
picked = [...new Set([
|
||||
...(parsed.direct || []).map((item) => item.smoke),
|
||||
...(parsed.registered || []).map((item) => item.smoke),
|
||||
])].filter((name) => typeof name === 'string');
|
||||
} catch {
|
||||
skipped.push('смоки — не удалось разобрать вывод smoke-select (запустите его вручную)');
|
||||
// ---- что тронуто ----------------------------------------------------------
|
||||
const diff = capture('git', ['diff', '--name-only', `${base}..${head}`]);
|
||||
if (diff.status !== 0) {
|
||||
console.error(`git diff ${base}..${head} не удался:\n${diff.stderr}`);
|
||||
return 2;
|
||||
}
|
||||
if (parsed?.noExecutableDiff) {
|
||||
console.log('\n── Смоки\n исполняемого кода дифф не трогает — смоки не требуются');
|
||||
} else if (parsed?.unproven) {
|
||||
skipped.push('смоки — дифф исполняемый, но связь ни с одним смоком не доказана.'
|
||||
+ ' Это НЕ «проверять нечего»: молчание здесь стоило #234 бета-блокирующего'
|
||||
+ ' регресса. Либо назовите смок в scripts/smoke-links.mjs, либо напишите новый');
|
||||
const changed = diff.stdout.split('\n').filter(Boolean);
|
||||
console.log(`Диапазон ${base}..${head}: файлов ${changed.length}`);
|
||||
for (const file of changed.slice(0, 20)) console.log(` ${file}`);
|
||||
if (changed.length > 20) console.log(` и ещё ${changed.length - 20}`);
|
||||
if (!changed.length) {
|
||||
console.log('\nДиффа нет — проверять нечего.');
|
||||
return 0;
|
||||
}
|
||||
if (picked.length) {
|
||||
// Стенд читает свою копию бандла; без раскладки смок врёт согласованно (#236).
|
||||
const missingEntries = ['houseplan-card.js', 'houseplan-panel.js']
|
||||
.filter((name) => !existsSync(resolve(ROOT, 'dist', name)));
|
||||
if (!missingEntries.length) {
|
||||
steps.push(run('Раскладка бандла', 'node', ['scripts/bundle-sync.mjs']));
|
||||
} else {
|
||||
skipped.push(`смоки — нет dist/${missingEntries.join(', dist/')}, нужен \`npm run build\``);
|
||||
picked = [];
|
||||
|
||||
// ---- типы и юниты: всегда -------------------------------------------------
|
||||
steps.push(run('Типы', 'npx', ['tsc', '--noEmit']));
|
||||
steps.push(run('Юнит-тесты', 'npm', ['test']));
|
||||
|
||||
// ---- смоки по диффу -------------------------------------------------------
|
||||
if (flag('no-smokes')) {
|
||||
skipped.push('смоки — запрошено --no-smokes');
|
||||
} else {
|
||||
const select = capture('node', ['scripts/smoke-select.mjs', '--base', base, '--head', head, '--json']);
|
||||
let picked = [];
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(select.stdout || '{}');
|
||||
// Прямое совпадение и зарегистрированная связь — разные виды ответа, но для
|
||||
// прогона равноправны. Третий вид, `unproven`, смоков не даёт и обязан
|
||||
// прозвучать отдельно: это не «проверять нечего» (#241).
|
||||
picked = [...new Set([
|
||||
...(parsed.direct || []).map((item) => item.smoke),
|
||||
...(parsed.registered || []).map((item) => item.smoke),
|
||||
])].filter((name) => typeof name === 'string');
|
||||
} catch {
|
||||
skipped.push('смоки — не удалось разобрать вывод smoke-select (запустите его вручную)');
|
||||
}
|
||||
if (parsed?.noExecutableDiff) {
|
||||
console.log('\n── Смоки\n исполняемого кода дифф не трогает — смоки не требуются');
|
||||
} else if (parsed?.unproven) {
|
||||
skipped.push('смоки — дифф исполняемый, но связь ни с одним смоком не доказана.'
|
||||
+ ' Это НЕ «проверять нечего»: молчание здесь стоило #234 бета-блокирующего'
|
||||
+ ' регресса. Либо назовите смок в scripts/smoke-links.mjs, либо напишите новый');
|
||||
}
|
||||
if (picked.length) {
|
||||
// Стенд читает свою копию бандла; без раскладки смок врёт согласованно (#236).
|
||||
const missingEntries = ['houseplan-card.js', 'houseplan-panel.js']
|
||||
.filter((name) => !existsSync(resolve(ROOT, 'dist', name)));
|
||||
if (!missingEntries.length) {
|
||||
steps.push(run('Раскладка бандла', 'node', ['scripts/bundle-sync.mjs']));
|
||||
} else {
|
||||
skipped.push(`смоки — нет dist/${missingEntries.join(', dist/')}, нужен \`npm run build\``);
|
||||
picked = [];
|
||||
}
|
||||
}
|
||||
if (picked.length > maxSmokes) {
|
||||
skipped.push(`смоки помимо первых ${maxSmokes}: ${picked.slice(maxSmokes).join(', ')}`
|
||||
+ ' — снимите ограничение --max-smokes, если время есть');
|
||||
picked = picked.slice(0, maxSmokes);
|
||||
}
|
||||
for (const name of picked) {
|
||||
steps.push(run(`Смок ${name}`, 'node', [`demo/${name}`]));
|
||||
}
|
||||
}
|
||||
if (picked.length > maxSmokes) {
|
||||
skipped.push(`смоки помимо первых ${maxSmokes}: ${picked.slice(maxSmokes).join(', ')}`
|
||||
+ ' — снимите ограничение --max-smokes, если время есть');
|
||||
picked = picked.slice(0, maxSmokes);
|
||||
}
|
||||
for (const name of picked) {
|
||||
steps.push(run(`Смок ${name}`, 'node', [`demo/${name}`]));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- мутанты по диффу -----------------------------------------------------
|
||||
if (flag('no-mutants')) {
|
||||
skipped.push('мутанты — запрошено --no-mutants');
|
||||
} else {
|
||||
const list = capture('node', ['scripts/mutation-gate.mjs', '--changed=' + `${base}..${head}`, '--check']);
|
||||
const touched = Number(/мутантов затронуто (\d+)/.exec(list.stdout || '')?.[1] ?? -1);
|
||||
if (touched === 0) {
|
||||
console.log('\n── Мутанты\n дифф не задевает ни одного patch.file');
|
||||
} else if (touched < 0) {
|
||||
skipped.push('мутанты — не удалось определить выборку по диффу');
|
||||
} else if (touched > maxMutants) {
|
||||
skipped.push(`мутанты (${touched}) — больше лимита ${maxMutants};`
|
||||
+ ` каждый пересобирает бандл. Прогон: node scripts/mutation-gate.mjs --changed=${base}..${head}`);
|
||||
// ---- мутанты по диффу -----------------------------------------------------
|
||||
if (flag('no-mutants')) {
|
||||
skipped.push('мутанты — запрошено --no-mutants');
|
||||
} else {
|
||||
steps.push(run('Мутанты по диффу', 'node',
|
||||
['scripts/mutation-gate.mjs', `--changed=${base}..${head}`]));
|
||||
const list = capture('node', ['scripts/mutation-gate.mjs', '--changed=' + `${base}..${head}`, '--check']);
|
||||
const touched = Number(/мутантов затронуто (\d+)/.exec(list.stdout || '')?.[1] ?? -1);
|
||||
if (touched === 0) {
|
||||
console.log('\n── Мутанты\n дифф не задевает ни одного patch.file');
|
||||
} else if (touched < 0) {
|
||||
skipped.push('мутанты — не удалось определить выборку по диффу');
|
||||
} else if (touched > maxMutants) {
|
||||
skipped.push(`мутанты (${touched}) — больше лимита ${maxMutants};`
|
||||
+ ` каждый пересобирает бандл. Прогон: node scripts/mutation-gate.mjs --changed=${base}..${head}`);
|
||||
} else {
|
||||
steps.push(run('Мутанты по диффу', 'node',
|
||||
['scripts/mutation-gate.mjs', `--changed=${base}..${head}`]));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- вердикт --------------------------------------------------------------
|
||||
const failed = steps.filter((step) => !step.ok);
|
||||
const total = steps.reduce((sum, step) => sum + step.seconds, 0);
|
||||
console.log(`\n${'═'.repeat(60)}`);
|
||||
for (const step of steps) console.log(`${step.ok ? 'ok ' : 'ПРОВАЛ'} ${step.label} (${step.seconds} с)`);
|
||||
console.log(`Суммарно: ${Math.floor(total / 60)} мин ${total % 60} с`);
|
||||
if (skipped.length) {
|
||||
console.log('\nЧего этот набор НЕ проверял:');
|
||||
for (const item of skipped) console.log(` · ${item}`);
|
||||
}
|
||||
console.log(' · golden, полная матрица смоков, HA-харнесс — heavy-набор Validate на кандидате;'
|
||||
+ ' весь мутационный реестр — ночное расписание (#513), не этот набор');
|
||||
if (failed.length) {
|
||||
console.log(`\nПровалов: ${failed.length}. Пуш до починки — это лишний раунд ревью.`);
|
||||
for (const step of failed) console.log(` ${step.command}`);
|
||||
}
|
||||
return failed.length ? 1 : 0;
|
||||
}
|
||||
|
||||
// ---- вердикт --------------------------------------------------------------
|
||||
const failed = steps.filter((step) => !step.ok);
|
||||
const total = steps.reduce((sum, step) => sum + step.seconds, 0);
|
||||
console.log(`\n${'═'.repeat(60)}`);
|
||||
for (const step of steps) console.log(`${step.ok ? 'ok ' : 'ПРОВАЛ'} ${step.label} (${step.seconds} с)`);
|
||||
console.log(`Суммарно: ${Math.floor(total / 60)} мин ${total % 60} с`);
|
||||
if (skipped.length) {
|
||||
console.log('\nЧего этот набор НЕ проверял:');
|
||||
for (const item of skipped) console.log(` · ${item}`);
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const argv = process.argv.slice(2);
|
||||
process.exitCode = argv.includes('--hook')
|
||||
? runHook({ stdin: readFileSync(0, 'utf8') })
|
||||
: manualGate(argv);
|
||||
}
|
||||
console.log(' · golden, полная матрица смоков, HA-харнесс — heavy-набор Validate на кандидате;'
|
||||
+ ' весь мутационный реестр — ночное расписание (#513), не этот набор');
|
||||
if (failed.length) {
|
||||
console.log(`\nПровалов: ${failed.length}. Пуш до починки — это лишний раунд ревью.`);
|
||||
for (const step of failed) console.log(` ${step.command}`);
|
||||
}
|
||||
process.exit(failed.length ? 1 : 0);
|
||||
|
||||
Executable
+191
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env bash
|
||||
# Локальный контур в Linux-песочнице агента одной командой (#633).
|
||||
#
|
||||
# bash scripts/sandbox-bootstrap.sh # всё по порядку
|
||||
# bash scripts/sandbox-bootstrap.sh <шаг> # worktree | deps | chromium | bundle | check
|
||||
# HP_BRANCH=issue/633-local-contour bash scripts/sandbox-bootstrap.sh
|
||||
#
|
||||
# Идемпотентен: готовый шаг узнаётся и пропускается, поэтому при обрыве (лимит
|
||||
# одной команды песочницы ≈ 3 мин, перезапуск песочницы стирает /tmp) скрипт
|
||||
# просто запускают ещё раз. Каждый шаг по отдельности укладывается в 3 минуты;
|
||||
# `all` на холодной песочнице может не уложиться — тогда повторить, сделанное
|
||||
# не переделывается. Скрипт не знает ни путей владельца, ни учётных данных: всё
|
||||
# машинно-специфичное приходит переменными окружения.
|
||||
#
|
||||
# HP_CLONE клон, из которого заводится worktree (по умолчанию —
|
||||
# репозиторий, где лежит этот скрипт)
|
||||
# HP_WORKTREE куда положить worktree (/tmp/hp-wt)
|
||||
# HP_BRANCH ветка worktree; если есть на origin — берётся оттуда,
|
||||
# иначе создаётся от HP_REF. Без неё — detached HEAD
|
||||
# HP_REF от чего заводить (origin/dev)
|
||||
# HP_SHARED_NODE_MODULES готовый node_modules с тем же package-lock.json:
|
||||
# ссылка на него вместо npm ci (параллельные сессии)
|
||||
# HP_CHROMIUM_DIR куда распаковать Chromium (/tmp/chr)
|
||||
# HP_CHROMIUM_PKG npm-пакет с Chromium (@sparticuz/chromium@152.0.0)
|
||||
#
|
||||
# Почему Chromium из npm, а не `npx playwright install`: CDN Playwright
|
||||
# (cdn.playwright.dev) закрыт allowlist'ом песочницы, а реестр npm открыт.
|
||||
# Пакет содержит brotli-архив бинаря и его библиотек; Playwright получает шим по
|
||||
# своему ожидаемому пути, который запускает этот бинарь. Существующий настоящий
|
||||
# браузер по этому пути не трогается — шим узнаётся по маркеру.
|
||||
#
|
||||
# Golden этим браузером не снимаются (#455, только Linux CI); смоки и
|
||||
# `golden:verify` как совет — да.
|
||||
set -euo pipefail
|
||||
|
||||
STEP="${1:-all}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CLONE="${HP_CLONE:-$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)}"
|
||||
WT="${HP_WORKTREE:-/tmp/hp-wt}"
|
||||
REF="${HP_REF:-origin/dev}"
|
||||
BRANCH="${HP_BRANCH:-}"
|
||||
CHR="${HP_CHROMIUM_DIR:-/tmp/chr}"
|
||||
CHR_PKG="${HP_CHROMIUM_PKG:-@sparticuz/chromium@152.0.0}"
|
||||
SHIM_MARK="# hp-sandbox-bootstrap chromium shim"
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
die() { printf 'sandbox-bootstrap: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
step_worktree() {
|
||||
if git -C "$WT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
say "worktree: $WT уже есть ($(git -C "$WT" rev-parse --abbrev-ref HEAD) @ $(git -C "$WT" rev-parse --short HEAD)) — не трогаю"
|
||||
return 0
|
||||
fi
|
||||
[ -e "$WT" ] && die "$WT существует, но это не рабочее дерево git — уберите его или задайте HP_WORKTREE"
|
||||
git -C "$CLONE" fetch -q origin
|
||||
if [ -n "$BRANCH" ]; then
|
||||
local from="$REF"
|
||||
if [ -z "${HP_REF:-}" ] && git -C "$CLONE" rev-parse -q --verify "refs/remotes/origin/$BRANCH" >/dev/null; then
|
||||
from="origin/$BRANCH"
|
||||
fi
|
||||
git -C "$CLONE" worktree add -q -B "$BRANCH" "$WT" "$from"
|
||||
say "worktree: $WT на $BRANCH от $from"
|
||||
else
|
||||
git -C "$CLONE" worktree add -q --detach "$WT" "$REF"
|
||||
say "worktree: $WT, detached на $REF"
|
||||
fi
|
||||
}
|
||||
|
||||
lock_digest() { sha256sum "$WT/package-lock.json" | cut -d' ' -f1; }
|
||||
|
||||
step_deps() {
|
||||
[ -f "$WT/package-lock.json" ] || die "нет $WT/package-lock.json — сначала шаг worktree"
|
||||
local stamp="$WT/node_modules/.hp-bootstrap-lock"
|
||||
if [ -n "${HP_SHARED_NODE_MODULES:-}" ]; then
|
||||
[ -x "$HP_SHARED_NODE_MODULES/.bin/tsc" ] || die "HP_SHARED_NODE_MODULES=$HP_SHARED_NODE_MODULES без .bin/tsc"
|
||||
if [ ! -e "$WT/node_modules" ]; then ln -s "$HP_SHARED_NODE_MODULES" "$WT/node_modules"; fi
|
||||
say "deps: node_modules -> $HP_SHARED_NODE_MODULES (общий; package-lock обязан совпадать)"
|
||||
elif [ -f "$stamp" ] && [ "$(cat "$stamp")" = "$(lock_digest)" ] && [ -x "$WT/node_modules/.bin/tsc" ]; then
|
||||
say "deps: node_modules соответствует package-lock.json — npm ci не нужен"
|
||||
else
|
||||
# --ignore-scripts: postinstall Playwright полез бы на закрытый CDN, а
|
||||
# prepare (установка хуков) выполняется ниже явно и только при нужде.
|
||||
(cd "$WT" && npm ci --ignore-scripts --no-audit --no-fund >/tmp/hp-bootstrap-npm-ci.log 2>&1) \
|
||||
|| { tail -20 /tmp/hp-bootstrap-npm-ci.log >&2; die "npm ci не удался (лог: /tmp/hp-bootstrap-npm-ci.log)"; }
|
||||
lock_digest >"$stamp"
|
||||
say "deps: npm ci — ok"
|
||||
fi
|
||||
if [ "$(git -C "$WT" config --get core.hooksPath || true)" != ".githooks" ]; then
|
||||
(cd "$WT" && node scripts/install-hooks.mjs)
|
||||
fi
|
||||
}
|
||||
|
||||
# Пути, по которым установленный Playwright ищет свои браузеры.
|
||||
playwright_targets() {
|
||||
(cd "$WT" && node --input-type=module -e "
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
const require = createRequire(join(process.cwd(), 'package.json'));
|
||||
const { chromium } = require('playwright');
|
||||
const full = chromium.executablePath();
|
||||
const root = dirname(dirname(dirname(full)));
|
||||
// browsers.json не экспортирован пакетом — читается по пути от его корня.
|
||||
const core = dirname(require.resolve('playwright-core'));
|
||||
const { browsers } = JSON.parse(readFileSync(join(core, 'browsers.json'), 'utf8'));
|
||||
const rev = browsers.find((b) => b.name === 'chromium-headless-shell').revision;
|
||||
const shell = join(root, 'chromium_headless_shell-' + rev);
|
||||
console.log([full,
|
||||
join(shell, 'chrome-linux64', 'chrome-headless-shell'),
|
||||
join(shell, 'chrome-headless-shell-linux64', 'chrome-headless-shell')].join('\n'));
|
||||
")
|
||||
}
|
||||
|
||||
brotli_out() { node -e "const z=require('zlib'),fs=require('fs');fs.writeFileSync(process.argv[2],z.brotliDecompressSync(fs.readFileSync(process.argv[1])));" "$1" "$2"; }
|
||||
|
||||
step_chromium() {
|
||||
[ -x "$WT/node_modules/.bin/tsc" ] || die "нет node_modules — сначала шаг deps"
|
||||
if [ ! -x "$CHR/bin/chromium" ]; then
|
||||
mkdir -p "$CHR/bin"
|
||||
local tgz
|
||||
tgz="$(ls "$CHR"/*.tgz 2>/dev/null | head -1 || true)"
|
||||
if [ -z "$tgz" ]; then
|
||||
(cd "$CHR" && npm pack --silent "$CHR_PKG" >/dev/null) || die "npm pack $CHR_PKG не удался"
|
||||
tgz="$(ls "$CHR"/*.tgz | head -1)"
|
||||
fi
|
||||
tar xzf "$tgz" -C "$CHR"
|
||||
brotli_out "$CHR/package/bin/chromium.br" "$CHR/bin/chromium.tmp"
|
||||
for archive in "$CHR"/package/bin/*.tar.br; do
|
||||
[ -f "$archive" ] || continue
|
||||
brotli_out "$archive" "$CHR/x.tar"
|
||||
tar xf "$CHR/x.tar" -C "$CHR/bin" 2>/dev/null || true
|
||||
rm -f "$CHR/x.tar"
|
||||
done
|
||||
chmod +x "$CHR/bin/chromium.tmp"
|
||||
mv "$CHR/bin/chromium.tmp" "$CHR/bin/chromium" # последним: наличие = распаковка завершена
|
||||
say "chromium: распакован $CHR_PKG в $CHR/bin"
|
||||
else
|
||||
say "chromium: $CHR/bin/chromium уже есть"
|
||||
fi
|
||||
local targets target
|
||||
targets="$(playwright_targets)" || die "не удалось узнать пути браузеров Playwright"
|
||||
[ -n "$targets" ] || die "Playwright не назвал ни одного пути браузера"
|
||||
while IFS= read -r target; do
|
||||
[ -n "$target" ] || continue
|
||||
if [ -e "$target" ] && ! grep -q "$SHIM_MARK" "$target" 2>/dev/null; then
|
||||
say "chromium: $target — настоящий браузер, шим не ставлю"
|
||||
continue
|
||||
fi
|
||||
mkdir -p "$(dirname "$target")"
|
||||
printf '#!/bin/bash\n%s\nexport LD_LIBRARY_PATH="%s/bin/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"\nexec "%s/bin/chromium" --headless=new "$@"\n' \
|
||||
"$SHIM_MARK" "$CHR" "$CHR" >"$target"
|
||||
chmod +x "$target"
|
||||
done <<<"$targets"
|
||||
say "chromium: шимы Playwright на месте"
|
||||
}
|
||||
|
||||
step_bundle() {
|
||||
[ -x "$WT/node_modules/.bin/tsc" ] || die "нет node_modules — сначала шаг deps"
|
||||
(cd "$WT" && npm run --silent bundle:sync >/tmp/hp-bootstrap-bundle.log 2>&1) \
|
||||
|| { tail -30 /tmp/hp-bootstrap-bundle.log >&2; die "npm run bundle:sync не удался (лог: /tmp/hp-bootstrap-bundle.log)"; }
|
||||
say "bundle: npm run bundle:sync — ok (dist, custom_components/houseplan/frontend, demo/srv/assets)"
|
||||
}
|
||||
|
||||
step_check() {
|
||||
(cd "$WT" && node --input-type=module -e "
|
||||
import { createRequire } from 'node:module';
|
||||
import { join } from 'node:path';
|
||||
const require = createRequire(join(process.cwd(), 'package.json'));
|
||||
const { chromium } = require('playwright');
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.setContent('<p id=ok>houseplan</p>');
|
||||
const text = await page.textContent('#ok');
|
||||
const version = browser.version();
|
||||
await browser.close();
|
||||
if (text !== 'houseplan') { console.error('playwright: страница не отрисовалась'); process.exit(1); }
|
||||
console.log('check: playwright открыл страницу, Chromium ' + version);
|
||||
") || die "проверка Playwright не прошла — повторите шаг chromium"
|
||||
say "готово: cd $WT && node demo/smoke_edge_cases.mjs"
|
||||
}
|
||||
|
||||
case "$STEP" in
|
||||
worktree) step_worktree ;;
|
||||
deps) step_deps ;;
|
||||
chromium) step_chromium ;;
|
||||
bundle) step_bundle ;;
|
||||
check) step_check ;;
|
||||
all) step_worktree; step_deps; step_chromium; step_bundle; step_check ;;
|
||||
-h|--help|help) sed -n '2,33p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' ;;
|
||||
*) die "неизвестный шаг «$STEP»: worktree | deps | chromium | bundle | check | all" ;;
|
||||
esac
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/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));
|
||||
Reference in New Issue
Block a user