Files
houseplan-card/test/golden-capture-provenance.test.mjs
T
Claude fa91f3a731 test: temp dirs are removed even when a test fails; lint guards it (#646)
golden-capture-provenance accept() left a ~20 MB baselines sandbox in
TMPDIR on every call (433 copies, 8.5 GB, ENOSPC in the shared sandbox).
It now reads what it needs and removes the sandbox in finally; fixtures
are removed via t.after. Same fix for the three other leaking sites
(bundle-assets hp-tree-, docs-accept hp-identical-, rebase-generated
hp-runner- outside finally).

test/temp-dir-hygiene.test.mjs parses test/**/*.mjs with the TypeScript
AST: every mkdtemp/mkdtempSync must be bound to a name removed by
rm*/rmSync in a finally block or an after/afterEach/t.after hook of the
same function, or returned by a named helper whose every call site does
so; exception `// tmp-ok: <reason>`. Two mutants witness the lint.

Issue: #646
User-Visible: no
2026-09-25 03:50:40 +03:00

208 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { cpSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
CAPTURE_PROVENANCE_SCHEMA, captureProvenance, reportCaptureProvenance,
} from '../scripts/capture-environment.mjs';
import { GOLDEN_INDEX_SCHEMA, indexCapturedOn } from '../demo/golden/policy.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from '../demo/golden/matrix.mjs';
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
// #571. Отчёт съёмки не нёс платформу, и приёмщик записывал в индекс СВОЮ:
// артефакт Linux-прогона 34853080375, принятый на Windows, дал на `ad4000f9`
// запись `"platform": "win32"` у кадров, которых Windows не снимала. Причина
// осознанного обхода при этом жила только в stdout.
//
// Проверки гоняют НАСТОЯЩИЙ `accept.mjs` в отдельном каталоге эталонов: писать
// в рабочий репозиторий тест не имеет права, а подменять модуль — значит
// проверять не то, что исполняется на приёмке.
const ROOT = resolve(fileURLToPath(new URL('../', import.meta.url)));
const BASELINES = resolve(ROOT, 'demo/golden/baselines');
/** Артефакт съёмки: кадры равны принятым эталонам, отчёт — с нужным провенансом. */
function fixture({
platform = 'linux', schema = CAPTURE_PROVENANCE_SCHEMA, capture = undefined, captureEnv = undefined,
} = {}) {
const dir = mkdtempSync(resolve(tmpdir(), 'hp-golden-571-'));
mkdirSync(resolve(dir, 'actual'), { recursive: true });
const index = JSON.parse(readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8'));
// #350/#593: сцена, добавленная в матрицу, живёт без эталона до первой
// приёмки. Настоящий прогон отчитывается по ней `missing-baseline`, и
// фикстура обязана моделировать это же, иначе `accept.mjs` справедливо
// ругается на неполный отчёт.
const results = GOLDEN_SCENARIOS.map((scenario) => {
const source = resolve(BASELINES, `${scenario.id}.png`);
const actual = resolve(dir, 'actual', `${scenario.id}.png`);
if (!existsSync(source)) {
// Кадр у новой сцены есть (его и смотрит ревьюер), эталона ещё нет.
// Байты берём у любой принятой сцены: провенанс не о содержимом кадра.
copyFileSync(resolve(BASELINES, `${GOLDEN_SCENARIOS[0].id}.png`), actual);
return {
id: scenario.id,
status: 'missing-baseline',
actualSha256: createHash('sha256').update(readFileSync(actual)).digest('hex'),
baselineSha256: null,
diffRatio: null,
};
}
copyFileSync(source, actual);
const sha = createHash('sha256').update(readFileSync(actual)).digest('hex');
return { id: scenario.id, status: 'passed', actualSha256: sha, baselineSha256: sha, diffRatio: 0 };
});
const report = {
schema,
mode: 'verify',
generatedAt: new Date().toISOString(),
matrixVersion: GOLDEN_MATRIX_VERSION,
buildFingerprint: sourceFingerprint(ROOT),
chromium: index.chromium,
results,
};
if (schema >= CAPTURE_PROVENANCE_SCHEMA) {
report.capture = capture === undefined
? { ...captureProvenance({
chromium: index.chromium,
buildFingerprint: report.buildFingerprint,
env: captureEnv ?? {
GITHUB_RUN_ID: '1', GITHUB_RUN_ATTEMPT: '1',
GITHUB_REPOSITORY: 'Matysh/houseplan-card', GITHUB_SHA: 'a'.repeat(40),
},
}), platform }
: capture;
}
writeFileSync(resolve(dir, 'golden-report.json'), `${JSON.stringify(report, null, 2)}\n`);
return dir;
}
/** Приёмка в изолированный каталог эталонов: рабочий репозиторий не трогаем. */
const HOST_TEST_ALLOWANCE = process.platform === 'linux'
? ''
: '#576: unit-тест приёмки из закреплённого Windows toolchain';
function accept(from, { reason = HOST_TEST_ALLOWANCE, expectFailure = false } = {}) {
// #646: каталог эталонов ≈ 20 МБ, и прежде он оставался в TMPDIR после
// каждого вызова — 433 копии за серию прогонов, 8,5 ГБ, ENOSPC. Всё, что
// тесту нужно из песочницы, читается здесь; наружу она не отдаётся.
const sandbox = mkdtempSync(resolve(tmpdir(), 'hp-golden-571-baselines-'));
try {
cpSync(BASELINES, sandbox, { recursive: true });
const env = { ...process.env };
if (reason) env.HP_ALLOW_FOREIGN_CAPTURE = reason; else delete env.HP_ALLOW_FOREIGN_CAPTURE;
try {
// Сцены без эталона объявляются явно: `--expect-new` — это утверждение
// «я посмотрел на новый кадр», и обойти его фикстура не должна.
const fresh = GOLDEN_SCENARIOS
.filter((scenario) => !existsSync(resolve(BASELINES, `${scenario.id}.png`)))
.map((scenario) => scenario.id);
const stdout = execFileSync(process.execPath,
[resolve(ROOT, 'demo/golden/accept.mjs'), '--reviewed', `--from=${from}`, `--baselines=${sandbox}`,
...(fresh.length ? [`--expect-new=${fresh.join(',')}`] : [])],
{ cwd: ROOT, env, encoding: 'utf8' });
if (expectFailure) throw new Error('приёмка обязана была отказать, а прошла');
return { stdout, index: JSON.parse(readFileSync(resolve(sandbox, 'baselines-index.json'), 'utf8')) };
} catch (error) {
if (!expectFailure) throw error;
return {
error: String(error.stderr || error.message),
index: JSON.parse(readFileSync(resolve(sandbox, 'baselines-index.json'), 'utf8')),
};
}
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
}
/** Артефакт съёмки, который убирается по завершении теста, даже упавшего (#646). */
function artifact(t, options) {
const dir = fixture(options);
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
test('#571 AC1: артефакт Linux принимается, обе стороны провенанса записаны', (t) => {
const from = artifact(t, { platform: 'linux' });
const { index } = accept(from);
assert.equal(index.schema, GOLDEN_INDEX_SCHEMA);
assert.equal(index.capturedOn, 'linux', 'платформа КАДРОВ — из отчёта');
assert.equal(index.acceptedOn, process.platform, 'платформа приёмки — своя');
assert.equal(indexCapturedOn(index), 'linux');
assert.equal(index.capture.chromium, index.chromium);
assert.equal(index.localAttestation, null, 'CI source is not presented as local WSL');
assert.deepEqual(index.foreignCapture, HOST_TEST_ALLOWANCE ? { reason: HOST_TEST_ALLOWANCE } : null,
'не-Linux хост теста оставляет явный след осознанного обхода');
});
test('#641: локальная Linux-съёмка без CI и WSL-аттестации отвергается до записи', {
skip: process.platform !== 'linux' && 'целевой guard исполняется в канонической Linux-среде приёмки',
}, (t) => {
const from = artifact(t, { platform: 'linux', captureEnv: {} });
assert.equal(existsSync(resolve(from, 'wsl-attestation.json')), false,
'обычный golden:capture не должен неявно получать WSL-аттестацию');
const before = JSON.parse(readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8'));
const { error, index } = accept(from, { reason: '', expectFailure: true });
assert.match(error, /локальная Linux-съёмка не аттестована/);
assert.deepEqual(index, before, 'отказ обязан произойти до изменения эталонов и индекса');
});
test('#571 AC2: чужая среда съёмки без причины — отказ до записи', (t) => {
const from = artifact(t, { platform: 'win32' });
const before = readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8');
const { error, index } = accept(from, { reason: '', expectFailure: true });
assert.match(error, /приёмка отказана/);
assert.match(error, /win32/);
assert.equal(index.capturedOn ?? null, JSON.parse(before).capturedOn ?? null,
'индекс обязан остаться нетронутым: отказ до записи');
});
test('#571 AC1: чужая среда съёмки с причиной — причина уезжает в индекс', (t) => {
const from = artifact(t, { platform: 'win32' });
const reason = 'аудит #571: проверяю ветку осознанного обхода';
const { index, stdout } = accept(from, { reason });
assert.equal(index.capturedOn, 'win32');
assert.equal(index.acceptedOn, process.platform);
assert.deepEqual(index.foreignCapture, { reason });
assert.match(stdout, /Чужая среда разрешена осознанно/);
});
test('#571 схема 2 fail-closed: раздел capture обязателен', (t) => {
assert.throws(() => reportCaptureProvenance({ schema: 2 }), /обязан нести раздел capture/);
assert.throws(() => reportCaptureProvenance({ schema: 2, capture: { arch: 'x64' } }), /не называет платформу/);
const from = artifact(t, { capture: { arch: 'x64', chromium: '1' } });
const { error } = accept(from, { expectFailure: true });
assert.match(error, /не называет платформу съёмки/);
});
test('#571 старая схема — отдельная явная ветка, а не подстановка своей платформы', (t) => {
assert.deepEqual(reportCaptureProvenance({ schema: 1 }), { provenance: null, legacy: true });
const from = artifact(t, { schema: 1 });
const { index, stdout } = accept(from);
assert.equal(index.capturedOn, null, 'выдумывать платформу кадров нельзя');
assert.equal(index.acceptedOn, process.platform);
assert.match(stdout, /Отчёт старой схемы/);
});
test('#571 индекс любой схемы читается одним правилом', () => {
assert.equal(indexCapturedOn({ schema: 1, platform: 'linux' }), 'linux', 'схема 1: platform — это кадры');
assert.equal(indexCapturedOn({ schema: 2, capturedOn: 'linux', acceptedOn: 'win32' }), 'linux');
assert.equal(indexCapturedOn({ schema: 2, acceptedOn: 'win32' }), null, 'схема 2 без capturedOn ничего не обещает');
assert.equal(indexCapturedOn(null), null);
});
test('#571 подмена PNG и неполный артефакт по-прежнему fail-closed', (t) => {
const tampered = artifact(t);
const victim = resolve(tampered, 'actual', `${GOLDEN_SCENARIOS[0].id}.png`);
writeFileSync(victim, Buffer.concat([readFileSync(victim), Buffer.from([0])]));
assert.match(accept(tampered, { expectFailure: true }).error, /candidate changed after capture/);
const incomplete = artifact(t);
rmSync(resolve(incomplete, 'actual', `${GOLDEN_SCENARIOS[1].id}.png`));
assert.match(accept(incomplete, { expectFailure: true }).error, /review candidate missing/);
});