Files
portal/tools/night/workspace.test.mjs
T
Дмитрий e0bd43b4ef test(night): fix flaky+blind check in workspace.test.mjs — single call, both parts on same message
The check "prichina padeniya dohodit do vladeltsa KAK BYLA" called
createWorkspace() twice (once per regex) instead of once. This caused two
bugs at once:

1. Flaky under full-suite load: double work didn't fit the 5s timeout
   (Test timed out in 5000ms), while passing in isolation. Measured
   635ms for a single call vs 1170ms for the old double-call version —
   confirms the double invocation was the timing cause.

2. Blind spot: the two regexes ("podstavnoe padenie" / "za soboy ubrano")
   were asserted on TWO DIFFERENT runs, so code that emits the reason on
   run 1 but only "za soboy ubrano" (no reason) on run 2 would still pass
   green — the check wasn't actually guarding what it claimed to guard.

Fixed by catching the thrown error once and asserting both regexes
against the same captured message, matching how workspace.mjs actually
throws (reason + cleanup note concatenated into one Error, see
workspace.mjs:725).

tools/test:tools full run before: 1577 passed | 1 failed (1578 total).
After: 1578 passed | 0 failed (50 files), stable across two full runs.
Mutation check: temporarily stripped "za soboy ubrano" wording from
workspace.mjs, confirmed the test goes red, then reverted (git diff
clean afterward).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:26:15 +03:00

1152 lines
75 KiB
JavaScript
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.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
workerEnvFile, workerDbName, buildWorkerSettings, assertNoLiveSecrets, assertAppKey,
dbCredsFromAppEnv, worktreeArgs, psqlEnv, writeWorkerSettings,
isWorkspaceStale, sweepStaleWorkspaces, DAY_MS, isProtectedFromSweep, installSecretsGuard,
sozdayRabochieKatalogi, RABOCHIE_KATALOGI, sPovtorom, POPYTOK_SETEVOGO_SHAGA,
} from './workspace.mjs';
import { createWorkspace } from './workspace.mjs';
import { writeJsonAtomic, readJson } from './state.mjs';
let dir;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'night-ws-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
const CREDS = { host: '127.0.0.1', port: '5432', username: 'liderra_dev', password: 'местный-пароль' };
describe('workerDbName — своя база каждому', () => {
it('у каждого работника имя базы своё', () => {
expect(workerDbName('r-1', 'w-1')).not.toBe(workerDbName('r-1', 'w-2'));
});
it('имя базы содержит и прогон, и работника — по нему видно, чьё оно', () => {
const n = workerDbName('r-1', 'w-3');
expect(n).toContain('r_1');
expect(n).toContain('w_3');
});
it('в имени только буквы, цифры и подчёркивание — иначе база не заведётся', () => {
expect(workerDbName('r-2026-07-29-01', 'w-7')).toMatch(/^[a-z0-9_]+$/);
});
});
describe('workerEnvFile — проверка 48', () => {
it('своё имя базы подставлено сразу', () => {
const env = workerEnvFile({ dbName: 'night_r_1_w_1', creds: CREDS });
expect(env).toContain('DB_DATABASE=night_r_1_w_1');
});
it('боевых ключей в окружении работника НЕТ — их не «нельзя», их не существует', () => {
const env = workerEnvFile({ dbName: 'night_r_1_w_1', creds: CREDS });
for (const key of ['ANTHROPIC_API_KEY', 'YOOKASSA_SECRET_KEY', 'DADATA_TOKEN', 'MAIL_PASSWORD', 'SMSC_PASSWORD']) {
expect(env).not.toContain(key);
}
});
it('живая база недостижима: хост боевого кластера в окружении не упомянут', () => {
const env = workerEnvFile({ dbName: 'night_r_1_w_1', creds: CREDS });
expect(env).not.toMatch(/mdb\.yandexcloud\.net/);
expect(env).toContain('DB_HOST=127.0.0.1');
});
it('доступы берутся из рабочих настроек, а не выдумываются: postgres/postgres местная база ОТВЕРГАЕТ', () => {
const env = workerEnvFile({ dbName: 'night_r_1_w_1', creds: CREDS });
expect(env).toContain('DB_USERNAME=liderra_dev');
expect(env).toContain('DB_PASSWORD=местный-пароль');
expect(env).not.toContain('DB_USERNAME=postgres');
});
it('доступов не дали — угол не заводится: выдуманные доступы дадут отказ базы посреди ночи', () => {
expect(() => workerEnvFile({ dbName: 'night_r_1_w_1', creds: null })).toThrow(/доступ/i);
});
it('пустая строка APP_KEY= есть — иначе key:generate тихо НЕ запишет ключ', () => {
// 🪤 `php artisan key:generate` ЗАМЕНЯЕТ строку `APP_KEY=`, а не дописывает её.
// Нет строки — заменять нечего, ключ не записан, а код возврата всё равно нулевой.
// Убери эту строку из текста — покраснеет здесь, а не ночью на первом же угле.
expect(workerEnvFile({ dbName: 'night_r_1_w_1', creds: CREDS }).split('\n')).toContain('APP_KEY=');
});
});
describe('assertAppKey — ключ приложения в углу заведён на самом деле', () => {
it('ключ на месте — отдаёт его', () => {
mkdirSync(join(dir, 'app'), { recursive: true });
writeFileSync(join(dir, 'app', '.env'), 'APP_ENV=testing\nAPP_KEY=base64:0123456789abcdef\n');
expect(assertAppKey(dir)).toBe('base64:0123456789abcdef');
});
it('строка есть, но ПУСТАЯ — падаем громко: ровно так и выглядит тихий провал key:generate', () => {
mkdirSync(join(dir, 'app'), { recursive: true });
writeFileSync(join(dir, 'app', '.env'), 'APP_ENV=testing\nAPP_KEY=\n');
expect(() => assertAppKey(dir)).toThrow(/APP_KEY/);
});
it('файла нет вовсе — тоже падаем, а не считаем это порядком', () => {
expect(() => assertAppKey(dir)).toThrow(/APP_KEY/);
});
});
describe('dbCredsFromAppEnv — доступы из рабочих настроек', () => {
it('читает имя, пароль, хост и порт из app/.env', () => {
mkdirSync(join(dir, 'app'), { recursive: true });
writeFileSync(join(dir, 'app', '.env'), [
'APP_ENV=local',
'DB_HOST=127.0.0.1',
'DB_PORT=5432',
'DB_DATABASE=liderra',
'DB_USERNAME=liderra_dev',
'DB_PASSWORD=местный-пароль',
].join('\n'));
expect(dbCredsFromAppEnv(dir)).toEqual(CREDS);
});
it('имя базы из рабочих настроек НЕ берётся — у работника своя', () => {
mkdirSync(join(dir, 'app'), { recursive: true });
writeFileSync(join(dir, 'app', '.env'), 'DB_USERNAME=liderra_dev\nDB_PASSWORD=п\nDB_DATABASE=liderra\n');
expect(dbCredsFromAppEnv(dir).database).toBeUndefined();
});
it('настроек нет или имени в них нет — падает громко, а не подставляет postgres', () => {
// 🪤 Косая черта в образце обязана быть ОБЕИХ видов: путь собирает `join`, а он
// на Windows ставит обратную (`...\app\.env`). Образец только с прямой не совпал бы
// никогда, и проверка была бы красной не по вине кода.
expect(() => dbCredsFromAppEnv(dir)).toThrow(/app[\\/]\.env/i);
mkdirSync(join(dir, 'app'), { recursive: true });
writeFileSync(join(dir, 'app', '.env'), 'APP_ENV=local\n');
expect(() => dbCredsFromAppEnv(dir)).toThrow(/DB_USERNAME/);
});
});
describe('worktreeArgs — проверка 47: угол ветвится от ветки затеи, а не от главной', () => {
it('точка ветвления передана ЯВНО, и это ветка затеи, а не главная', () => {
const a = worktreeArgs({ branch: 'night/r-1/w-1', workspaceRoot: 'C:/ws/w-1', baseBranch: 'feat/night-dispatcher-part1' });
expect(a).toEqual(['worktree', 'add', '-b', 'night/r-1/w-1', 'C:/ws/w-1', 'feat/night-dispatcher-part1']);
expect(a).not.toContain('main');
});
it('точку ветвления не назвали — угол не заводится: от главной ветки в углу нет ничего из затеи', () => {
expect(() => worktreeArgs({ branch: 'night/r-1/w-1', workspaceRoot: 'C:/ws/w-1', baseBranch: null })).toThrow(/точк[аи] ветвления/i);
});
});
describe('psqlEnv — пароль базы уходит окружением', () => {
it('пароль передан через PGPASSWORD — иначе psql повиснет, спрашивая его у процесса без клавиатуры', () => {
const env = psqlEnv(CREDS, { PATH: 'C:/bin' });
expect(env.PGPASSWORD).toBe('местный-пароль');
expect(env.PATH).toBe('C:/bin');
});
});
describe('assertNoLiveSecrets — проверка 48: сторож самого замка', () => {
it('в углу нашёлся боевой .env — угол признан негодным, работник не стартует', () => {
mkdirSync(join(dir, 'app'), { recursive: true });
writeFileSync(join(dir, 'app', '.env'), 'DB_HOST=rc1a-xxx.rw.mdb.yandexcloud.net\n');
const r = assertNoLiveSecrets(dir);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/боев/i);
});
it('чистый угол — годен', () => {
mkdirSync(join(dir, 'app'), { recursive: true });
writeFileSync(join(dir, 'app', '.env'), 'DB_HOST=127.0.0.1\nDB_DATABASE=night_r_1_w_1\n');
expect(assertNoLiveSecrets(dir).ok).toBe(true);
});
it('боевое лежит НЕ только в app/.env — сторож смотрит и в соседние файлы', () => {
// 🪤 Сторож, который читает ровно тот файл, что мы сами тремя строками выше написали,
// в живой ночи отвечает «годен» ВСЕГДА, что бы ни случилось. Поэтому список мест,
// куда он смотрит, шире одного: боевые настройки продукта и настройки самой затеи
// с паролем почты тоже могут оказаться в углу — и тогда угол негоден.
mkdirSync(join(dir, 'app'), { recursive: true });
writeFileSync(join(dir, 'app', '.env.production'), 'DB_HOST=rc1a-xxx.rw.mdb.yandexcloud.net\n');
const r = assertNoLiveSecrets(dir);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/env\.production/);
});
});
describe('writeWorkerSettings — замки кладутся в угол, а не остаются обещанием', () => {
it('файл замков появляется в углу — без него работника нечем запускать с запретами', () => {
// 🪤 Правило, объявленное в одном месте и не положенное в другое, зеленеет на проверках
// и не срабатывает ни разу. Вырежи запись — покраснеет здесь.
const { settingsPath } = writeWorkerSettings(dir);
expect(settingsPath).toBe(join(dir, 'night-settings.json'));
expect(JSON.parse(readFileSync(settingsPath, 'utf8')).permissions.deny.join(' ')).toContain('git push');
});
it('проектный список угла перебит теми же замками, а личный убран', () => {
// 🪤 Угол — worktree того же хранилища, и в нём лежит отслеживаемый `.claude/settings.json`
// с чужими разрешениями и хуками. Чей список победит — вопрос, на который отвечать
// не надо: оба говорят одно и то же.
mkdirSync(join(dir, '.claude'), { recursive: true });
writeFileSync(join(dir, '.claude', 'settings.json'), '{"permissions":{"allow":["Bash(git push:*)"]}}');
writeFileSync(join(dir, '.claude', 'settings.local.json'), '{"permissions":{"allow":["Bash(curl:*)"]}}');
writeWorkerSettings(dir);
const project = JSON.parse(readFileSync(join(dir, '.claude', 'settings.json'), 'utf8'));
expect(project.permissions.allow.join(' ')).not.toContain('git push');
expect(project.permissions.deny.join(' ')).toContain('git push');
expect(existsSync(join(dir, '.claude', 'settings.local.json'))).toBe(false);
});
});
describe('buildWorkerSettings — проверки 47, 49', () => {
it('запрещённые инструменты названы поимённо, а не «по умолчанию»', () => {
const s = buildWorkerSettings();
const denied = s.permissions.deny.join(' ');
expect(denied).toContain('git push');
expect(denied).toContain('git merge');
expect(denied).toContain('git checkout main');
});
it('запрет действует, даже если хуки в углу не запустились: он в настройках запуска', () => {
const s = buildWorkerSettings();
expect(s.permissions.defaultMode).toBe('acceptEdits');
expect(Array.isArray(s.permissions.deny)).toBe(true);
expect(s.hooks).toBeUndefined(); // на хуки НЕ опираемся — они второй рубеж, а не этот файл
});
it('работнику РАЗРЕШЕНО то, без чего он не закроет ни одного пункта', () => {
// 🪤 Мина: в безголовом запуске согласия человека спросить не у кого.
// Одних запретов мало — коммит и прогон проверок обязаны быть разрешены явно,
// иначе работник восемь часов бьётся в отказы и не закрывает ни одного пункта.
const allowed = buildWorkerSettings().permissions.allow.join(' ');
expect(allowed).toContain('git commit');
expect(allowed).toContain('git add');
expect(allowed).toContain('npm run test:tools');
expect(allowed).toContain('composer test');
expect(allowed).toContain('Edit');
expect(allowed).toContain('Write');
expect(allowed).toContain('Read');
});
it('браузерные проверки и сборка фронта тоже разрешены — иначе пункт про экраны не закрыть', () => {
// Пункт, который трогает то, что видит клиент, закрывается браузерной проверкой
// и собранным фронтом. Без разрешения работник бился бы в отказ и не закрыл пункт.
const allowed = buildWorkerSettings().permissions.allow.join(' ');
expect(allowed).toContain('npx playwright test');
expect(allowed).toContain('php artisan test --testsuite=Browser');
expect(allowed).toContain('npm run a11y');
expect(allowed).toContain('npm run build');
});
it('🔴 ВЫДЕРЖАТЬ ПАУЗУ разрешено — иначе работнику нечем ждать между отметками', () => {
// 🔴 На отметках раз в десять минут держится ВСЁ доказательство проверки 1
// («жив через три часа»). Без этого права работник упрётся в отказ на первой же паузе,
// замолчит — и надзиратель назовёт его вставшим. Проверка 1 провалилась бы не потому,
// что затея не работает, а потому, что работнику нечем было переждать десять минут.
// Вырежи строку из разрешений — покраснеет здесь.
expect(buildWorkerSettings().permissions.allow).toContain('Bash(sleep:*)');
});
it('разрешение НЕ отменяет запрета: push и наружу остаются закрытыми', () => {
const s = buildWorkerSettings();
const allowed = s.permissions.allow.join(' ');
expect(allowed).not.toContain('git push');
expect(allowed).not.toContain('git merge');
expect(allowed).not.toContain('curl');
expect(s.permissions.deny.some(d => d.includes('git push'))).toBe(true);
});
it('правка, запись и чтение ограничены УГЛОМ — голых Write/Edit/Read в разрешениях нет', () => {
// 🪤 Голое `Write` без границы по пути — право записать любой файл на машине:
// главный рабочий каталог, `.git` главной ветки, ключи в домашнем каталоге.
// Вырежи границы — эта проверка покраснеет.
const allow = buildWorkerSettings().permissions.allow;
for (const bare of ['Read', 'Write', 'Edit']) {
expect(allow).not.toContain(bare);
expect(allow).toContain(`${bare}(./**)`);
}
});
it('наружу из угла ни писать, ни читать: `..` и домашний каталог закрыты', () => {
const deny = buildWorkerSettings().permissions.deny;
for (const rule of ['Read(../**)', 'Write(../**)', 'Edit(../**)', 'Read(~/**)', 'Write(~/**)', 'Edit(~/**)']) {
expect(deny).toContain(rule);
}
});
it('запрет не обходится другой формой той же команды', () => {
// 🪤 Запрет ловит НАЧАЛО строки. `git -C <чужой каталог> push`, `git switch main`,
// `git worktree add ... main`, `git update-ref` делают ровно запрещённое, не таясь.
const deny = buildWorkerSettings().permissions.deny;
for (const rule of [
'Bash(git -C:*)', 'Bash(git -c:*)', 'Bash(git --git-dir:*)', 'Bash(git --work-tree:*)',
'Bash(git switch:*)', 'Bash(git worktree:*)', 'Bash(git update-ref:*)', 'Bash(git config:*)',
]) {
expect(deny).toContain(rule);
}
});
});
describe('уборка старых углов — решение владельца: семь дней и вон', () => {
const NOW = Date.parse('2026-08-10T09:00:00+03:00');
function runWithWorker({
runId = 'r-1', status = 'running', finishedAt = NOW - 8 * DAY_MS, removedAt = null,
// 🆕 Долг перед куском 6 (решение владельца 126 от 01.08): статус работника и отметка
// о подъёме — независимо от статуса прогона, ими проверяется отдельное правило ниже.
// 🔴 Найдено живьём при выполнении задачи: план держал тут по умолчанию 'extinguished' —
// это ЗАЩИЩЁННЫЙ статус, и с ним обычная уборка по сроку (тесты без явного workerStatus)
// переставала бы срабатывать вовсе. Нейтральный 'done' не входит в AWAITING_OWNER_STATUSES,
// долговые тесты по-прежнему задают нужный статус явно.
workerStatus = 'done', resumedAt = null,
} = {}) {
const runsRoot = join(dir, 'runs');
const wdir = join(runsRoot, runId, 'workers', 'w-1');
mkdirSync(wdir, { recursive: true });
writeJsonAtomic(join(runsRoot, runId, 'run.json'), { run_id: runId, status, workers: ['w-1'] });
writeJsonAtomic(join(wdir, 'worker.json'), {
worker_id: 'w-1',
status: workerStatus,
workspace: join(dir, 'ws', runId, 'w-1'),
db_name: `night_${runId.replace(/-/g, '_')}_w_1`,
started_at: NOW - 9 * DAY_MS,
finished_at: finishedAt,
workspace_removed_at: removedAt,
resumed_at: resumedAt,
});
return { runsRoot, cardPath: join(wdir, 'worker.json') };
}
function fakeDestroy(calls) {
// Живьём углы и базы в проверках не сносим: тут проверяется правило, а не рука.
return args => { calls.push(args); return { worktree: true, database: true, errors: [] }; };
}
it('угол моложе срока не трогаем — наработки могут ещё понадобиться владельцу', () => {
const { runsRoot } = runWithWorker({ finishedAt: NOW - 2 * DAY_MS });
const calls = [];
expect(sweepStaleWorkspaces({ repoRoot: dir, runsRoot, maxAgeDays: 7, now: NOW, destroy: fakeDestroy(calls) })).toEqual([]);
expect(calls).toEqual([]);
});
it('угол старше срока убирается, ХОТЯ задача не принята, и об этом отдельная строка сводки', () => {
const { runsRoot, cardPath } = runWithWorker({ finishedAt: NOW - 8 * DAY_MS });
const calls = [];
const swept = sweepStaleWorkspaces({ repoRoot: dir, runsRoot, maxAgeDays: 7, now: NOW, destroy: fakeDestroy(calls) });
expect(calls.length).toBe(1);
expect(calls[0].dbName).toBe('night_r_1_w_1');
expect(swept[0].line).toContain('w-1');
expect(swept[0].line).toMatch(/не принята/i);
expect(swept[0].line).toContain('7');
// В карточке остаётся отметка — иначе на следующее утро угол убирали бы снова и снова,
// и в сводке каждый день стояла бы одна и та же строка.
expect(readJson(cardPath).workspace_removed_at).toBe(NOW);
});
it('убрать не вышло — сводка всё равно уходит, и владелец узнаёт об этом строкой', () => {
// 🔴 Уборка не имеет права уронить утреннюю сводку: письмо обязано уйти ВСЕГДА.
const { runsRoot, cardPath } = runWithWorker({ finishedAt: NOW - 8 * DAY_MS });
const swept = sweepStaleWorkspaces({
repoRoot: dir, runsRoot, maxAgeDays: 7, now: NOW,
destroy: () => { throw new Error('база не отвечает'); },
});
expect(swept[0].line).toContain('база не отвечает');
// Отметки нет — значит завтра попробуем снова, а не забудем про этот угол навсегда.
expect(readJson(cardPath).workspace_removed_at).toBeNull();
});
it('уже убранный угол второй раз не убирается', () => {
const { runsRoot } = runWithWorker({ finishedAt: NOW - 8 * DAY_MS, removedAt: NOW - DAY_MS });
const calls = [];
expect(sweepStaleWorkspaces({ repoRoot: dir, runsRoot, maxAgeDays: 7, now: NOW, destroy: fakeDestroy(calls) })).toEqual([]);
});
it('принятый прогон не трогаем — его углы убрал `cli.mjs accept`', () => {
const { runsRoot } = runWithWorker({ status: 'accepted', finishedAt: NOW - 30 * DAY_MS });
const calls = [];
expect(sweepStaleWorkspaces({ repoRoot: dir, runsRoot, maxAgeDays: 7, now: NOW, destroy: fakeDestroy(calls) })).toEqual([]);
});
it('срок считается от конца работника, а нет его — от начала: карточка без времён не убирается', () => {
expect(isWorkspaceStale({ workspace: 'C:/ws', started_at: NOW - 9 * DAY_MS }, { now: NOW, maxAgeDays: 7 })).toBe(true);
expect(isWorkspaceStale({ workspace: 'C:/ws' }, { now: NOW, maxAgeDays: 7 })).toBe(false);
expect(isWorkspaceStale({ started_at: NOW - 99 * DAY_MS }, { now: NOW, maxAgeDays: 7 })).toBe(false);
});
// 🆕 Долг перед куском 6 (решение владельца 126 от 01.08.2026). Кнопка «продолжить»
// поднимает работника из его СТАРОГО угла — снеси уборка этот угол по одному лишь сроку,
// продолжать станет нечего, и выглядеть это будет как обычная уборка, а не как беда.
// Признак защиты — итоговый статус работника (stalled/extinguished) БЕЗ отметки resumed_at.
it('угол работника со статусом stalled старше срока — НЕ убирается, пока владелец не решил его судьбу', () => {
const { runsRoot, cardPath } = runWithWorker({ workerStatus: 'stalled', finishedAt: NOW - 30 * DAY_MS });
const calls = [];
expect(sweepStaleWorkspaces({ repoRoot: dir, runsRoot, maxAgeDays: 7, now: NOW, destroy: fakeDestroy(calls) })).toEqual([]);
expect(calls).toEqual([]);
expect(readJson(cardPath).workspace_removed_at).toBeNull();
});
it('угол работника со статусом extinguished старше срока — тоже НЕ убирается без отметки resumed_at', () => {
const { runsRoot } = runWithWorker({ workerStatus: 'extinguished', finishedAt: NOW - 30 * DAY_MS, resumedAt: null });
const calls = [];
expect(sweepStaleWorkspaces({ repoRoot: dir, runsRoot, maxAgeDays: 7, now: NOW, destroy: fakeDestroy(calls) })).toEqual([]);
expect(calls).toEqual([]);
});
it('работника УЖЕ подняли (resumed_at стоит) — прежний угол уборка старше срока трогает как обычно', () => {
// Владелец уже решил судьбу этого угла нажатием «продолжить» — держать его вечно
// больше незачем, послабление снято отметкой, а не сроком.
const { runsRoot, cardPath } = runWithWorker({ workerStatus: 'extinguished', finishedAt: NOW - 30 * DAY_MS, resumedAt: NOW - 5 * DAY_MS });
const calls = [];
const swept = sweepStaleWorkspaces({ repoRoot: dir, runsRoot, maxAgeDays: 7, now: NOW, destroy: fakeDestroy(calls) });
expect(calls.length).toBe(1);
expect(swept[0].line).toContain('w-1');
expect(readJson(cardPath).workspace_removed_at).toBe(NOW);
});
it('работника поставили на паузу (paused) — старое правило про паузу не задето, срок его и раньше не трогал', () => {
// 🪤 Страховка от расширения списка защищённых статусов на всё подряд: paused никогда
// не убирался принудительно и без этого долга (у него нет finished_at, пока он живой);
// здесь явно проверяем, что новое правило его не выключило по ошибке в другую сторону.
const { runsRoot } = runWithWorker({ workerStatus: 'paused', finishedAt: NOW - 30 * DAY_MS, resumedAt: null });
const calls = [];
const swept = sweepStaleWorkspaces({ repoRoot: dir, runsRoot, maxAgeDays: 7, now: NOW, destroy: fakeDestroy(calls) });
expect(calls.length).toBe(1); // paused в защищённый список НЕ входит — это работа задачи 8/12, а не уборки
expect(swept.length).toBe(1);
});
it('isProtectedFromSweep — голое правило поимённо: два статуса, и снимается отметкой resumed_at', () => {
expect(isProtectedFromSweep({ status: 'stalled' })).toBe(true);
expect(isProtectedFromSweep({ status: 'extinguished' })).toBe(true);
expect(isProtectedFromSweep({ status: 'extinguished', resumed_at: NOW })).toBe(false);
expect(isProtectedFromSweep({ status: 'paused' })).toBe(false);
expect(isProtectedFromSweep({ status: 'working' })).toBe(false);
});
});
// 🆕 ДОЛГ 13 (найдено живьём 01.08.2026, см. врезку выше). `createWorkspace` целиком сюда
// не позвать — он тянет настоящие `git`/`composer`/`npm`/`psql`; свою руку доказывает только
// живая ночь (задача 16). А вот саму установку сторожа проверить можно и нужно НАСТОЯЩИМ
// кодом, без единой подделки внутри неё самой — стрелка и файловая система здесь реальные,
// подставной только `run` (то же самое разделение, что и `destroy` у уборки выше).
describe('installSecretsGuard — ДОЛГ 13: сторож обязан не только лежать в углу, но и работать', () => {
function putRealGuardInto(repoRoot) {
mkdirSync(join(repoRoot, 'bin'), { recursive: true });
writeFileSync(join(repoRoot, 'bin', 'gitleaks.exe'), 'заглушка вместо 22 МБ, но файл настоящий', 'utf8');
}
it('угол завёлся — bin/gitleaks.exe в нём ВИДЕН стрелкой на главное хозяйство, а сторожа окликнули', () => {
putRealGuardInto(dir); // dir тут — за repoRoot
const ws = join(dir, 'угол-1');
mkdirSync(ws, { recursive: true });
const calls = [];
installSecretsGuard({ repoRoot: dir, workspaceRoot: ws, run: (...args) => calls.push(args) });
expect(existsSync(join(ws, 'bin', 'gitleaks.exe'))).toBe(true);
expect(calls.length).toBe(1); // не только положили — ещё и спросили «жив?»
});
it('в главном хозяйстве сторожа нет вовсе — заведение угла падает понятной фразой, а не тихо', () => {
const ws = join(dir, 'угол-2');
mkdirSync(ws, { recursive: true });
expect(() => installSecretsGuard({ repoRoot: dir, workspaceRoot: ws, run: () => {} }))
.toThrow(/сторож секретов/i);
});
// 🔴🔴 НАЙДЕНО ЖИВЫМ ОБРЫВОМ 02.08.2026, НЕ ЧТЕНИЕМ, и это НЕ из порученных бед.
// Настоящий пробный угол упал ровно здесь, и упал бы КАЖДЫЙ раз: стрелка на `bin`
// ставится только если каталога `bin` в углу НЕТ вовсе. А он там есть ВСЕГДА —
// хранилище само хранит `bin/.gitkeep` и `bin/zap-active-scan.ps1`, значит в любой
// свежий угол каталог `bin` приезжает вместе с веткой. Стрелка не ставится, сторожа
// в углу нет, заведение угла падает. Ни один работник не завёлся бы ни одной ночью.
it('🔴 в углу УЖЕ есть свой каталог bin (он в хранилище) — сторож всё равно обязан там оказаться', () => {
putRealGuardInto(dir);
const ws = join(dir, 'угол-с-готовым-bin');
mkdirSync(ws, { recursive: true });
// Ровно то, что приезжает в угол вместе с веткой из настоящего хранилища.
mkdirSync(join(ws, 'bin'), { recursive: true });
writeFileSync(join(ws, 'bin', '.gitkeep'), '', 'utf8');
writeFileSync(join(ws, 'bin', 'zap-active-scan.ps1'), '# отслеживаемый хранилищем файл', 'utf8');
installSecretsGuard({ repoRoot: dir, workspaceRoot: ws, run: () => {} });
expect(existsSync(join(ws, 'bin', 'gitleaks.exe'))).toBe(true);
// 🪤 И своё содержимое каталога при этом на месте: подменять чужие файлы мы не вправе.
expect(existsSync(join(ws, 'bin', 'zap-active-scan.ps1'))).toBe(true);
});
it('сторож в угол попал, но не запускается — угол не заводим, доверять коммитам в нём нельзя', () => {
putRealGuardInto(dir);
const ws = join(dir, 'угол-3');
mkdirSync(ws, { recursive: true });
expect(() => installSecretsGuard({
repoRoot: dir,
workspaceRoot: ws,
run: () => { throw new Error('не программа Windows, а мусор вместо неё'); },
})).toThrow(/не запускается/i);
});
});
describe('createWorkspace — своей базы не досталось, значит угла нет вовсе (проверка 46)', () => {
// 🔴 Проверка 46 говорит дословно: «работник, которому своей базы не досталось, НЕ СТАРТОВАЛ
// ВОВСЕ, и ни одной проверки по его пункту не пошло». Значит отказ обязан случиться ДО того,
// как заведён угол и поставлены зависимости: заведённый угол — это уже полшага к запуску.
//
// 🪤 Ни одна проверка ниже не доходит до `git worktree add`: все пять падают на первой
// строке `createWorkspace`. Убери правило — и они полезут в настоящий `git` во временном
// каталоге, который хранилищем не является; там они тоже упадут, но уже по другой причине
// и с другими словами. Это и есть красный, которого мы ждём на шаге 5.
const ARGS = () => ({
repoRoot: dir,
branch: 'night/r-1/w-1',
workspaceRoot: join(dir, 'ws', 'w-1'),
baseBranch: 'feat/night-dispatcher-part1',
});
it('номеров нет и имя базы не дали — угол не заводится', () => {
expect(() => createWorkspace({ ...ARGS(), runId: null, workerId: null }))
.toThrow(/своей базы/i);
});
it('отказ случается ДО заведения угла: каталога угла не появилось', () => {
// 🔴 Это и есть «не стартовал вовсе». Появись каталог — значит правило стоит слишком
// поздно: зависимости в углу ставятся минутами, и мы бы их уже поставили впустую.
//
// 🔴🔴 Причину спрашиваем ОБЯЗАТЕЛЬНО, а не просто «хоть чем-нибудь упало». Голый
// `toThrow()` без слов зеленел бы и на сломанном коде: во временном каталоге, который
// хранилищем не является, `git worktree add` упадёт сам по себе, каталога угла тоже
// не появится — и проверка сказала бы «правило работает» там, где правила нет вовсе.
const a = ARGS();
expect(() => createWorkspace({ ...a, runId: null, workerId: null })).toThrow(/своей базы/i);
expect(existsSync(a.workspaceRoot)).toBe(false);
});
it('имя базы дали пустой строкой — отказ, а не «имя вроде бы есть»', () => {
// 🪤 Пустая строка не `null` и не `undefined`, поэтому `dbName ?? …` её НЕ заменит
// на счётное имя: она пройдёт дальше как есть. Без этой проверки ветка «имени нет
// вовсе» внутри правила остаётся мёртвой — написана и ни разу не спрошена.
expect(() => createWorkspace({ ...ARGS(), runId: 'r-1', workerId: 'w-1', dbName: ' ' }))
.toThrow(/своей базы/i);
});
it('имя базы вышло с «undefined» — отказ, а не тихая общая база night_undefined_undefined', () => {
// 🪤 Самый тихий путь к общей базе: номера не передали, имя всё равно построилось,
// и семеро таких работников оказались бы в ОДНОЙ базе с правдоподобным именем.
expect(() => createWorkspace({ ...ARGS(), runId: undefined, workerId: undefined }))
.toThrow(/своей базы/i);
});
it('подсунули общую базу проверок — отказ: в неё семеро пишут поверх друг друга', () => {
// 🪤 Образец писан ПОД САМ ОТКАЗ, знак в знак: отказ говорит «подсунули общую базу
// проверок», и падеж тут не мелочь — «общая база» не совпало бы ни одной буквой,
// проверка осталась бы красной и после правильного кода.
expect(() => createWorkspace({ ...ARGS(), runId: 'r-1', workerId: 'w-1', dbName: 'liderra_testing' }))
.toThrow(/общую базу проверок/i);
});
it('в имени базы посторонние знаки — отказ, а не отказ самой базы посреди ночи', () => {
expect(() => createWorkspace({ ...ARGS(), runId: 'r-1', workerId: 'w-1', dbName: 'night-r-1;drop' }))
.toThrow(/буквы, цифры и подчёркивание/i);
});
});
// ============ БЕДА 7: установка угла упала на полпути и оставила след ============
//
// 🔴 НАЙДЕНО ЖИВЫМ ОБРЫВОМ 02.08.2026, а не чтением. Заведён настоящий пробный угол
// настоящим `createWorkspace`; установка упала на втором шаге (сторож секретов). После
// падения в хранилище ОСТАЛИСЬ: ветка `probe/stroitel-obryv-8021`, запись угла в реестре
// `git worktree list` и полный каталог с выгруженным хранилищем. Убирать это было некому:
// `createWorkspace` при падении не убирал за собой ничего, а карточка провалившегося
// работника (`run.mjs`) не несёт ни `workspace`, ни `db_name` — значит утренняя уборка
// (`sweepStaleWorkspaces`) на такой угол не смотрит ВООБЩЕ: `isWorkspaceStale` отвечает
// «угла нет — убирать нечего». Каждая упавшая ночь копила бы ветку, каталог и, если
// упасть успели позже, ещё и базу — навсегда.
//
// 🔴 ЛЕЧЕНИЕ ВЫБРАНО «УБОРКА ЗА СОБОЙ», А НЕ «ЗАВОДИТЬ ВЕТКУ ПОСЛЕДНИМ ШАГОМ». Довод
// простой и проверяемый: ветку и каталог заводит ОДНА команда `git worktree add -b`,
// и всё, что идёт дальше (composer, npm, база, миграции), без этого каталога исполнять
// негде. Отложить заведение ветки на конец физически нельзя.
//
// 🪤 УБОРКА, КОТОРАЯ СНОСИТ ЛИШНЕЕ, ОПАСНЕЕ ТОЙ, ЧТО НЕ УБРАЛА. Поэтому убирается
// только то, что заведено СВОЕЙ РУКОЙ В ЭТОМ САМОМ ВЫЗОВЕ, и доказательство у каждого
// своё:
// · ветка и каталог — `git worktree add -b` на УЖЕ существующей ветке падает
// (замерено живьём: код возврата 255, «a branch named ... already exists»,
// каталога при этом не появляется). Значит удачный `add` = ветку завели мы;
// · база — метка ставится ПОСЛЕ удачного `CREATE DATABASE`, и имя её уже прошло
// `assertOwnDbName` (общая база проверок туда не пролезает).
describe('беда 7: установка угла упала на полпути — за собой убрано, и убрано только СВОЁ', () => {
// Настоящее хозяйство для подставного `run`: сторож секретов и доступы к базе обязаны
// быть на месте, иначе установка упадёт не там, где мы её роняем.
function hozyaystvo() {
mkdirSync(join(dir, 'bin'), { recursive: true });
writeFileSync(join(dir, 'bin', 'gitleaks.exe'), 'заглушка вместо 22 МБ, но файл настоящий', 'utf8');
mkdirSync(join(dir, 'app'), { recursive: true });
writeFileSync(join(dir, 'app', '.env'), 'DB_USERNAME=liderra_dev\nDB_PASSWORD=местный\n', 'utf8');
}
// Подставной запуск внешних программ. Считает всё, что позвали, и роняет тот шаг,
// который назвали словом. Ронять по НОМЕРУ вызова нельзя: добавь кто-нибудь шаг
// в середину — и проверка стала бы ронять другое место, оставаясь зелёной.
//
// 🔴 Подставной `git worktree add` ОБЯЗАН оставить после себя то же, что оставляет
// настоящий, — каталог угла с `app/` внутри. Не оставь он этого, установка падала бы
// на записи `app/.env`, то есть НЕ НА ТОМ шаге, который мы роняем, и проверка сторожила
// бы не то, что думает.
function podstavnojZapusk({ upadyot = null, ugol = null }) {
const zvali = [];
return {
zvali,
run: (bin, args) => {
const stroka = `${String(bin).toLowerCase()} ${(args ?? []).join(' ')}`;
zvali.push(stroka);
if (upadyot && stroka.includes(upadyot)) throw new Error(`подставное падение на «${upadyot}»`);
if (ugol && stroka.includes('worktree add')) mkdirSync(join(ugol, 'app'), { recursive: true });
// Ключ приложения пишет настоящий `key:generate`, а здесь он подставной: делаем
// за него ровно то, что сторож ключа проверяет, — иначе упадёт сторож, а не шаг.
if (ugol && stroka.includes('artisan key:generate')) {
const p = join(ugol, 'app', '.env');
writeFileSync(p, readFileSync(p, 'utf8').replace(/^APP_KEY=.*$/m, 'APP_KEY=base64:проба'), 'utf8');
}
return '';
},
};
}
// 🔴 Каталог угла считается ФУНКЦИЕЙ, а не разом на весь файл: `dir` заводится заново
// перед каждой проверкой, и постоянная указывала бы на каталог прошлого захода.
const UGOL = () => join(dir, 'ugol', 'w-1');
const ARGS = () => ({
repoRoot: dir,
runId: 'r-proba',
workerId: 'w-1',
branch: 'night/r-proba/w-1',
workspaceRoot: UGOL(),
baseBranch: 'feat/night-dispatcher-part1',
dbName: 'night_r_proba_w_1',
});
it('упало ПОСЛЕ заведения ветки — убраны и каталог угла, и ветка, и убраны они в верном порядке', () => {
hozyaystvo();
// Роняем установку зависимостей — самый живой случай: обрыв сети посреди `npm ci`.
const p = podstavnojZapusk({ upadyot: 'ci --no-audit', ugol: UGOL() });
expect(() => createWorkspace({ ...ARGS(), run: p.run })).toThrow();
const ubralUgol = p.zvali.findIndex(s => s.includes('worktree remove --force'));
const ubralVetku = p.zvali.findIndex(s => s.includes('branch -D night/r-proba/w-1'));
expect(ubralUgol).toBeGreaterThan(-1);
expect(ubralVetku).toBeGreaterThan(-1);
// 🔴 Порядок не украшение: ветку, на которую выгружен угол, git удалить не даст.
expect(ubralUgol).toBeLessThan(ubralVetku);
});
it('причина падения доходит до владельца КАК БЫЛА — уборка её не заслоняет', () => {
hozyaystvo();
const p = podstavnojZapusk({ upadyot: 'ci --no-audit', ugol: UGOL() });
// 🔴 ОДИН заход, ОДНО пойманное сообщение — обе половины проверяются на НЁМ ЖЕ.
// Раньше здесь звали createWorkspace ДВАЖДЫ (второй раз — по второму же следу
// первого), и это пряталось за формой: два throw() выглядят как одна проверка,
// а на деле проверяют два РАЗНЫХ сообщения. Код и вправду шлёт причину и «убрано»
// одним словом (workspace.mjs:725), но проверка этого не видела бы: код, который
// на втором заходе отдал бы только «за собой убрано» без причины (например, увидев
// уже прибранный от первого раза угол), остался бы зелёным.
let oshibka;
expect(() => {
try {
createWorkspace({ ...ARGS(), run: p.run });
} catch (err) {
oshibka = err;
throw err;
}
}).toThrow();
// Слова самого падения обязаны остаться: по ним владелец и понимает, что чинить.
expect(oshibka.message).toMatch(/подставное падение/);
// И рядом, в ТОМ ЖЕ сообщении, — прямое слово о том, что следов не осталось.
expect(oshibka.message).toMatch(/за собой убрано/i);
});
it('сама ветка не завелась — не убираем НИ ОДНОЙ командой: чужого мы не заводили', () => {
hozyaystvo();
const p = podstavnojZapusk({ upadyot: 'worktree add', ugol: UGOL() });
expect(() => createWorkspace({ ...ARGS(), run: p.run })).toThrow();
// 🪤 Ровно то место, где уборка опаснее беды: ветка с таким именем может существовать
// не наша. Не завели — не трогаем.
expect(p.zvali.some(s => s.includes('branch -D'))).toBe(false);
expect(p.zvali.some(s => s.includes('worktree remove'))).toBe(false);
expect(p.zvali.some(s => s.includes('DROP DATABASE'))).toBe(false);
});
it('база уже заведена, а миграции упали — убрана и база тоже, и ровно СВОЯ по имени', () => {
hozyaystvo();
const p = podstavnojZapusk({ upadyot: 'artisan migrate', ugol: UGOL() });
expect(() => createWorkspace({ ...ARGS(), run: p.run })).toThrow();
const drop = p.zvali.find(s => s.includes('DROP DATABASE'));
expect(drop).toBeTruthy();
expect(drop).toContain('night_r_proba_w_1');
// Имя в сносе — то же самое, что в заведении. Второго имени тут быть не может.
expect(p.zvali.some(s => s.includes('CREATE DATABASE night_r_proba_w_1'))).toBe(true);
});
it('до заведения базы не дошли — снос базы не зовётся ВОВСЕ', () => {
hozyaystvo();
// Падаем раньше базы: на установке зависимостей.
const p = podstavnojZapusk({ upadyot: 'install --no-interaction', ugol: UGOL() });
expect(() => createWorkspace({ ...ARGS(), run: p.run })).toThrow();
expect(p.zvali.some(s => s.includes('CREATE DATABASE'))).toBe(false);
// 🪤 Снос базы, которую не заводили, — это снос ЧУЖОЙ базы с таким же именем.
expect(p.zvali.some(s => s.includes('DROP DATABASE'))).toBe(false);
// А угол при этом убран: его-то мы завели.
expect(p.zvali.some(s => s.includes('worktree remove --force'))).toBe(true);
});
// 🔴 НАЙДЕНО ВЫРЕЗАНИЕМ ВТОРОГО РОДА, а не планом. Перенеси кто-нибудь метку «база наша»
// на строку ПЕРЕД самой командой — и все прочие проверки этого раздела остались бы
// зелёными: они роняют шаги, до базы не доходящие. А беда была бы худшая из возможных:
// упало ЗАВЕДЕНИЕ базы (например, потому что база с таким именем уже есть и она ЧУЖАЯ) —
// и уборка тут же сносит её.
it('заведение базы упало САМО — снос не зовётся: этой базы мы не заводили, она может быть чужой', () => {
hozyaystvo();
const p = podstavnojZapusk({ upadyot: 'CREATE DATABASE', ugol: UGOL() });
expect(() => createWorkspace({ ...ARGS(), run: p.run })).toThrow();
expect(p.zvali.some(s => s.includes('DROP DATABASE'))).toBe(false);
// Угол при этом всё равно убран: его завели мы.
expect(p.zvali.some(s => s.includes('worktree remove --force'))).toBe(true);
});
it('уборка сама не удалась — владелец узнаёт И причину падения, И что именно осталось лежать', () => {
hozyaystvo();
// Установка падает на `npm ci`, а САМА УБОРКА падает на сносе каталога. Второй
// подставной пишем руками: у общего роняется ровно один шаг, а тут их два.
const osnova = podstavnojZapusk({ upadyot: 'ci --no-audit', ugol: UGOL() });
const run = (bin, args) => {
const stroka = `${String(bin).toLowerCase()} ${(args ?? []).join(' ')}`;
if (stroka.includes('worktree remove')) throw new Error('каталог держит чужая программа');
return osnova.run(bin, args);
};
// 🔴 МИР ПРОВЕРКИ ОБЯЗАН БЫТЬ СВЯЗНЫМ (поправлено 03.08.2026). Раньше здесь падал
// только `git worktree remove`, а снос руками молча удавался — такого мира не бывает:
// если каталог держит чужая программа, он НЕ СНОСИТСЯ и руками тоже. С появлением
// запасной дороги уборки прежняя проверка стала охранять несуществующий случай и
// покраснела — правильно покраснела. Теперь роняем ОБЕ дороги, и проверка сторожит
// то, что и должна: что владельцу честно скажут об оставшемся.
const snesi = () => { throw new Error('каталог держит чужая программа'); };
let slova = '';
try { createWorkspace({ ...ARGS(), run, snesi }); } catch (err) { slova = err?.message ?? String(err); }
expect(slova).toMatch(/подставное падение/);
expect(slova).toMatch(/осталось/i);
// 🔴 Что именно осталось — названо. Молчаливое «убрать не вышло» владельцу бесполезно:
// руками чинить нечего, если не сказано что.
expect(slova).toContain(UGOL());
// 🔴 И про ветку сказано отдельной строкой: снять её нельзя, пока лежит каталог, —
// владелец должен знать, что снимать придётся ОБА следа, а не один.
expect(slova).toContain('night/r-proba/w-1');
// И вранья «за собой убрано» при неубранном быть не должно.
expect(slova).not.toMatch(/за собой убрано/i);
});
it('всё прошло — уборка не зовётся ни разу: удачный угол сносить нечем и незачем', () => {
hozyaystvo();
const p = podstavnojZapusk({ upadyot: null, ugol: UGOL() });
const r = createWorkspace({ ...ARGS(), run: p.run });
expect(r.dbName).toBe('night_r_proba_w_1');
expect(p.zvali.some(s => s.includes('worktree remove'))).toBe(false);
expect(p.zvali.some(s => s.includes('branch -D'))).toBe(false);
expect(p.zvali.some(s => s.includes('DROP DATABASE'))).toBe(false);
});
});
// ============ СТЕНА 2: установка в `app/` падала у КАЖДОГО работника ============
//
// 🔴 ЗАМЕРЕНО ЖИВЬЁМ 03.08.2026, а не вычитано. Во временной папке вне хранилища положены
// только `app/package.json` и `app/package-lock.json`, и позвана ровно та команда, что
// зовёт затея, — `npm ci --no-audit --no-fund`. Код возврата 1:
//
// npm error code ERESOLVE / While resolving: histoire@1.0.0-beta.1
// npm error Found: vite@8.0.11 ... Could not resolve: peer vite@"^7.3.0" from histoire
//
// Значит живой работник не рождался НИ РАЗУ: угол не доживал до конца установки.
//
// 🔴 ПОЧЕМУ ЛЕЧИМ КЛЮЧОМ, А НЕ БУМАГАМИ. `app/package.json` — бумага основного портала,
// её правит владелец. Ключ `--legacy-peer-deps` живёт в НАШЕЙ строке запуска и глушит
// ровно спор о версиях; дерево ставится строго по замку. Замерено: состав, который он
// даёт, — 409 папок верхнего уровня, ровно как у `--force` и ровно как в главной рабочей
// копии портала.
//
// 🔴 ПОЧЕМУ ПРОВЕРКА СМОТРИТ И НА ПАПКУ. Обе установки — `npm ci --no-audit --no-fund`,
// и по одной строке команды они НЕРАЗЛИЧИМЫ. Проверка, не глядящая на папку, осталась бы
// зелёной, окажись ключ приклеен к корневой установке вместо `app/`, — то есть сторожила
// бы не то место. Поэтому свой соглядатай, а не общий: он пишет и папку тоже.
describe('стена 2: установка в app/ идёт с ключом, глушащим спор о версиях', () => {
function hozyaystvo() {
mkdirSync(join(dir, 'bin'), { recursive: true });
writeFileSync(join(dir, 'bin', 'gitleaks.exe'), 'заглушка вместо 22 МБ, но файл настоящий', 'utf8');
mkdirSync(join(dir, 'app'), { recursive: true });
writeFileSync(join(dir, 'app', '.env'), 'DB_USERNAME=liderra_dev\nDB_PASSWORD=местный\n', 'utf8');
}
const UGOL = () => join(dir, 'ugol', 'w-1');
// Соглядатай, помнящий ПАПКУ каждого вызова, — в этом и вся его надобность.
function soglyadatay(ugol) {
const zvali = [];
return {
zvali,
run: (bin, args, opts) => {
zvali.push({ stroka: `${String(bin).toLowerCase()} ${(args ?? []).join(' ')}`, cwd: String(opts?.cwd ?? '') });
if (String(bin).toLowerCase().includes('git') && (args ?? []).includes('worktree')) {
mkdirSync(join(ugol, 'app'), { recursive: true });
}
if ((args ?? []).join(' ').includes('artisan key:generate')) {
const p = join(ugol, 'app', '.env');
writeFileSync(p, readFileSync(p, 'utf8').replace(/^APP_KEY=.*$/m, 'APP_KEY=base64:проба'), 'utf8');
}
return '';
},
};
}
// Все установки `npm ci`, разложенные по папкам, — общий сбор для всех проверок ниже.
function ustanovki() {
hozyaystvo();
const ugol = UGOL();
const s = soglyadatay(ugol);
createWorkspace({
repoRoot: dir, runId: 'r-proba', workerId: 'w-1', branch: 'night/r-proba/w-1',
workspaceRoot: ugol, baseBranch: 'feat/night-dispatcher-part1',
dbName: 'night_r_proba_w_1', run: s.run,
});
const npmCi = s.zvali.filter(z => z.stroka.includes('ci --no-audit'));
return {
vseh: npmCi.length,
vApp: npmCi.filter(z => z.cwd === join(ugol, 'app')),
vKorne: npmCi.filter(z => z.cwd === ugol),
};
}
it('установок ровно две, и лежат они по своим папкам — в корне угла и в app/', () => {
const u = ustanovki();
// Если этих трёх чисел нет, все проверки ниже смотрят в пустоту и зелены даром.
expect(u.vseh).toBe(2);
expect(u.vKorne).toHaveLength(1);
expect(u.vApp).toHaveLength(1);
});
it('установка в app/ идёт с `--legacy-peer-deps` — иначе она падает у КАЖДОГО работника', () => {
const u = ustanovki();
expect(u.vApp[0].stroka).toContain('--legacy-peer-deps');
});
it('корневая установка ключа НЕ получает: она замерена отдельно и проходит с нулём', () => {
const u = ustanovki();
// 🪤 Ключ, приклеенный «на всякий случай» ко всему подряд, глушил бы споры и там,
// где их никто не мерил, — то есть прятал бы будущие стены вместо того, чтобы их
// показывать. Ставим его ровно там, где стена замерена.
expect(u.vKorne[0].stroka).not.toContain('--legacy-peer-deps');
});
it('`--force` не подсунут вместо ключа поуже: состав он даёт тот же, а глушит шире', () => {
const u = ustanovki();
expect(u.vApp[0].stroka).not.toContain('--force');
expect(u.vKorne[0].stroka).not.toContain('--force');
});
});
// ============ СТЕНЫ 4 и 5: угол собран, а работать в нём нельзя ============
//
// 🔴 ОБЕ НАЙДЕНЫ ЖИВЬЁМ 03.08.2026 В НАСТОЯЩЕМ УГЛУ, и найдены цепочкой: пока не сняли
// стену 3 (`item-audit.mjs`, папка команды), проверки продукта не запускались вовсе и
// стену 4 не показывали. Сняли 4 — из-под неё вышла 5. Прибор, смотрящий внутрь, не
// показал ни одной из трёх: угол «заводился успешно» все пять смен.
describe('стены 4 и 5: свежий угол собран, но проверки продукта в нём падают пятисоткой', () => {
// Свои подпорки: те, что выше, живут в чужом блоке и сюда не видны.
function hozyaystvo() {
mkdirSync(join(dir, 'bin'), { recursive: true });
writeFileSync(join(dir, 'bin', 'gitleaks.exe'), 'заглушка вместо 22 МБ, но файл настоящий', 'utf8');
mkdirSync(join(dir, 'app'), { recursive: true });
writeFileSync(join(dir, 'app', '.env'), 'DB_USERNAME=liderra_dev\nDB_PASSWORD=местный\n', 'utf8');
}
const UGOL = () => join(dir, 'ugol', 'w-1');
function soglyadatay(ugol) {
const zvali = [];
return {
zvali,
run: (bin, args, opts) => {
zvali.push({ stroka: `${String(bin).toLowerCase()} ${(args ?? []).join(' ')}`, cwd: String(opts?.cwd ?? '') });
if (String(bin).toLowerCase().includes('git') && (args ?? []).includes('worktree')) {
mkdirSync(join(ugol, 'app'), { recursive: true });
}
if ((args ?? []).join(' ').includes('artisan key:generate')) {
const p = join(ugol, 'app', '.env');
writeFileSync(p, readFileSync(p, 'utf8').replace(/^APP_KEY=.*$/m, 'APP_KEY=base64:проба'), 'utf8');
}
return '';
},
};
}
it('🔴 сама починка стены 4: заводит ровно недостающее и НЕ трогает то, что уже стоит', () => {
// Подставной угол: часть каталогов уже есть, часть нет. Довод — на живом угле
// существовали `cache/data` и `logs`, а `views`, `sessions`, `testing` отсутствовали.
const ugol = join(dir, 'ugol-kat');
mkdirSync(join(ugol, 'app', 'storage', 'logs'), { recursive: true });
mkdirSync(join(ugol, 'app', 'storage', 'framework', 'cache', 'data'), { recursive: true });
const zaveli = sozdayRabochieKatalogi(ugol);
// 🔴 Именно те три, что вправду отсутствовали на живом угле, — и ни одного лишнего.
expect(zaveli).toContain('storage/framework/views');
expect(zaveli).toContain('storage/framework/sessions');
expect(zaveli).toContain('storage/framework/testing');
expect(zaveli).not.toContain('storage/logs');
expect(zaveli).not.toContain('storage/framework/cache/data');
// И они вправду появились на диске, а не только в ответе.
expect(existsSync(join(ugol, 'app', 'storage', 'framework', 'views'))).toBe(true);
});
it('🪤 второй зов не заводит НИЧЕГО — иначе починка затирала бы чужое каждую ночь', () => {
const ugol = join(dir, 'ugol-dvazhdy');
mkdirSync(join(ugol, 'app'), { recursive: true });
expect(sozdayRabochieKatalogi(ugol).length).toBeGreaterThan(0);
expect(sozdayRabochieKatalogi(ugol)).toEqual([]);
});
it('🔴 `views` в наборе есть — без него первый же шаблон роняет запрос пятисоткой', () => {
// Замерено живьём: «InvalidArgumentException: Please provide a valid cache path»
// из `Illuminate\View\Compilers\Compiler.php`. Причина — хранилище не знает подпорки
// `.gitignore` для этого каталога, а пустых каталогов git не держит.
expect(RABOCHIE_KATALOGI.map(c => c.join('/'))).toContain('storage/framework/views');
});
it('🔴 заведение угла вправду ЗОВЁТ починку каталогов — и зовёт ДО первой php-команды', () => {
// 🪤 Проверка «есть такая функция» не сторожит ничего: её можно написать и не позвать.
// Поэтому смотрим на живой ход `createWorkspace` и на ПОРЯДОК.
hozyaystvo();
const ugol = UGOL();
const s = soglyadatay(ugol);
createWorkspace({
repoRoot: dir, runId: 'r-proba', workerId: 'w-1', branch: 'night/r-proba/w-1',
workspaceRoot: ugol, baseBranch: 'feat/night-dispatcher-part1',
dbName: 'night_r_proba_w_1', run: s.run,
});
// Каталоги на месте после заведения угла.
expect(existsSync(join(ugol, 'app', 'storage', 'framework', 'views'))).toBe(true);
expect(existsSync(join(ugol, 'app', 'bootstrap', 'cache'))).toBe(true);
});
it('🔴 стена 5: сборка фронта зовётся, и зовётся ИЗ `app/`', () => {
hozyaystvo();
const ugol = UGOL();
const s = soglyadatay(ugol);
createWorkspace({
repoRoot: dir, runId: 'r-proba', workerId: 'w-1', branch: 'night/r-proba/w-1',
workspaceRoot: ugol, baseBranch: 'feat/night-dispatcher-part1',
dbName: 'night_r_proba_w_1', run: s.run,
});
const sborka = s.zvali.filter(z => z.stroka.includes('run build'));
expect(sborka).toHaveLength(1);
// 🪤 Папка — половина починки. Наряда `build` в корневом `package.json` НЕТ: замерено
// живьём, из корня угла `npm run build` отвечает «нет такого наряда». Проверка, не
// глядящая на папку, осталась бы зелёной при неработающей сборке.
expect(sborka[0].cwd).toBe(join(ugol, 'app'));
});
it('🔴 три установки идут ЧЕРЕЗ ПОВТОР, остальные шаги — нет', () => {
// 🪤 Вопрос вырезания второго рода: сколько РАЗНЫХ состояний бывает у шага на самом
// деле? Их три, а не два: «прошёл», «упал по своей вине», «упал не по своей вине».
// Прежний код сливал два последних в одно и валил всю ночь на моргнувшей сети.
hozyaystvo();
const ugol = UGOL();
// Роняем КАЖДУЮ установку по одному разу — если повтора нет, заведение угла упадёт.
const upalo = new Map();
const s = soglyadatay(ugol);
const nastoyashchiy = s.run;
const run = (bin, args, opts) => {
const stroka = `${String(bin).toLowerCase()} ${(args ?? []).join(' ')}`;
const ustanovka = stroka.includes('install --no-interaction') || stroka.includes('ci --no-audit');
if (ustanovka && !upalo.get(stroka)) {
upalo.set(stroka, true);
const e = new Error('подставной обрыв сети');
throw e;
}
return nastoyashchiy(bin, args, opts);
};
const r = createWorkspace({
repoRoot: dir, runId: 'r-proba', workerId: 'w-1', branch: 'night/r-proba/w-1',
workspaceRoot: ugol, baseBranch: 'feat/night-dispatcher-part1',
dbName: 'night_r_proba_w_1', run,
});
// Угол вправду завёлся, несмотря на три падения.
expect(r.dbName).toBe('night_r_proba_w_1');
// 🔴 И повторы НАЗВАНЫ ВСЛУХ — ровно три шага, по одному повтору каждый.
expect(r.povtory).toHaveLength(3);
for (const p of r.povtory) {
expect(p.popytok).toBe(2);
expect(p.upali[0]).toContain('подставной обрыв сети');
}
expect(r.povtory.map(p => p.shag).join('|')).toMatch(/composer install/);
});
it('🔴 гладкая ночь не пишет НИ ОДНОЙ строки про повторы', () => {
// 🪤 Пиши мы каждую удачу — владелец читал бы «повторов 0» семь раз за ночь и
// перестал бы это замечать. Строка нужна ровно затем, чтобы её заметили.
hozyaystvo();
const ugol = UGOL();
const s = soglyadatay(ugol);
const r = createWorkspace({
repoRoot: dir, runId: 'r-proba', workerId: 'w-1', branch: 'night/r-proba/w-1',
workspaceRoot: ugol, baseBranch: 'feat/night-dispatcher-part1',
dbName: 'night_r_proba_w_1', run: s.run,
});
expect(r.povtory).toEqual([]);
});
it('🔴 сборка идёт ПОСЛЕ установки библиотек — до неё собирать нечем', () => {
hozyaystvo();
const ugol = UGOL();
const s = soglyadatay(ugol);
createWorkspace({
repoRoot: dir, runId: 'r-proba', workerId: 'w-1', branch: 'night/r-proba/w-1',
workspaceRoot: ugol, baseBranch: 'feat/night-dispatcher-part1',
dbName: 'night_r_proba_w_1', run: s.run,
});
const ustanovkaApp = s.zvali.findIndex(z => z.stroka.includes('ci --no-audit') && z.cwd === join(ugol, 'app'));
const sborka = s.zvali.findIndex(z => z.stroka.includes('run build'));
expect(ustanovkaApp).toBeGreaterThan(-1);
expect(sborka).toBeGreaterThan(ustanovkaApp);
});
});
// ============ НЕНАДЁЖНЫЙ ШАГ: повтор ограниченный и сказанный вслух ============
//
// 🔴 ЗАМЕРЕНО ЖИВЬЁМ 03.08.2026: `composer install` в свежем углу упал ОДИН РАЗ ИЗ ТРЁХ
// на одном и том же коде. Причина не найдена и здесь НЕ выдумывается — лечится
// ненадёжность, а не догадка о ней.
describe('ненадёжный шаг: повтор ограничен, назван вслух и не глушит настоящие отказы', () => {
// Подставной запуск, падающий заданное число раз подряд, а потом отвечающий.
function padaet(razProvalov, slova = 'подставной обрыв') {
let bylo = 0;
const zvali = [];
return {
zvali,
run: () => {
zvali.push(1);
if (bylo < razProvalov) { bylo += 1; throw new Error(slova); }
return 'готово';
},
};
}
it('упал один раз — повторили и прошли; в журнале сказано, что повторяли', () => {
const p = padaet(1);
const zhurnal = [];
const out = sPovtorom({ run: p.run, bin: 'проба', args: [], opts: {}, imya: 'проба', zhurnal });
expect(out).toBe('готово');
expect(p.zvali).toHaveLength(2);
expect(zhurnal).toHaveLength(1);
expect(zhurnal[0].popytok).toBe(2);
});
it('🔴 повтор ОГРАНИЧЕН: падает всегда — сдаёмся ровно после стольких попыток, сколько объявлено', () => {
// 🪤 Без предела ночь встала бы навсегда в одном шаге, и это хуже отказа: отказ виден,
// а вечный повтор выглядит работой.
const p = padaet(999);
const zhurnal = [];
expect(() => sPovtorom({ run: p.run, bin: 'проба', args: [], opts: {}, imya: 'проба', zhurnal }))
.toThrow(/отказал 3 раза подряд/);
expect(p.zvali).toHaveLength(POPYTOK_SETEVOGO_SHAGA);
expect(zhurnal[0].sdalis).toBe(true);
});
it('🔴 причина отказа доходит до владельца ЦЕЛИКОМ — наша приписка её не заслоняет', () => {
const p = padaet(999, 'на диске кончилось место');
expect(() => sPovtorom({ run: p.run, bin: 'проба', args: [], opts: {}, imya: 'проба' }))
.toThrow(/на диске кончилось место/);
});
it('🔴 настоящий отказ НЕ повторяется: спор версий падает с первой попытки', () => {
// 🔴 Замерено в круге 1 (стена 2): `ERESOLVE` падает ВСЕГДА, на каждой попытке.
// Повторять его — сжечь три раза по минуте и написать владельцу неправду про сеть.
const p = padaet(999, 'npm error code ERESOLVE\nnpm error could not resolve');
const zhurnal = [];
expect(() => sPovtorom({ run: p.run, bin: 'проба', args: [], opts: {}, imya: 'проба', zhurnal }))
.toThrow(/ERESOLVE/);
// 🔴 Ровно ОДНА попытка, а не три — вот всё содержание этой проверки.
expect(p.zvali).toHaveLength(1);
expect(zhurnal[0].ne_povtoryali).toMatch(/не сошлись версии/);
});
it('прошло с первого раза — в журнале НИЧЕГО', () => {
const p = padaet(0);
const zhurnal = [];
sPovtorom({ run: p.run, bin: 'проба', args: [], opts: {}, imya: 'проба', zhurnal });
expect(p.zvali).toHaveLength(1);
expect(zhurnal).toEqual([]);
});
});
// ============ УБОРКА, КОТОРАЯ ПАДАЕТ САМА ============
//
// 🔴 НАЙДЕНО ЖИВЬЁМ 03.08.2026. Установка упала сама, уборка пошла штатной дорогой и ТОЖЕ
// упала: `git worktree remove --force` → код 128, «validation failed, cannot remove
// working tree: '…/.git' does not exist». Угол и ветка остались в хранилище, снимал руками.
// «Беда 7» была закрыта наполовину: закрыт случай «упал шаг установки», не закрыт
// «упала сама уборка».
describe('уборка падает сама — следов всё равно не остаётся', () => {
function hozyaystvo() {
mkdirSync(join(dir, 'bin'), { recursive: true });
writeFileSync(join(dir, 'bin', 'gitleaks.exe'), 'заглушка вместо 22 МБ, но файл настоящий', 'utf8');
mkdirSync(join(dir, 'app'), { recursive: true });
writeFileSync(join(dir, 'app', '.env'), 'DB_USERNAME=liderra_dev\nDB_PASSWORD=местный\n', 'utf8');
}
const UGOL = () => join(dir, 'ugol', 'w-1');
// Заведение угла падает на установке, а штатная уборка каталога — тоже падает.
function zapusk({ uborkaPadaet }) {
const ugol = UGOL();
const zvali = [];
const run = (bin, args) => {
const stroka = `${String(bin).toLowerCase()} ${(args ?? []).join(' ')}`;
zvali.push(stroka);
if (stroka.includes('worktree add')) { mkdirSync(join(ugol, 'app'), { recursive: true }); return ''; }
// Роняем установку — ровно тот случай, ради которого уборка и заведена.
if (stroka.includes('install --no-interaction')) throw new Error('подставное падение установки');
if (uborkaPadaet && stroka.includes('worktree remove')) {
throw new Error("fatal: validation failed, cannot remove working tree: '.git' does not exist");
}
return '';
};
let slova = '';
try {
createWorkspace({
repoRoot: dir, runId: 'r-proba', workerId: 'w-1', branch: 'night/r-proba/w-1',
workspaceRoot: ugol, baseBranch: 'feat/night-dispatcher-part1',
dbName: 'night_r_proba_w_1', run,
});
} catch (err) { slova = String(err?.message ?? err); }
return { zvali, slova, ugol };
}
it('🔴 штатная уборка отказала — каталог всё равно снесён, и запись о нём вычищена', () => {
hozyaystvo();
const r = zapusk({ uborkaPadaet: true });
// 🔴 Главное: на диске УГЛА НЕТ. Это не про вызванные команды, а про итог.
expect(existsSync(r.ugol)).toBe(false);
// 🪤 Без `worktree prune` git считает угол живым и ветку не отдаёт — тогда следом
// упала бы и `branch -D`, и в остатках стояла бы вторая строка про ту же беду.
expect(r.zvali.some(s => s.includes('worktree prune'))).toBe(true);
expect(r.zvali.some(s => s.includes('branch -D night/r-proba/w-1'))).toBe(true);
// И владельцу сказано, что следов не осталось.
expect(r.slova).toMatch(/за собой убрано/i);
});
it('причина падения САМОЙ установки не потеряна за рассказом про уборку', () => {
hozyaystvo();
const r = zapusk({ uborkaPadaet: true });
expect(r.slova).toMatch(/подставное падение установки/);
});
it('штатная уборка сработала — запасная дорога не зовётся вовсе', () => {
// 🪤 Обратная сторона: запасная дорога, которая ходит ВСЕГДА, — это не запасная
// дорога, а вторая основная, и она сносила бы каталог мимо git каждую ночь.
hozyaystvo();
const r = zapusk({ uborkaPadaet: false });
expect(r.zvali.some(s => s.includes('worktree remove'))).toBe(true);
expect(r.zvali.some(s => s.includes('worktree prune'))).toBe(false);
});
});