882 lines
58 KiB
JavaScript
882 lines
58 KiB
JavaScript
// 🔴 `afterEach` и `existsSync` дописаны в УЖЕ СТОЯЩИЕ строки ввоза (кусок 3, задача 6),
|
||
// а не отдельными строками рядом: план предлагал завести вторые ввозы из тех же файлов
|
||
// с прозвищами (`rmSync as ubratSled`), а второе имя того же прибора — это ровно тот сорт
|
||
// мелочи, которая потом разъезжается молча.
|
||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||
import { rmSync, existsSync, mkdtempSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { randomUUID } from 'node:crypto';
|
||
import { planRun, startAll, MAX_WORKERS } from './run.mjs';
|
||
import { mayTouch } from './file-claims.mjs';
|
||
import { runDir, workerDir, summaryLinesPath } from './paths.mjs';
|
||
// 🔴 Путь к следам знает ОДИН файл на всю затею — берём его оттуда же, что и рабочий код,
|
||
// а не складываем свой второй такой же путь строкой.
|
||
import { deferredDir } from './deferred.mjs';
|
||
import { readJson, readLines } from './state.mjs';
|
||
// 🔴 Итог прогона пишет ОДНО место на всю затею — `writeRunOutcome` надзирателя. Проверки
|
||
// ниже подставляют его же, но с временным путём для `last-run.json`: настоящий лежит в общем
|
||
// `.night/` этого рабочего угла, и трогать его проверкой нельзя.
|
||
import { writeRunOutcome } from './supervisor.mjs';
|
||
|
||
// 🔴🔴 У КАЖДОГО ЗАПУСКА ПРИБОРА ПРОВЕРОК — СВОЙ КАТАЛОГ ПРОГОНА, И ЭТО НЕ УКРАШЕНИЕ.
|
||
// Проверки ниже пишут в НАСТОЯЩИЙ `.night/` хозяйства: каталог прогона считает сам
|
||
// `startAll` через `runDir`, и подменить его нечем — подпись задана договором о стыках.
|
||
// Поэтому уборка стоит в `finally`/`afterEach`, а имена прогонов заведомо пробные.
|
||
//
|
||
// 🪤 ЧЕГО ЭТОГО БЫЛО МАЛО. Имена прогонов были ПОСТОЯННЫМИ (`r-zanyatiya-fajlov`,
|
||
// `проба-головы` и прочие), а уборка сносит каталог ЦЕЛИКОМ. Два прибора проверок,
|
||
// запущенные разом в одном рабочем углу, брали ОДИН И ТОТ ЖЕ каталог: уборка одного
|
||
// сносила хозяйство другому прямо посреди опыта. Замерено 02.08.2026 — два прибора
|
||
// по шесть заходов: 10 красных из 12, и красные КАЖДЫЙ РАЗ РАЗНЫЕ, а поодиночке тот же
|
||
// файл зелен всегда. Это ровно тот сорт беды, из-за которого идут чинить исправное.
|
||
//
|
||
// 🔴 Неповторимый хвост берётся ТЕМ ЖЕ прибором, каким его берёт рабочий код
|
||
// (`run.mjs`: `r-<время>-<randomUUID>`), а не вторым, своим: два правила на одно дело
|
||
// в разных местах — известная беда хозяйства. Сносим при этом ТОЛЬКО СВОЁ.
|
||
// Сторож на этот класс — `nastoyashchee-hozyaystvo-v-proverkah.test.mjs`.
|
||
|
||
const okInput = {
|
||
base_branch: 'feat/night-dispatcher-part1',
|
||
items: [
|
||
{ id: '1', files: ['a.php'], tests: ['t.mjs'] },
|
||
{ id: '2', files: ['b.php'], tests: ['t.mjs'] },
|
||
],
|
||
// Эти поля кусок 1 принимает и не смотрит — их подключает кусок 2.
|
||
budget: { may_start: true, run_share_limit: 1 / 7 },
|
||
disk: { ok: true, free_gb: 40 },
|
||
owner: { last_summary_at: null },
|
||
previousWorkers: [],
|
||
capacity: { tasks_running: 0, max_tasks: 2 },
|
||
newTaskFiles: ['a.php'],
|
||
runningTasksFiles: [],
|
||
planSeal: { ok: true },
|
||
papers: { ok: true },
|
||
spec: { ok: true },
|
||
};
|
||
|
||
describe('planRun — расписание прогона', () => {
|
||
it('прогон расписан, работники получили по пункту', () => {
|
||
const r = planRun(okInput);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.workers.length).toBe(2);
|
||
expect(r.workers[0].item.id).toBe('1');
|
||
expect(r.workers[0].worker_id).toBe('w-1');
|
||
expect(r.workers[1].worker_id).toBe('w-2');
|
||
});
|
||
|
||
it('каждому работнику своё имя базы — двое не пишут в одну', () => {
|
||
const r = planRun(okInput);
|
||
const names = new Set(r.workers.map(w => w.db_name));
|
||
expect(names.size).toBe(r.workers.length);
|
||
});
|
||
|
||
it('денег работникам не выдаётся вовсе — своего счёта в долларах у затеи нет', () => {
|
||
const r = planRun(okInput);
|
||
for (const w of r.workers) {
|
||
expect(w.max_usd).toBeUndefined();
|
||
expect(w.limit_usd).toBeUndefined();
|
||
}
|
||
expect(r.run_limit_usd).toBeUndefined();
|
||
});
|
||
|
||
it('доля недельного запаса пробрасывается как есть — считает её кусок 2, не мы', () => {
|
||
expect(planRun(okInput).run_share_limit).toBeCloseTo(1 / 7, 6);
|
||
expect(planRun({ ...okInput, budget: null }).run_share_limit).toBeNull();
|
||
});
|
||
|
||
it('точка ветвления уходит каждому работнику — угол ветвится от ветки затеи, не от главной', () => {
|
||
const r = planRun(okInput);
|
||
expect(r.workers[0].base_branch).toBe('feat/night-dispatcher-part1');
|
||
expect(r.base_branch).toBe('feat/night-dispatcher-part1');
|
||
});
|
||
|
||
it('точку ветвления не назвали — прогона нет: от главной в углу нет ничего из затеи', () => {
|
||
const r = planRun({ ...okInput, base_branch: null });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toMatch(/точк[аи] ветвления/i);
|
||
expect(r.workers).toEqual([]);
|
||
expect(r.failed_gates[0].gate).toMatch(/точка ветвления/i);
|
||
});
|
||
|
||
it('делать нечего — прогона нет, и причина названа', () => {
|
||
const r = planRun({ ...okInput, items: [] });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toMatch(/ни одного пункта/i);
|
||
expect(r.workers).toEqual([]);
|
||
});
|
||
|
||
it('работников не больше семи, даже если пунктов больше', () => {
|
||
const items = Array.from({ length: 12 }, (_, i) => ({ id: String(i + 1), files: [`f${i}.php`], tests: ['t.mjs'] }));
|
||
expect(MAX_WORKERS).toBe(7);
|
||
expect(planRun({ ...okInput, items }).workers.length).toBe(7);
|
||
});
|
||
|
||
it('пункты за потолком НЕ пропадают молча — сколько не взято, сказано числом и словами', () => {
|
||
// 🔴 Беда 8. Раньше лишние пункты просто срезались, и владелец не видел ни строки.
|
||
const items = Array.from({ length: 12 }, (_, i) => ({ id: String(i + 1), files: [`f${i}.php`], tests: ['t.mjs'] }));
|
||
const r = planRun({ ...okInput, items });
|
||
expect(r.ne_vzyato).toBe(5);
|
||
expect(r.ne_vzyato_slovami).toMatch(/не взято/i);
|
||
});
|
||
|
||
it('номер прогона содержит ВРЕМЯ, а не только дату — два прогона за сутки не затрут друг друга', () => {
|
||
// 🪤 Прежде номер был `r-<дата>`: второй прогон за те же сутки получил бы тот же номер,
|
||
// ту же ветку и тот же каталог — и затёр бы итог первого молча.
|
||
// 🆕 Обрезка до минуты снята (см. раздел «номер прогона вправду неповторим» ниже):
|
||
// теперь во имени есть и секунды, и случайный хвост. День и время в нём по-прежнему
|
||
// на первом месте — владелец обязан узнавать свой прогон в сводке глазами.
|
||
expect(planRun(okInput).run_id).toMatch(/^r-\d{4}-\d{2}-\d{2}-\d{6}-[0-9a-f]+$/);
|
||
});
|
||
});
|
||
|
||
// ============ БЕДА 8: потолок работников применялся ДВАЖДЫ и по-разному ============
|
||
//
|
||
// 🔴 ЗАМЕРЕНО СЧЁТОМ МЕСТ, а не памятью. На вопрос «сколько работников разом можно»
|
||
// в затее отвечали ДВА места, и отвечали они по-разному:
|
||
//
|
||
// · `gates.mjs` (`capacityFree`) — берёт число ИЗ НАСТРОЕК ВЛАДЕЛЬЦА
|
||
// (`max_workers_per_task`), запасное `MAX_WORKERS_DEFAULT = 7`, и при переборе
|
||
// ОТКАЗЫВАЕТ ВСЛУХ, назвав и своё число, и сколько просят;
|
||
// · `run.mjs` (`planRun`) — держал СВОЮ семёрку строкой в коде, настроек владельца
|
||
// не спрашивал вовсе и лишние пункты срезал МОЛЧА.
|
||
//
|
||
// Разъезд живой, не бумажный: напиши владелец в настройках «десять» — ворота пропустили
|
||
// бы десять, а срезка оставила бы семь. Три пункта не взял бы никто, и в сводке про них
|
||
// не стояло бы ни строки.
|
||
//
|
||
// Лечение: число живёт в ОДНОМ месте (ворота), `run.mjs` берёт его оттуда же, а всё,
|
||
// что за потолком, называется владельцу словами.
|
||
describe('беда 8: потолок работников — одно число, одно место, и остаток НАЗВАН владельцу', () => {
|
||
const punkty = n => Array.from({ length: n }, (_, i) => ({ id: String(i + 1), files: [`f${i}.php`], tests: ['t.mjs'] }));
|
||
|
||
it('владелец написал десять — заводится десять, а не семь из чужой головы', () => {
|
||
// 🔴 Эту проверку постоянная-семёрка в коде пройти не может физически.
|
||
const r = planRun({ ...okInput, items: punkty(10), capacity: { ...okInput.capacity, max_workers: 10 } });
|
||
expect(r.ok).toBe(true);
|
||
expect(r.workers.length).toBe(10);
|
||
expect(r.ne_vzyato).toBe(0);
|
||
});
|
||
|
||
it('владелец написал троих — заводится трое, а лишние шесть НАЗВАНЫ', () => {
|
||
const r = planRun({ ...okInput, items: punkty(9), capacity: { ...okInput.capacity, max_workers: 3 } });
|
||
expect(r.workers.length).toBe(3);
|
||
expect(r.ne_vzyato).toBe(6);
|
||
expect(r.ne_vzyato_slovami).toContain('6');
|
||
expect(r.ne_vzyato_slovami).toContain('3');
|
||
});
|
||
|
||
it('владелец не программист — в словах про непрочитанное нет ни одного имени из кода', () => {
|
||
const r = planRun({ ...okInput, items: punkty(9), capacity: { ...okInput.capacity, max_workers: 3 } });
|
||
const s = r.ne_vzyato_slovami;
|
||
// 🪤 «Лишь бы не пусто» тут не годится: строка обязана быть человеческой.
|
||
expect(s).not.toMatch(/\.mjs|\.json|planRun|MAX_WORKERS|max_workers|slice|items/);
|
||
expect(s).toMatch(/пункт/i);
|
||
});
|
||
|
||
it('пунктов не больше потолка — владельцу про «не взято» не говорят ничего', () => {
|
||
const r = planRun({ ...okInput, items: punkty(3), capacity: { ...okInput.capacity, max_workers: 7 } });
|
||
expect(r.ne_vzyato).toBe(0);
|
||
// 🪤 Пустая строка вместо молчания — это лишняя строка в сводке каждую ночь.
|
||
expect(r.ne_vzyato_slovami).toBeNull();
|
||
});
|
||
|
||
it('ровно по потолку — тоже молчание: семеро при потолке семь это разрешённые семеро', () => {
|
||
const r = planRun({ ...okInput, items: punkty(7), capacity: { ...okInput.capacity, max_workers: 7 } });
|
||
expect(r.workers.length).toBe(7);
|
||
expect(r.ne_vzyato).toBe(0);
|
||
expect(r.ne_vzyato_slovami).toBeNull();
|
||
});
|
||
|
||
// 🔴🔴 «А МОЙ ОТВЕТ КТО-НИБУДЬ СЛУШАЕТ?» — третий вопрос ко всякой защите. Число
|
||
// непрочитанных пунктов `planRun` возвращает полем, но ПЕЧАТАЕТ его чужой файл, и мог
|
||
// бы не печатать вовсе. Поэтому строка кладётся ещё и в сводку — тот канал, который
|
||
// до владельца доходит утром наверняка.
|
||
it('строка про непрочитанное вправду ДОХОДИТ до владельца — она в утренней сводке', () => {
|
||
const runId = `проба-потолка-${randomUUID().slice(0, 8)}`;
|
||
// Работников двое, а пунктов в очереди пять — трое сразу никому не достались.
|
||
const workers = [
|
||
{ worker_id: 'w-1', item: { id: '1', files: ['a.php'], tests: ['t.mjs'] }, branch: 'night/x/w-1', db_name: 'db1' },
|
||
{ worker_id: 'w-2', item: { id: '2', files: ['b.php'], tests: ['t.mjs'] }, branch: 'night/x/w-2', db_name: 'db2' },
|
||
];
|
||
const items = punkty(5);
|
||
try {
|
||
startAll({
|
||
runId, workers, items, planPath: 'план.md', baseBranch: 'feat/night-dispatcher-part1',
|
||
workerModel: 'голова', createWorkspaceFn: ({ workspaceRoot, dbName }) => ({ workspaceRoot, dbName, settingsPath: 'нет.json' }),
|
||
spawnSupervisorFn: () => ({ pid: 1 }),
|
||
});
|
||
const stroki = readLines(summaryLinesPath(runId)).map(s => s.line ?? '');
|
||
const pro = stroki.find(s => /не взято/i.test(s));
|
||
expect(pro).toBeTruthy();
|
||
expect(pro).toContain('3');
|
||
} finally {
|
||
rmSync(runDir(runId), { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('пунктов ровно по работникам — лишней строки в сводке НЕТ', () => {
|
||
const runId = `проба-без-остатка-${randomUUID().slice(0, 8)}`;
|
||
const workers = [{ worker_id: 'w-1', item: { id: '1', files: ['a.php'], tests: ['t.mjs'] }, branch: 'night/x/w-1', db_name: 'db1' }];
|
||
try {
|
||
startAll({
|
||
runId, workers, items: punkty(1), planPath: 'план.md', baseBranch: 'feat/night-dispatcher-part1',
|
||
workerModel: 'голова', createWorkspaceFn: ({ workspaceRoot, dbName }) => ({ workspaceRoot, dbName, settingsPath: 'нет.json' }),
|
||
spawnSupervisorFn: () => ({ pid: 1 }),
|
||
});
|
||
const stroki = readLines(summaryLinesPath(runId)).map(s => s.line ?? '');
|
||
expect(stroki.some(s => /не взято/i.test(s))).toBe(false);
|
||
} finally {
|
||
rmSync(runDir(runId), { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('🔴 запасное число берётся ИЗ ВОРОТ, а не своей копией: поменяли там — прогон идёт за ним', async () => {
|
||
// 🔴🔴 ВЫРЕЗАНИЕ ВТОРОГО РОДА, вделанное в саму проверку. Сравнение
|
||
// `expect(MAX_WORKERS).toBe(MAX_WORKERS_DEFAULT)` было бы пустышкой: обе стороны
|
||
// равны семи и при ДВУХ отдельных семёрках. Поэтому число в воротах подменяется
|
||
// на четвёрку, и прогон обязан пойти за ним. Своя постоянная в коде дала бы семь.
|
||
vi.resetModules();
|
||
vi.doMock('./gates.mjs', async () => {
|
||
const nastoyashchie = await vi.importActual('./gates.mjs');
|
||
return { ...nastoyashchie, MAX_WORKERS_DEFAULT: 4 };
|
||
});
|
||
try {
|
||
const { planRun: planRunSPodmenoy } = await import('./run.mjs');
|
||
// Настроек владельца НЕТ — значит берётся запасное число, и оно теперь четыре.
|
||
const r = planRunSPodmenoy({ ...okInput, items: punkty(9) });
|
||
expect(r.workers.length).toBe(4);
|
||
expect(r.ne_vzyato).toBe(5);
|
||
} finally {
|
||
vi.doUnmock('./gates.mjs');
|
||
vi.resetModules();
|
||
}
|
||
});
|
||
});
|
||
|
||
// ============ беда 1: номер прогона мельчал только до минуты ============
|
||
//
|
||
// 🔴 Найдено воротами ЖИВЬЁМ, а не чтением: две разные задачи, заведённые в одну минуту,
|
||
// получили ОДИН номер. Карточка второй затёрла первую, строки сводки обоих смешались
|
||
// в одном каталоге, а ветки столкнулись живьём («ветка night/... уже существует»).
|
||
// Примечание рядом с кодом обещало обратное — «во имя прогона входит ВРЕМЯ», — а защита
|
||
// была сделана только до минуты. Ночью две задачи разом — это расписание, а не редкость.
|
||
describe('номер прогона вправду неповторим — два прогона в одну минуту не сливаются', () => {
|
||
// Часы замораживаем НАМЕРТВО: на живых часах две подряд идущие строки почти всегда
|
||
// попадут в разные секунды, и проверка зеленела бы на сломанном коде.
|
||
function vOdnuMinutu(delo) {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date('2026-08-02T23:17:04.000Z'));
|
||
try {
|
||
return delo();
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
}
|
||
|
||
it('🔴 два прогона в ОДНУ И ТУ ЖЕ секунду получают РАЗНЫЕ номера', () => {
|
||
const [a, b] = vOdnuMinutu(() => [planRun(okInput).run_id, planRun(okInput).run_id]);
|
||
expect(a).not.toBe(b);
|
||
});
|
||
|
||
it('🔴 у них разные каталоги, разные ветки и разные базы — затирать друг друга нечем', () => {
|
||
// Номер прогона входит в имена веток, каталогов и баз. Совпал номер — совпало ВСЁ:
|
||
// второй прогон пишет в чужой каталог, а `git worktree add -b` падает на занятой ветке.
|
||
const [p1, p2] = vOdnuMinutu(() => [planRun(okInput), planRun(okInput)]);
|
||
expect(runDir(p1.run_id)).not.toBe(runDir(p2.run_id));
|
||
expect(p1.workers[0].branch).not.toBe(p2.workers[0].branch);
|
||
expect(p1.workers[0].db_name).not.toBe(p2.workers[0].db_name);
|
||
});
|
||
|
||
it('номер остаётся понятным человеку: в нём видны день и время прогона', () => {
|
||
// 🔴 Неповторимость нельзя покупать нечитаемым именем: этот номер владелец видит
|
||
// в сводке и по нему же ищет каталог прогона.
|
||
const id = vOdnuMinutu(() => planRun(okInput).run_id);
|
||
expect(id.startsWith('r-2026-08-02-2317')).toBe(true);
|
||
});
|
||
|
||
it('имя базы работника из такого номера база ПРИМЕТ: только строчные, цифры и подчёркивание', () => {
|
||
// 🪤 `assertOwnDbName` (workspace.mjs) отвергает посторонние знаки, и отвергает уже
|
||
// посреди ночи. Длина имени базы у PostgreSQL — 63 знака.
|
||
const w = vOdnuMinutu(() => planRun(okInput).workers[0]);
|
||
expect(w.db_name).toMatch(/^[a-z0-9_]+$/);
|
||
expect(w.db_name.length).toBeLessThan(63);
|
||
});
|
||
|
||
it('имя ветки из такого номера git ПРИМЕТ: без пробелов и служебных знаков', () => {
|
||
const w = vOdnuMinutu(() => planRun(okInput).workers[0]);
|
||
expect(w.branch).toMatch(/^night\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/);
|
||
});
|
||
|
||
it('номер назвали снаружи — берётся он, а не выдуманный (не глухо)', () => {
|
||
expect(planRun({ ...okInput, run_id: 'r-своё-имя' }).run_id).toBe('r-своё-имя');
|
||
});
|
||
});
|
||
|
||
describe('startAll — превращение расписания в живых надзирателей', () => {
|
||
it('точку ветвления не назвали — углы не заводятся вовсе, и сказано почему', () => {
|
||
// Углы и надзиратели живьём проверяются в задаче 16: там заводится настоящий угол
|
||
// и настоящий отвязанный надзиратель. Здесь — единственное, что проверяется без них.
|
||
expect(() => startAll({ runId: 'r-1', workers: [], planPath: 'план.md', baseBranch: null }))
|
||
.toThrow(/точк[аи] ветвления/i);
|
||
});
|
||
|
||
it('голову работника не назвали — углы не заводятся вовсе, и сказано почему', () => {
|
||
// 🔴 Решение владельца: голова работника живёт в настройках (`worker_model`), а не в коде.
|
||
// Падать надо ЗДЕСЬ, до заведения углов: узнать об этом после десяти минут
|
||
// `composer install` в семи углах — значит выбросить всю работу впустую.
|
||
// Верни в код значение по умолчанию — покраснеет здесь.
|
||
expect(() => startAll({
|
||
runId: 'r-1', workers: [], planPath: 'план.md',
|
||
baseBranch: 'feat/night-dispatcher-part1', workerModel: null,
|
||
})).toThrow(/worker_model/i);
|
||
});
|
||
|
||
it('голова из настроек ложится в карточку работника — ею его и запустят', async () => {
|
||
// 🔴 Вторая половина того же решения: мало отказаться от зашитого слова, надо ещё
|
||
// донести настройку до места запуска. Карточка — то самое место: из неё надзиратель
|
||
// берёт голову и передаёт в `startWorker`.
|
||
// Впиши в карточку слово от себя вместо `workerModel` — покраснеет здесь.
|
||
const { readJson } = await import('./state.mjs');
|
||
const { runDir, workerDir } = await import('./paths.mjs');
|
||
const runId = `проба-головы-${randomUUID().slice(0, 8)}`;
|
||
const workers = [{ worker_id: 'w-1', item: { id: '1', files: [], tests: ['t.mjs'] }, branch: 'night/x/w-1', db_name: 'db1' }];
|
||
try {
|
||
startAll({
|
||
runId, workers, planPath: 'план.md', baseBranch: 'feat/night-dispatcher-part1',
|
||
workerModel: 'голова-из-настроек',
|
||
createWorkspaceFn: ({ workspaceRoot, dbName }) => ({ workspaceRoot, dbName, settingsPath: 'нет.json' }),
|
||
spawnSupervisorFn: () => ({ pid: 102 }),
|
||
});
|
||
expect(readJson(join(workerDir(runId, 'w-1'), 'worker.json')).model).toBe('голова-из-настроек');
|
||
} finally {
|
||
rmSync(runDir(runId), { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('карточка заводит resumed_at и resumed_into пустыми — долг перед кусками 6 и 7 (позиция 10)', async () => {
|
||
// 🆕 Кусок 1 только ЗАВОДИТ поля; читает и пишет их при подъёме кусок 6. Без них
|
||
// второе нажатие «продолжить» не отличило бы уже поднятого работника от свежего.
|
||
const { readJson } = await import('./state.mjs');
|
||
const { runDir, workerDir } = await import('./paths.mjs');
|
||
const runId = `проба-resumed-полей-${randomUUID().slice(0, 8)}`;
|
||
const workers = [{ worker_id: 'w-1', item: { id: '1', files: [], tests: ['t.mjs'] }, branch: 'night/x/w-1', db_name: 'db1' }];
|
||
try {
|
||
startAll({
|
||
runId, workers, planPath: 'план.md', baseBranch: 'feat/night-dispatcher-part1',
|
||
workerModel: 'голова-из-настроек',
|
||
createWorkspaceFn: ({ workspaceRoot, dbName }) => ({ workspaceRoot, dbName, settingsPath: 'нет.json' }),
|
||
spawnSupervisorFn: () => ({ pid: 102 }),
|
||
});
|
||
const card = readJson(join(workerDir(runId, 'w-1'), 'worker.json'));
|
||
expect(card.resumed_at).toBeNull();
|
||
expect(card.resumed_into).toBeNull();
|
||
} finally {
|
||
rmSync(runDir(runId), { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('карточка прогона со ВСЕМ составом написана ДО первого надзирателя', async () => {
|
||
// 🔴 Заведение угла идёт минутами. Напиши состав после цикла — первый же погасший
|
||
// работник увидел бы в каталоге одну карточку (свою), решил, что он последний,
|
||
// и закрыл бы весь прогон итогом из одного. Письмо ушло бы про одного из семи.
|
||
// Перенеси запись `run.json` обратно в конец — покраснеет здесь.
|
||
const { readJson } = await import('./state.mjs');
|
||
const { runDir } = await import('./paths.mjs');
|
||
const runId = `проба-порядка-${randomUUID().slice(0, 8)}`;
|
||
const seen = [];
|
||
// 🪤 У пунктов обязаны быть проверки: без них `buildWorkerPrompt` намеренно бросает
|
||
// («такой пункт закрыть нечем»), и проверка упала бы не на том, что мерит.
|
||
const workers = [
|
||
{ worker_id: 'w-1', item: { id: '1', files: [], tests: ['t.mjs'] }, branch: 'night/x/w-1', db_name: 'db1' },
|
||
{ worker_id: 'w-2', item: { id: '2', files: [], tests: ['t.mjs'] }, branch: 'night/x/w-2', db_name: 'db2' },
|
||
];
|
||
try {
|
||
startAll({
|
||
runId, workers, planPath: 'план.md', baseBranch: 'feat/night-dispatcher-part1',
|
||
workerModel: 'голова-из-настроек',
|
||
createWorkspaceFn: ({ workspaceRoot, dbName }) => ({ workspaceRoot, dbName, settingsPath: 'нет.json' }),
|
||
spawnSupervisorFn: ({ workerId }) => {
|
||
// Спрашиваем ровно в тот миг, когда поднимается надзиратель: объявлен ли состав?
|
||
seen.push({ workerId, run: readJson(join(runDir(runId), 'run.json')) });
|
||
return { pid: 100 };
|
||
},
|
||
});
|
||
expect(seen[0].run.workers).toEqual(['w-1', 'w-2']);
|
||
expect(seen[0].run.status).toBe('running');
|
||
} finally {
|
||
rmSync(runDir(runId), { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it('угол одного не завёлся — остальные заводятся, а провалившемуся написана карточка с причиной', async () => {
|
||
// 🪤 Прежде `startAll` бросал посреди цикла: прогон навсегда «идёт» (объявить себя
|
||
// последним некому), заведённые углы висят, а владелец не узнаёт ни слова о причине.
|
||
const { readJson } = await import('./state.mjs');
|
||
const { runDir, workerDir } = await import('./paths.mjs');
|
||
const runId = `проба-провала-${randomUUID().slice(0, 8)}`;
|
||
// 🪤 У пунктов обязаны быть проверки: без них `buildWorkerPrompt` намеренно бросает
|
||
// («такой пункт закрыть нечем»), и проверка упала бы не на том, что мерит.
|
||
const workers = [
|
||
{ worker_id: 'w-1', item: { id: '1', files: [], tests: ['t.mjs'] }, branch: 'night/x/w-1', db_name: 'db1' },
|
||
{ worker_id: 'w-2', item: { id: '2', files: [], tests: ['t.mjs'] }, branch: 'night/x/w-2', db_name: 'db2' },
|
||
];
|
||
try {
|
||
const started = startAll({
|
||
runId, workers, planPath: 'план.md', baseBranch: 'feat/night-dispatcher-part1',
|
||
workerModel: 'голова-из-настроек',
|
||
createWorkspaceFn: ({ workerId, workspaceRoot, dbName }) => {
|
||
if (workerId === 'w-1') throw new Error('git worktree add: уже занято');
|
||
return { workspaceRoot, dbName, settingsPath: 'нет.json' };
|
||
},
|
||
spawnSupervisorFn: () => ({ pid: 101 }),
|
||
});
|
||
expect(started.map(s => s.worker_id)).toEqual(['w-2']);
|
||
const card = readJson(join(workerDir(runId, 'w-1'), 'worker.json'));
|
||
expect(card.status).toBe('extinguished');
|
||
expect(card.finish_reason).toMatch(/угол не завёлся/i);
|
||
// Состав прогона по-прежнему из двоих — иначе прогон некому будет закрыть.
|
||
expect(readJson(join(runDir(runId), 'run.json')).workers).toEqual(['w-1', 'w-2']);
|
||
} finally {
|
||
rmSync(runDir(runId), { recursive: true, force: true });
|
||
}
|
||
});
|
||
});
|
||
|
||
// ============ кусок 2: ворота перед прогоном ============
|
||
|
||
describe('planRun зовёт ворота ПЕРВОЙ строкой — иначе они правило без зовущего', () => {
|
||
it('запас дошёл до двух седьмых — прогона нет вовсе (проверка 16)', () => {
|
||
const r = planRun({
|
||
...okInput,
|
||
budget: { may_start: false, reason: 'Израсходовано 5/7 недельного запаса — прогоны не запускаются.', run_share_limit: 1 / 7 },
|
||
});
|
||
expect(r.ok).toBe(false);
|
||
expect(r.workers).toEqual([]);
|
||
expect(r.reason).toMatch(/запас/i);
|
||
});
|
||
|
||
it('замерить запас не удалось — прогона нет, и сказано ПОЧЕМУ (проверка 17)', () => {
|
||
// 🪤 Ноль почти всегда сбой, а не правда о мире. «Не знаем остаток» — это запрет,
|
||
// а не «наверное хватит»: решать о деньгах вслепую нельзя.
|
||
const r = planRun({
|
||
...okInput,
|
||
budget: { may_start: false, reason: 'Остаток недельного запаса замерить не удалось: события «rate_limit_event» вида seven_day от системы не приходило.', run_share_limit: null },
|
||
});
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toMatch(/замерить не удалось/i);
|
||
expect(r.workers).toEqual([]);
|
||
});
|
||
|
||
it('в отказе назван ДЕНЬ, до которого прогоны не запускаются (проверка 23)', () => {
|
||
const r = planRun({
|
||
...okInput,
|
||
budget: { may_start: false, reason: 'Израсходовано 5/7 недельного запаса. Прогоны не запускаются до 2026-08-08 — в этот день запас обновится.', renews_on: '2026-08-08', run_share_limit: 1 / 7 },
|
||
});
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toContain('2026-08-08');
|
||
});
|
||
|
||
it('план правлен после печати — отказ (проверка 42)', () => {
|
||
const r = planRun({ ...okInput, planSeal: { ok: false, reason: 'Отпечаток плана не сошёлся: план правили после того, как его опечатали.' } });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toMatch(/отпечаток/i);
|
||
expect(r.workers).toEqual([]);
|
||
});
|
||
|
||
it('замысел не проверен — отказ (проверка 44)', () => {
|
||
const r = planRun({ ...okInput, spec: { ok: false, reason: 'Замысел не проверен: рецензентов меньше двух.' } });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toMatch(/замысел/i);
|
||
});
|
||
|
||
it('бумаги не сохранены в хранилище — отказ (проверка 45)', () => {
|
||
const r = planRun({ ...okInput, papers: { ok: false, reason: 'Не сохранены в хранилище: docs/superpowers/plans/пример.md' } });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toMatch(/не сохранен/i);
|
||
});
|
||
|
||
it('на сервере нет места — отказ', () => {
|
||
const r = planRun({ ...okInput, disk: { ok: false, free_gb: 1.5, min_gb: 5, reason: 'На сервере кончается место: свободно 1.5 ГБ при пороге 5 ГБ.' } });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toMatch(/место/i);
|
||
});
|
||
|
||
it('не прошли ДВОЕ ворот — названы оба, а не только первые', () => {
|
||
// 🔴 Владельцу нужен весь список: починив одно и упёршись во второе, он потерял бы ночь.
|
||
const r = planRun({
|
||
...okInput,
|
||
budget: { may_start: false, reason: 'Запас кончился.', run_share_limit: null },
|
||
papers: { ok: false, reason: 'Бумаги не сохранены.' },
|
||
});
|
||
expect(r.ok).toBe(false);
|
||
expect(r.failed_gates.length).toBeGreaterThanOrEqual(2);
|
||
const all = r.failed_gates.map(g => g.reason).join(' ');
|
||
expect(all).toMatch(/Запас кончился/);
|
||
expect(all).toMatch(/Бумаги не сохранены/);
|
||
});
|
||
|
||
it('у каждых непройденных ворот названо ИМЯ, а не «[object Object]»', () => {
|
||
// 🔴 Печать отказа устроена так: ` · ${g.gate}: ${g.reason}`. Смотри проверка один
|
||
// только `reason` — она зеленела бы и на выводе `[object Object]: запас: Запас кончился.`
|
||
// Ровно это и было в первой записи плана. Поэтому спрашиваем поле `gate` поимённо.
|
||
const r = planRun({
|
||
...okInput,
|
||
budget: { may_start: false, reason: 'Запас кончился.', run_share_limit: null },
|
||
papers: { ok: false, reason: 'Бумаги не сохранены.' },
|
||
});
|
||
const names = r.failed_gates.map(g => g.gate);
|
||
expect(names).toContain('запас');
|
||
expect(names).toContain('бумаги сохранены');
|
||
for (const n of names) {
|
||
expect(typeof n).toBe('string');
|
||
expect(n).not.toMatch(/object Object/);
|
||
}
|
||
// Имя ворот не задвоено: в `reason` его быть не должно — оно живёт в `gate`.
|
||
expect(r.failed_gates.find(g => g.gate === 'запас').reason).toBe('Запас кончился.');
|
||
});
|
||
|
||
it('все ворота пройдены — прогон расписан как прежде: ворота не глухие', () => {
|
||
// 🔴 Страховка. Ворота, которые не пропускают НИКОГДА, зеленят все семь проверок выше
|
||
// и убивают затею целиком.
|
||
const r = planRun(okInput);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.workers.length).toBe(2);
|
||
});
|
||
});
|
||
|
||
// ============ кусок 3: занятие файлов за работниками ============
|
||
// 🔴 Ввозы этого куска дописаны в шапку файла (см. примечание там), а не здесь: вторых
|
||
// строк ввоза из тех же файлов не заводим.
|
||
|
||
// 🔴 Хвост неповторим У КАЖДОГО ЗАПУСКА ПРИБОРА: соседний прибор физически не может
|
||
// дотянуться до этого каталога, а уборка в `afterEach` сносит только его (см. шапку файла).
|
||
const RID = `r-zanyatiya-fajlov-${randomUUID().slice(0, 8)}`;
|
||
|
||
function dvoeRabotnikov(faylPervogo = 'app/A.php', faylVtorogo = 'app/B.php') {
|
||
return [
|
||
{ worker_id: 'w-1', item: { id: '1', files: [faylPervogo], tests: ['npm run test:tools'] }, branch: `night/${RID}/w-1`, base_branch: 'feat/night', db_name: 'db_1' },
|
||
{ worker_id: 'w-2', item: { id: '2', files: [faylVtorogo], tests: ['npm run test:tools'] }, branch: `night/${RID}/w-2`, base_branch: 'feat/night', db_name: 'db_2' },
|
||
];
|
||
}
|
||
|
||
// Подставной угол подаётся ЯВНО и только здесь: живой угол ставит зависимости минутами.
|
||
function ugolNaBumage({ workspaceRoot, dbName }) {
|
||
return { workspaceRoot, dbName, settingsPath: join(workspaceRoot, 'night-settings.json'), branch: null, creds: null };
|
||
}
|
||
|
||
function zavesti({ workers = dvoeRabotnikov(), createWorkspaceFn = ugolNaBumage, spawnSupervisorFn = () => ({ pid: 1 }) } = {}) {
|
||
return startAll({
|
||
runId: RID,
|
||
workers,
|
||
planPath: 'docs/superpowers/plans/пример.md',
|
||
baseBranch: 'feat/night',
|
||
workerModel: 'голова-из-настроек',
|
||
taskId: RID,
|
||
createWorkspaceFn,
|
||
spawnSupervisorFn,
|
||
});
|
||
}
|
||
|
||
describe('startAll занимает файлы пунктов за работниками (зовущий проверки 51)', () => {
|
||
afterEach(() => rmSync(runDir(RID), { recursive: true, force: true }));
|
||
|
||
it('файлы каждого пункта записаны за своим работником', () => {
|
||
zavesti();
|
||
const otkaz = mayTouch(runDir(RID), 'w-2', 'app/A.php');
|
||
expect(otkaz.ok).toBe(false);
|
||
expect(otkaz.clash_with).toBe('w-1');
|
||
expect(mayTouch(runDir(RID), 'w-1', 'app/A.php').ok).toBe(true);
|
||
});
|
||
|
||
it('занятия лежат в каталоге ПРОГОНА, а не в углу работника и не в общем корне', () => {
|
||
// Угол у каждого свой — в своём углу каждый занял бы всё. А в общем корне занятия
|
||
// пережили бы ночь, и назавтра ночь началась бы с чужих замков.
|
||
zavesti();
|
||
expect(existsSync(join(runDir(RID), 'file-claims.json'))).toBe(true);
|
||
});
|
||
|
||
it('файлы заняты ДО подъёма надзирателя, а не после', () => {
|
||
// 🔴 Опоздай занятие на минуту — двое заняли бы один файл: углы заводятся минутами,
|
||
// и первый надзиратель уже живёт, пока строится угол седьмого.
|
||
let vidnoVMigPodyoma = null;
|
||
zavesti({
|
||
spawnSupervisorFn: () => {
|
||
vidnoVMigPodyoma = mayTouch(runDir(RID), 'w-9', 'app/B.php').clash_with;
|
||
return { pid: 1 };
|
||
},
|
||
});
|
||
expect(vidnoVMigPodyoma).toBe('w-2');
|
||
});
|
||
|
||
it('два пункта на один файл — второй работник не заводится, первый работает', () => {
|
||
const started = zavesti({ workers: dvoeRabotnikov('app/A.php', 'app/A.php') });
|
||
expect(started.map(s => s.worker_id)).toEqual(['w-1']);
|
||
const karta = readJson(join(workerDir(RID, 'w-2'), 'worker.json'));
|
||
expect(karta.status).toBe('extinguished');
|
||
expect(karta.finish_reason).toContain('app/A.php');
|
||
const stroki = readLines(join(runDir(RID), 'summary-lines.jsonl')).map(r => r.line).join(' ');
|
||
expect(stroki).toContain('w-2');
|
||
});
|
||
|
||
it('🔴 не начавший работник ВЫЧЕРКНУТ из состава прогона', () => {
|
||
// 🔴 Состав из `run.json` поля `workers` — это тот самый список, по которому задача 7
|
||
// решает, все ли исполнили общую команду «стоп всё». Оставь мы здесь работника,
|
||
// которого не заводили вовсе, — отметку «исполнил» ставить некому, общий файл команды
|
||
// не уберётся НИКОГДА, и следующая ночь начнётся с чужого «стоп всё». То же и с решением
|
||
// «прогон окончен, когда погас последний».
|
||
zavesti({ workers: dvoeRabotnikov('app/A.php', 'app/A.php') });
|
||
const karta = readJson(join(runDir(RID), 'run.json'));
|
||
expect(karta.workers).toEqual(['w-1']);
|
||
});
|
||
|
||
it('🔴 СВЕРХ ПЛАНА: пункт БЕЗ файлов — работник заводится, и владельцу не врут про два пункта на один файл', () => {
|
||
// 🔴 Сторож на КЛАСС, а не на место: «замок принял „занимать нечего" за „занято чужим"».
|
||
// `claimFiles` отказывает двумя РАЗНЫМИ отказами, и свалить их в один — значит погасить
|
||
// работника с причиной, которой не было. План куска 3 ровно это и предлагал; три
|
||
// проверки куска 1 покраснели и оказались правы. Сведи эти два отказа обратно в один —
|
||
// покраснеет здесь, а не через месяц в живой ночи.
|
||
const bezFajlov = [
|
||
{ worker_id: 'w-1', item: { id: '1', files: [], tests: ['npm run test:tools'] }, branch: `night/${RID}/w-1`, base_branch: 'feat/night', db_name: 'db_1' },
|
||
];
|
||
const started = zavesti({ workers: bezFajlov });
|
||
expect(started.map(s => s.worker_id)).toEqual(['w-1']);
|
||
expect(readJson(join(runDir(RID), 'run.json')).workers).toEqual(['w-1']);
|
||
const stroki = readLines(join(runDir(RID), 'summary-lines.jsonl')).map(r => r.line).join(' ');
|
||
// Тихо пропустить такой пункт тоже нельзя: владелец обязан узнать, что этот работник
|
||
// ничем не заперт. Но сказано ему ПРАВДОЙ, а не выдуманным пересечением.
|
||
expect(stroki).toContain('не назван ни один файл');
|
||
expect(stroki).not.toContain('один и тот же файл');
|
||
});
|
||
|
||
it('угол не завёлся — занятые за ним файлы освобождены', () => {
|
||
// Иначе замок держал бы файл всю ночь за работника, которого нет вовсе, и никто
|
||
// другой не смог бы взять этот пункт.
|
||
zavesti({
|
||
createWorkspaceFn: ({ workspaceRoot, dbName, workerId }) => {
|
||
if (workerId === 'w-2') throw new Error('composer в углу не собрался');
|
||
return ugolNaBumage({ workspaceRoot, dbName });
|
||
},
|
||
});
|
||
expect(mayTouch(runDir(RID), 'w-9', 'app/B.php').ok).toBe(true);
|
||
// А файл живого работника остался занятым.
|
||
expect(mayTouch(runDir(RID), 'w-9', 'app/A.php').ok).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ============ беда 3: прогон без единого работника держал место навсегда ============
|
||
//
|
||
// 🔴 Найдено воротами ЖИВЬЁМ: «Прогон заведён. Работников: 0» — и запись «идёт», навсегда
|
||
// занимающая одно из двух мест в потолке задач. Признак «идёт» ставится ДО того, как
|
||
// заведутся углы, а признак «кончился» ставит надзиратель ПОСЛЕДНЕГО погасшего работника
|
||
// (`amILast` отвечает «нет», пока карточек ноль). Работников ноль — ставить некому.
|
||
// Два таких призрака запирают всё хозяйство наглухо, и чинится это только руками.
|
||
// 🔴 Хвост неповторим у каждого запуска прибора — по той же причине, что и у `RID` выше.
|
||
const RID_PUSTO = `r-progon-bez-rabotnikov-${randomUUID().slice(0, 8)}`;
|
||
|
||
describe('прогон, в котором не завёлся НИ ОДИН работник, не остаётся «идущим» навсегда', () => {
|
||
let vremennyj;
|
||
afterEach(() => {
|
||
rmSync(runDir(RID_PUSTO), { recursive: true, force: true });
|
||
// 🔴🔴 УБОРКА СЛЕДА ОТЛОЖЕННОЙ ЗАДАЧИ — ДОПИСАНО КУСКОМ 4, беда была настоящая.
|
||
// Замерено: `writeRunOutcome` на пустом прогоне кладёт след отложенной задачи
|
||
// в ОБЩИЙ боевой `.night/deferred/`, а уборка сносила только каталог прогона. Следы
|
||
// копились от каждого запуска прибора (`r-progon-bez-rabotnikov-…json`, четыре штуки
|
||
// за вечер) и роняли ЧУЖУЮ проверку — сторож соседей «проверки не оставили в боевом
|
||
// каталоге следов». Поодиночке оба файла зелены, вместе — красный, и идут чинить
|
||
// исправное. Ровно тот класс, из-за которого заведён неповторимый хвост у номера прогона.
|
||
rmSync(join(deferredDir(), `${RID_PUSTO}.json`), { force: true });
|
||
if (vremennyj) rmSync(vremennyj, { recursive: true, force: true });
|
||
vremennyj = null;
|
||
});
|
||
|
||
// 🔴 Итог прогона пишет ОДНО место — `writeRunOutcome` надзирателя. Здесь подставляется
|
||
// ОНО ЖЕ, только `last-run.json` уводится во временный каталог: настоящий лежит в общем
|
||
// `.night/`, и проверка не имеет права его трогать. Второго способа закрыть прогон
|
||
// не заводим — иначе два правила на одно дело разъедутся молча.
|
||
function zavestiPusto({ workers, spawnSupervisorFn = () => ({ pid: 7 }) } = {}) {
|
||
vremennyj = mkdtempSync(join(tmpdir(), 'night-pusto-'));
|
||
const vidno = [];
|
||
const started = startAll({
|
||
runId: RID_PUSTO,
|
||
workers,
|
||
planPath: 'docs/superpowers/plans/пример.md',
|
||
baseBranch: 'feat/night',
|
||
workerModel: 'голова-из-настроек',
|
||
taskId: RID_PUSTO,
|
||
createWorkspaceFn: ({ workerId, workspaceRoot, dbName }) => {
|
||
if (workerId === 'w-9') return { workspaceRoot, dbName, settingsPath: 'нет.json' };
|
||
throw new Error('git worktree add: уже занято');
|
||
},
|
||
spawnSupervisorFn,
|
||
writeRunOutcomeFn: args => {
|
||
vidno.push(args);
|
||
return writeRunOutcome({ ...args, lastRunFile: join(vremennyj, 'last-run.json') });
|
||
},
|
||
});
|
||
return { started, vidno };
|
||
}
|
||
|
||
const dvoe = [
|
||
{ worker_id: 'w-1', item: { id: '1', files: ['app/P.php'], tests: ['t.mjs'] }, branch: `night/${RID_PUSTO}/w-1`, db_name: 'db_1' },
|
||
{ worker_id: 'w-2', item: { id: '2', files: ['app/Q.php'], tests: ['t.mjs'] }, branch: `night/${RID_PUSTO}/w-2`, db_name: 'db_2' },
|
||
];
|
||
|
||
it('🔴 не завёлся никто — прогон объявлен КОНЧИВШИМСЯ, а не «идёт»', () => {
|
||
const { started } = zavestiPusto({ workers: dvoe });
|
||
expect(started).toEqual([]);
|
||
const karta = readJson(join(runDir(RID_PUSTO), 'run.json'));
|
||
// Именно это поле читают ворота потолков: «идёт» здесь означает занятое место.
|
||
expect(karta.status).not.toBe('running');
|
||
expect(karta.status).toBe('finished');
|
||
expect(karta.finished_at).toBeTruthy();
|
||
});
|
||
|
||
it('🔴 закрывает прогон ТО ЖЕ место, что закрывает его после последнего погасшего', () => {
|
||
// Заведи мы здесь второе правило «прогон кончился» — оно разъехалось бы с первым молча.
|
||
const { vidno } = zavestiPusto({ workers: dvoe });
|
||
expect(vidno.length).toBe(1);
|
||
expect(vidno[0].runId).toBe(RID_PUSTO);
|
||
expect(vidno[0].runDirPath).toBe(runDir(RID_PUSTO));
|
||
});
|
||
|
||
it('владельцу сказано строкой сводки, что прогон кончился, не начавшись', () => {
|
||
zavestiPusto({ workers: dvoe });
|
||
const stroki = readLines(join(runDir(RID_PUSTO), 'summary-lines.jsonl')).map(r => r.line).join(' ');
|
||
expect(stroki).toMatch(/ни один работник|ни одного работника/i);
|
||
});
|
||
|
||
it('🔴 завёлся хоть один — прогон по-прежнему «идёт» (правило не глухое)', () => {
|
||
// Страховка. Закрывай мы прогон всегда — надзиратели живых работников нашли бы прогон
|
||
// уже кончившимся, и ночь умерла бы целиком при зелёных проверках выше.
|
||
const smeshanno = [
|
||
...dvoe,
|
||
{ worker_id: 'w-9', item: { id: '9', files: ['app/R.php'], tests: ['t.mjs'] }, branch: `night/${RID_PUSTO}/w-9`, db_name: 'db_9' },
|
||
];
|
||
const { started, vidno } = zavestiPusto({ workers: smeshanno });
|
||
expect(started.map(s => s.worker_id)).toEqual(['w-9']);
|
||
expect(readJson(join(runDir(RID_PUSTO), 'run.json')).status).toBe('running');
|
||
expect(vidno).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// ============ кусок 4: пункты плана доходят до прогона и до карточек ============
|
||
//
|
||
// 🔴 ПРО ИМЕНА ЧЕСТНО, И ЭТО ОТСТУПЛЕНИЕ ОТ ПЛАНА ЗАДАЧИ 6, НАЗВАННОЕ ВСЛУХ.
|
||
// План писал эти проверки прозвищами (`ubratSled`, `prochest`, `katalogProgona`,
|
||
// `katalogRabotnika`, `mozhnoLi`) и уверял, что они «уже ввезены». Их здесь НЕТ и не было:
|
||
// кусок 3 прозвища сознательно отверг (см. шапку файла) и ввёз приборы своими именами.
|
||
// Пиши я по плану — файл не разобрался бы вовсе. Поэтому ниже настоящие имена:
|
||
// `rmSync`, `readJson`, `runDir`, `workerDir`, `mayTouch`. Вторых ввозов не заведено ни одного.
|
||
//
|
||
// 🔴 Хвост неповторим у каждого запуска прибора — по той же причине, что у `RID` выше:
|
||
// соседний прибор проверок физически не должен дотянуться до этого каталога.
|
||
const RID4 = `r-punkty-plana-${randomUUID().slice(0, 8)}`;
|
||
|
||
const PUNKT_PHP = { id: '1', title: 'Первый', tests: ['app/tests/Feature/PervyyTest.php'], files: ['app/A.php'], long: false };
|
||
const PUNKT_MJS = { id: '2', title: 'Второй', tests: ['tools/night/vtoroy.test.mjs'], files: ['app/B.php'], long: false };
|
||
const PUNKT_BEZ_PROVEROK = { id: '3', title: 'Третий', tests: [], files: ['app/C.php'], long: false };
|
||
|
||
function rabotnik4(item, n) {
|
||
return {
|
||
worker_id: `w-${n}`, item,
|
||
branch: `night/${RID4}/w-${n}`, base_branch: 'feat/night', db_name: `db_${n}`,
|
||
};
|
||
}
|
||
|
||
function zavesti4({ workers, items = null, runId = RID4 } = {}) {
|
||
return startAll({
|
||
runId,
|
||
workers,
|
||
items,
|
||
planPath: 'docs/superpowers/plans/пример.md',
|
||
baseBranch: 'feat/night',
|
||
workerModel: 'голова-из-настроек',
|
||
taskId: runId,
|
||
testCommandFor: w => `проверки-пункта-${w.item?.id}`,
|
||
createWorkspaceFn: ugolNaBumage,
|
||
spawnSupervisorFn: () => ({ pid: 1 }),
|
||
});
|
||
}
|
||
|
||
describe('пункты плана доходят до карточки прогона и до карточек работников', () => {
|
||
afterEach(() => rmSync(runDir(RID4), { recursive: true, force: true }));
|
||
|
||
it('🔴 в карточке прогона лежат ВСЕ пункты плана, а не только пункты заведённых работников', () => {
|
||
// 🔴🔴 Находка куска 4. Поле `items` в `run.json` — это ОЧЕРЕДЬ: из неё блок 5а берёт
|
||
// работнику следующий пункт. Клади туда только пункты семерых — пункты с восьмого
|
||
// не достались бы никому, и ночь кончилась бы на середине плана при зелёных проверках.
|
||
const vesPlan = [PUNKT_PHP, PUNKT_MJS, { id: '8', title: 'Восьмой', tests: ['t.test.mjs'], files: ['app/H.php'], long: false }];
|
||
zavesti4({ workers: [rabotnik4(PUNKT_PHP, 1), rabotnik4(PUNKT_MJS, 2)], items: vesPlan });
|
||
const karta = readJson(join(runDir(RID4), 'run.json'));
|
||
expect(karta.items.map(i => i.id)).toEqual(['1', '2', '8']);
|
||
});
|
||
|
||
it('пункт в карточке прогона несёт и заголовок, и проверки, и пометку «длинный»', () => {
|
||
// Без `tests` закрывать пункт нечем (проверка 39), без `long` — план не проверить (41).
|
||
zavesti4({ workers: [rabotnik4(PUNKT_PHP, 1)], items: [{ ...PUNKT_PHP, long: true }] });
|
||
const punkt = readJson(join(runDir(RID4), 'run.json')).items[0];
|
||
expect(punkt.title).toBe('Первый');
|
||
expect(punkt.tests).toEqual(['app/tests/Feature/PervyyTest.php']);
|
||
expect(punkt.files).toEqual(['app/A.php']);
|
||
expect(punkt.long).toBe(true);
|
||
});
|
||
|
||
it('пунктов плана не дали — в карточке прогона пункты работников, как прежде', () => {
|
||
// Умолчание честное: выдуманных пунктов здесь нет ни одного.
|
||
zavesti4({ workers: [rabotnik4(PUNKT_PHP, 1)] });
|
||
expect(readJson(join(runDir(RID4), 'run.json')).items.map(i => i.id)).toEqual(['1']);
|
||
});
|
||
|
||
it('🔴 в карточку работника лёг `test_command` — иначе замку нечем мерить', () => {
|
||
// 🔴🔴 Шов с задачей 5. Надзиратель берёт команду проверок ИЗ КАРТОЧКИ и подаёт замку.
|
||
// Не положи её здесь — замок каждую заявку отвечал бы «замерить не удалось», ни один
|
||
// пункт не засчитался бы ни одной ночью, а все проверки остались бы зелёными.
|
||
zavesti4({ workers: [rabotnik4(PUNKT_PHP, 1)] });
|
||
const karta = readJson(join(workerDir(RID4, 'w-1'), 'worker.json'));
|
||
expect(karta.test_command).toBe('проверки-пункта-1');
|
||
});
|
||
|
||
it('🔴 пункт без единой проверки — работник НЕ заводится и вычеркнут из состава (проверка 39)', () => {
|
||
// 🔴 Правило куска 1 на таком пункте бросает ошибку прямо в поручении — и убило бы
|
||
// ВЕСЬ прогон вместе с исправными работниками. Поэтому такой пункт отсеивается здесь,
|
||
// а владельцу про него говорят строкой.
|
||
const started = zavesti4({ workers: [rabotnik4(PUNKT_PHP, 1), rabotnik4(PUNKT_BEZ_PROVEROK, 3)] });
|
||
expect(started.map(s => s.worker_id)).toEqual(['w-1']);
|
||
const karta = readJson(join(workerDir(RID4, 'w-3'), 'worker.json'));
|
||
expect(karta.status).toBe('extinguished');
|
||
expect(karta.finish_reason).toMatch(/ни одной проверки/i);
|
||
expect(readJson(join(runDir(RID4), 'run.json')).workers).toEqual(['w-1']);
|
||
});
|
||
|
||
it('пункт без проверок никого больше не задел — исправный работник заведён и файлы за ним', () => {
|
||
// 🔴 Страховка от отсева, который валит всех подряд: он зеленил бы проверку выше
|
||
// и убивал бы каждую ночь целиком.
|
||
zavesti4({ workers: [rabotnik4(PUNKT_PHP, 1), rabotnik4(PUNKT_BEZ_PROVEROK, 3)] });
|
||
expect(mayTouch(runDir(RID4), 'w-9', 'app/A.php').ok).toBe(false);
|
||
// А файлы отсеянного никем не заняты — иначе его пункт не смог бы взять никто.
|
||
expect(mayTouch(runDir(RID4), 'w-9', 'app/C.php').ok).toBe(true);
|
||
});
|
||
|
||
it('🔴 вернувшуюся задачу делает НОВЫЙ работник — свой угол и своя сессия (проверка 29г)', () => {
|
||
// 🔴🔴 Дословное требование проверки 29: задача, не прошедшая приёмку, возвращается
|
||
// и делает её НОВЫЙ работник. Прежний погас вместе с концом задачи, и поднимать его
|
||
// тем же углом нельзя: в углу лежит ровно та работа, которую владелец забраковал.
|
||
// Сверяем номера и углы ДО и ПОСЛЕ — одинаковыми они быть не должны.
|
||
const pervyy = zavesti4({ workers: [rabotnik4(PUNKT_PHP, 1)] });
|
||
const RID5 = `r-vozvrat-zadachi-${randomUUID().slice(0, 8)}`;
|
||
try {
|
||
const vtoroy = zavesti4({
|
||
runId: RID5,
|
||
workers: [{ ...rabotnik4(PUNKT_PHP, 1), branch: `night/${RID5}/w-1` }],
|
||
items: [PUNKT_PHP],
|
||
});
|
||
expect(vtoroy[0].workspace).not.toBe(pervyy[0].workspace);
|
||
expect(vtoroy[0].session_id).not.toBe(pervyy[0].session_id);
|
||
expect(vtoroy[0].branch).not.toBe(pervyy[0].branch);
|
||
} finally {
|
||
rmSync(runDir(RID5), { recursive: true, force: true });
|
||
}
|
||
});
|
||
});
|