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 } = {}) { 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: {} }), 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 } = {}) { const sandbox = mkdtempSync(resolve(tmpdir(), 'hp-golden-571-baselines-')); 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')), sandbox }; } catch (error) { if (!expectFailure) throw error; return { error: String(error.stderr || error.message), index: JSON.parse(readFileSync(resolve(sandbox, 'baselines-index.json'), 'utf8')), sandbox, }; } } test('#571 AC1: артефакт Linux принимается, обе стороны провенанса записаны', () => { const from = fixture({ 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.deepEqual(index.foreignCapture, HOST_TEST_ALLOWANCE ? { reason: HOST_TEST_ALLOWANCE } : null, 'не-Linux хост теста оставляет явный след осознанного обхода'); rmSync(from, { recursive: true, force: true }); }); test('#571 AC2: чужая среда съёмки без причины — отказ до записи', () => { const from = fixture({ 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, 'индекс обязан остаться нетронутым: отказ до записи'); rmSync(from, { recursive: true, force: true }); }); test('#571 AC1: чужая среда съёмки с причиной — причина уезжает в индекс', () => { const from = fixture({ 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, /Чужая среда разрешена осознанно/); rmSync(from, { recursive: true, force: true }); }); test('#571 схема 2 fail-closed: раздел capture обязателен', () => { assert.throws(() => reportCaptureProvenance({ schema: 2 }), /обязан нести раздел capture/); assert.throws(() => reportCaptureProvenance({ schema: 2, capture: { arch: 'x64' } }), /не называет платформу/); const from = fixture({ capture: { arch: 'x64', chromium: '1' } }); const { error } = accept(from, { expectFailure: true }); assert.match(error, /не называет платформу съёмки/); rmSync(from, { recursive: true, force: true }); }); test('#571 старая схема — отдельная явная ветка, а не подстановка своей платформы', () => { assert.deepEqual(reportCaptureProvenance({ schema: 1 }), { provenance: null, legacy: true }); const from = fixture({ schema: 1 }); const { index, stdout } = accept(from); assert.equal(index.capturedOn, null, 'выдумывать платформу кадров нельзя'); assert.equal(index.acceptedOn, process.platform); assert.match(stdout, /Отчёт старой схемы/); rmSync(from, { recursive: true, force: true }); }); 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', () => { const tampered = fixture(); 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/); rmSync(tampered, { recursive: true, force: true }); const incomplete = fixture(); rmSync(resolve(incomplete, 'actual', `${GOLDEN_SCENARIOS[1].id}.png`)); assert.match(accept(incomplete, { expectFailure: true }).error, /review candidate missing/); rmSync(incomplete, { recursive: true, force: true }); });