1136 lines
74 KiB
JavaScript
1136 lines
74 KiB
JavaScript
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() });
|
||
// Слова самого падения обязаны остаться: по ним владелец и понимает, что чинить.
|
||
expect(() => createWorkspace({ ...ARGS(), run: p.run })).toThrow(/подставное падение/);
|
||
// И рядом — прямое слово о том, что следов не осталось.
|
||
expect(() => createWorkspace({ ...ARGS(), run: p.run })).toThrow(/за собой убрано/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);
|
||
});
|
||
});
|