mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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
This commit is contained in:
@@ -2988,6 +2988,28 @@ const MUTANT_DEFINITIONS = [
|
|||||||
replace: ' pass',
|
replace: ' pass',
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'golden-accept-sandbox-leaks',
|
||||||
|
guard: 'node --test test/temp-dir-hygiene.test.mjs',
|
||||||
|
because: 'the ~20 MB baselines sandbox of accept() outliving every call is what filled the '
|
||||||
|
+ 'shared TMPDIR to 8.5 GB and ENOSPC (#646 AC2)',
|
||||||
|
patches: [{
|
||||||
|
file: 'test/golden-capture-provenance.test.mjs',
|
||||||
|
find: ' rmSync(sandbox, { recursive: true, force: true });',
|
||||||
|
replace: ' // sandbox is left for inspection',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'temp-hygiene-accepts-cleanup-outside-finally',
|
||||||
|
guard: 'node --test test/temp-dir-hygiene.test.mjs',
|
||||||
|
because: 'a trailing rmSync after the asserts does not run when an assert fails, so a red '
|
||||||
|
+ 'test still leaks; the lint must not count it as cleanup (#646 AC2)',
|
||||||
|
patches: [{
|
||||||
|
file: 'test/temp-dir-hygiene.test.mjs',
|
||||||
|
find: 'function inCleanupPosition(node, scope) {',
|
||||||
|
replace: 'function inCleanupPosition(node, scope) {\n if (node) return true;',
|
||||||
|
}],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'ha-harness-notice-silent',
|
id: 'ha-harness-notice-silent',
|
||||||
guard: 'python3 -m pytest tests_backend/test_conftest_harness_notice.py -q -p no:cacheprovider',
|
guard: 'python3 -m pytest tests_backend/test_conftest_harness_notice.py -q -p no:cacheprovider',
|
||||||
|
|||||||
@@ -709,12 +709,13 @@ test('#535 no built entry reaches the card by an address without a version', ()
|
|||||||
'сам фасад остаётся стабильным входом Lovelace-ресурса и тянет хешированный чанк');
|
'сам фасад остаётся стабильным входом Lovelace-ресурса и тянет хешированный чанк');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('bundle tree verification rejects orphan chunks (#353 AC4)', async () => {
|
test('bundle tree verification rejects orphan chunks (#353 AC4)', async (t) => {
|
||||||
const { mkdtempSync, writeFileSync, mkdirSync } = await import('node:fs');
|
const { mkdtempSync, writeFileSync, mkdirSync } = await import('node:fs');
|
||||||
const { tmpdir } = await import('node:os');
|
const { tmpdir } = await import('node:os');
|
||||||
const { join } = await import('node:path');
|
const { join } = await import('node:path');
|
||||||
const { verifyBundleTree, sha256Bytes } = await import('../scripts/bundle-tree.mjs');
|
const { verifyBundleTree, sha256Bytes } = await import('../scripts/bundle-tree.mjs');
|
||||||
const root = mkdtempSync(join(tmpdir(), 'hp-tree-'));
|
const root = mkdtempSync(join(tmpdir(), 'hp-tree-'));
|
||||||
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||||
mkdirSync(join(root, 'houseplan-assets'));
|
mkdirSync(join(root, 'houseplan-assets'));
|
||||||
const entryCode = 'try{await import("./houseplan-assets/main-abc.js")}catch(e){}';
|
const entryCode = 'try{await import("./houseplan-assets/main-abc.js")}catch(e){}';
|
||||||
const panelCode = 'try{await import("./houseplan-card.js")}catch(e){}';
|
const panelCode = 'try{await import("./houseplan-card.js")}catch(e){}';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import test from 'node:test';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { acceptIdentical, acceptedDocsManifest, identicalDecision, identicalDocsManifest, verifyDocsCandidate } from '../scripts/docs-accept.mjs';
|
import { acceptIdentical, acceptedDocsManifest, identicalDecision, identicalDocsManifest, verifyDocsCandidate } from '../scripts/docs-accept.mjs';
|
||||||
@@ -213,8 +213,9 @@ test('#455 принятый манифест несёт среду приёмк
|
|||||||
|
|
||||||
// ---------- #512: --identical ----------
|
// ---------- #512: --identical ----------
|
||||||
|
|
||||||
const identicalRoot = (frames) => {
|
const identicalRoot = (t, frames) => {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'hp-identical-'));
|
const root = mkdtempSync(join(tmpdir(), 'hp-identical-'));
|
||||||
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||||
mkdirSync(join(root, 'docs', 'images'), { recursive: true });
|
mkdirSync(join(root, 'docs', 'images'), { recursive: true });
|
||||||
const scenarios = {};
|
const scenarios = {};
|
||||||
for (const scenario of DOC_SCREENSHOTS) {
|
for (const scenario of DOC_SCREENSHOTS) {
|
||||||
@@ -241,8 +242,8 @@ const identicalRoot = (frames) => {
|
|||||||
return { root, manifest, capture, compare };
|
return { root, manifest, capture, compare };
|
||||||
};
|
};
|
||||||
|
|
||||||
test('#512 AC3: identical frames accept only the source fingerprint; bytes and provenance stay committed', async () => {
|
test('#512 AC3: identical frames accept only the source fingerprint; bytes and provenance stay committed', async (t) => {
|
||||||
const { root, manifest, capture, compare } = identicalRoot({});
|
const { root, manifest, capture, compare } = identicalRoot(t, {});
|
||||||
const log = [];
|
const log = [];
|
||||||
const code = await acceptIdentical({ root, capture, compare, log: (line) => log.push(line) });
|
const code = await acceptIdentical({ root, capture, compare, log: (line) => log.push(line) });
|
||||||
assert.equal(code, 0);
|
assert.equal(code, 0);
|
||||||
@@ -259,8 +260,8 @@ test('#512 AC3: identical frames accept only the source fingerprint; bytes and p
|
|||||||
assert.match(log[0], /попиксельно совпали/);
|
assert.match(log[0], /попиксельно совпали/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('#512 AC3: one differing pixel refuses, names the frame and leaves everything committed as it was', async () => {
|
test('#512 AC3: one differing pixel refuses, names the frame and leaves everything committed as it was', async (t) => {
|
||||||
const { root, manifest, capture, compare } = identicalRoot({ 'view-desktop': { identical: false, differing: 3, sizeMismatch: false } });
|
const { root, manifest, capture, compare } = identicalRoot(t, { 'view-desktop': { identical: false, differing: 3, sizeMismatch: false } });
|
||||||
const log = [];
|
const log = [];
|
||||||
const code = await acceptIdentical({ root, capture, compare, log: (line) => log.push(line) });
|
const code = await acceptIdentical({ root, capture, compare, log: (line) => log.push(line) });
|
||||||
assert.equal(code, 1);
|
assert.equal(code, 1);
|
||||||
|
|||||||
@@ -87,7 +87,11 @@ const HOST_TEST_ALLOWANCE = process.platform === 'linux'
|
|||||||
: '#576: unit-тест приёмки из закреплённого Windows toolchain';
|
: '#576: unit-тест приёмки из закреплённого Windows toolchain';
|
||||||
|
|
||||||
function accept(from, { reason = HOST_TEST_ALLOWANCE, expectFailure = false } = {}) {
|
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-'));
|
const sandbox = mkdtempSync(resolve(tmpdir(), 'hp-golden-571-baselines-'));
|
||||||
|
try {
|
||||||
cpSync(BASELINES, sandbox, { recursive: true });
|
cpSync(BASELINES, sandbox, { recursive: true });
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
if (reason) env.HP_ALLOW_FOREIGN_CAPTURE = reason; else delete env.HP_ALLOW_FOREIGN_CAPTURE;
|
if (reason) env.HP_ALLOW_FOREIGN_CAPTURE = reason; else delete env.HP_ALLOW_FOREIGN_CAPTURE;
|
||||||
@@ -102,19 +106,28 @@ function accept(from, { reason = HOST_TEST_ALLOWANCE, expectFailure = false } =
|
|||||||
...(fresh.length ? [`--expect-new=${fresh.join(',')}`] : [])],
|
...(fresh.length ? [`--expect-new=${fresh.join(',')}`] : [])],
|
||||||
{ cwd: ROOT, env, encoding: 'utf8' });
|
{ cwd: ROOT, env, encoding: 'utf8' });
|
||||||
if (expectFailure) throw new Error('приёмка обязана была отказать, а прошла');
|
if (expectFailure) throw new Error('приёмка обязана была отказать, а прошла');
|
||||||
return { stdout, index: JSON.parse(readFileSync(resolve(sandbox, 'baselines-index.json'), 'utf8')), sandbox };
|
return { stdout, index: JSON.parse(readFileSync(resolve(sandbox, 'baselines-index.json'), 'utf8')) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!expectFailure) throw error;
|
if (!expectFailure) throw error;
|
||||||
return {
|
return {
|
||||||
error: String(error.stderr || error.message),
|
error: String(error.stderr || error.message),
|
||||||
index: JSON.parse(readFileSync(resolve(sandbox, 'baselines-index.json'), 'utf8')),
|
index: JSON.parse(readFileSync(resolve(sandbox, 'baselines-index.json'), 'utf8')),
|
||||||
sandbox,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(sandbox, { recursive: true, force: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('#571 AC1: артефакт Linux принимается, обе стороны провенанса записаны', () => {
|
/** Артефакт съёмки, который убирается по завершении теста, даже упавшего (#646). */
|
||||||
const from = fixture({ platform: 'linux' });
|
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);
|
const { index } = accept(from);
|
||||||
assert.equal(index.schema, GOLDEN_INDEX_SCHEMA);
|
assert.equal(index.schema, GOLDEN_INDEX_SCHEMA);
|
||||||
assert.equal(index.capturedOn, 'linux', 'платформа КАДРОВ — из отчёта');
|
assert.equal(index.capturedOn, 'linux', 'платформа КАДРОВ — из отчёта');
|
||||||
@@ -124,61 +137,55 @@ test('#571 AC1: артефакт Linux принимается, обе сторо
|
|||||||
assert.equal(index.localAttestation, null, 'CI source is not presented as local WSL');
|
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,
|
assert.deepEqual(index.foreignCapture, HOST_TEST_ALLOWANCE ? { reason: HOST_TEST_ALLOWANCE } : null,
|
||||||
'не-Linux хост теста оставляет явный след осознанного обхода');
|
'не-Linux хост теста оставляет явный след осознанного обхода');
|
||||||
rmSync(from, { recursive: true, force: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('#641: локальная Linux-съёмка без CI и WSL-аттестации отвергается до записи', {
|
test('#641: локальная Linux-съёмка без CI и WSL-аттестации отвергается до записи', {
|
||||||
skip: process.platform !== 'linux' && 'целевой guard исполняется в канонической Linux-среде приёмки',
|
skip: process.platform !== 'linux' && 'целевой guard исполняется в канонической Linux-среде приёмки',
|
||||||
}, () => {
|
}, (t) => {
|
||||||
const from = fixture({ platform: 'linux', captureEnv: {} });
|
const from = artifact(t, { platform: 'linux', captureEnv: {} });
|
||||||
assert.equal(existsSync(resolve(from, 'wsl-attestation.json')), false,
|
assert.equal(existsSync(resolve(from, 'wsl-attestation.json')), false,
|
||||||
'обычный golden:capture не должен неявно получать WSL-аттестацию');
|
'обычный golden:capture не должен неявно получать WSL-аттестацию');
|
||||||
const before = JSON.parse(readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8'));
|
const before = JSON.parse(readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8'));
|
||||||
const { error, index } = accept(from, { reason: '', expectFailure: true });
|
const { error, index } = accept(from, { reason: '', expectFailure: true });
|
||||||
assert.match(error, /локальная Linux-съёмка не аттестована/);
|
assert.match(error, /локальная Linux-съёмка не аттестована/);
|
||||||
assert.deepEqual(index, before, 'отказ обязан произойти до изменения эталонов и индекса');
|
assert.deepEqual(index, before, 'отказ обязан произойти до изменения эталонов и индекса');
|
||||||
rmSync(from, { recursive: true, force: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('#571 AC2: чужая среда съёмки без причины — отказ до записи', () => {
|
test('#571 AC2: чужая среда съёмки без причины — отказ до записи', (t) => {
|
||||||
const from = fixture({ platform: 'win32' });
|
const from = artifact(t, { platform: 'win32' });
|
||||||
const before = readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8');
|
const before = readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8');
|
||||||
const { error, index } = accept(from, { reason: '', expectFailure: true });
|
const { error, index } = accept(from, { reason: '', expectFailure: true });
|
||||||
assert.match(error, /приёмка отказана/);
|
assert.match(error, /приёмка отказана/);
|
||||||
assert.match(error, /win32/);
|
assert.match(error, /win32/);
|
||||||
assert.equal(index.capturedOn ?? null, JSON.parse(before).capturedOn ?? null,
|
assert.equal(index.capturedOn ?? null, JSON.parse(before).capturedOn ?? null,
|
||||||
'индекс обязан остаться нетронутым: отказ до записи');
|
'индекс обязан остаться нетронутым: отказ до записи');
|
||||||
rmSync(from, { recursive: true, force: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('#571 AC1: чужая среда съёмки с причиной — причина уезжает в индекс', () => {
|
test('#571 AC1: чужая среда съёмки с причиной — причина уезжает в индекс', (t) => {
|
||||||
const from = fixture({ platform: 'win32' });
|
const from = artifact(t, { platform: 'win32' });
|
||||||
const reason = 'аудит #571: проверяю ветку осознанного обхода';
|
const reason = 'аудит #571: проверяю ветку осознанного обхода';
|
||||||
const { index, stdout } = accept(from, { reason });
|
const { index, stdout } = accept(from, { reason });
|
||||||
assert.equal(index.capturedOn, 'win32');
|
assert.equal(index.capturedOn, 'win32');
|
||||||
assert.equal(index.acceptedOn, process.platform);
|
assert.equal(index.acceptedOn, process.platform);
|
||||||
assert.deepEqual(index.foreignCapture, { reason });
|
assert.deepEqual(index.foreignCapture, { reason });
|
||||||
assert.match(stdout, /Чужая среда разрешена осознанно/);
|
assert.match(stdout, /Чужая среда разрешена осознанно/);
|
||||||
rmSync(from, { recursive: true, force: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('#571 схема 2 fail-closed: раздел capture обязателен', () => {
|
test('#571 схема 2 fail-closed: раздел capture обязателен', (t) => {
|
||||||
assert.throws(() => reportCaptureProvenance({ schema: 2 }), /обязан нести раздел capture/);
|
assert.throws(() => reportCaptureProvenance({ schema: 2 }), /обязан нести раздел capture/);
|
||||||
assert.throws(() => reportCaptureProvenance({ schema: 2, capture: { arch: 'x64' } }), /не называет платформу/);
|
assert.throws(() => reportCaptureProvenance({ schema: 2, capture: { arch: 'x64' } }), /не называет платформу/);
|
||||||
const from = fixture({ capture: { arch: 'x64', chromium: '1' } });
|
const from = artifact(t, { capture: { arch: 'x64', chromium: '1' } });
|
||||||
const { error } = accept(from, { expectFailure: true });
|
const { error } = accept(from, { expectFailure: true });
|
||||||
assert.match(error, /не называет платформу съёмки/);
|
assert.match(error, /не называет платформу съёмки/);
|
||||||
rmSync(from, { recursive: true, force: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('#571 старая схема — отдельная явная ветка, а не подстановка своей платформы', () => {
|
test('#571 старая схема — отдельная явная ветка, а не подстановка своей платформы', (t) => {
|
||||||
assert.deepEqual(reportCaptureProvenance({ schema: 1 }), { provenance: null, legacy: true });
|
assert.deepEqual(reportCaptureProvenance({ schema: 1 }), { provenance: null, legacy: true });
|
||||||
const from = fixture({ schema: 1 });
|
const from = artifact(t, { schema: 1 });
|
||||||
const { index, stdout } = accept(from);
|
const { index, stdout } = accept(from);
|
||||||
assert.equal(index.capturedOn, null, 'выдумывать платформу кадров нельзя');
|
assert.equal(index.capturedOn, null, 'выдумывать платформу кадров нельзя');
|
||||||
assert.equal(index.acceptedOn, process.platform);
|
assert.equal(index.acceptedOn, process.platform);
|
||||||
assert.match(stdout, /Отчёт старой схемы/);
|
assert.match(stdout, /Отчёт старой схемы/);
|
||||||
rmSync(from, { recursive: true, force: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('#571 индекс любой схемы читается одним правилом', () => {
|
test('#571 индекс любой схемы читается одним правилом', () => {
|
||||||
@@ -188,15 +195,13 @@ test('#571 индекс любой схемы читается одним пра
|
|||||||
assert.equal(indexCapturedOn(null), null);
|
assert.equal(indexCapturedOn(null), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('#571 подмена PNG и неполный артефакт по-прежнему fail-closed', () => {
|
test('#571 подмена PNG и неполный артефакт по-прежнему fail-closed', (t) => {
|
||||||
const tampered = fixture();
|
const tampered = artifact(t);
|
||||||
const victim = resolve(tampered, 'actual', `${GOLDEN_SCENARIOS[0].id}.png`);
|
const victim = resolve(tampered, 'actual', `${GOLDEN_SCENARIOS[0].id}.png`);
|
||||||
writeFileSync(victim, Buffer.concat([readFileSync(victim), Buffer.from([0])]));
|
writeFileSync(victim, Buffer.concat([readFileSync(victim), Buffer.from([0])]));
|
||||||
assert.match(accept(tampered, { expectFailure: true }).error, /candidate changed after capture/);
|
assert.match(accept(tampered, { expectFailure: true }).error, /candidate changed after capture/);
|
||||||
rmSync(tampered, { recursive: true, force: true });
|
|
||||||
|
|
||||||
const incomplete = fixture();
|
const incomplete = artifact(t);
|
||||||
rmSync(resolve(incomplete, 'actual', `${GOLDEN_SCENARIOS[1].id}.png`));
|
rmSync(resolve(incomplete, 'actual', `${GOLDEN_SCENARIOS[1].id}.png`));
|
||||||
assert.match(accept(incomplete, { expectFailure: true }).error, /review candidate missing/);
|
assert.match(accept(incomplete, { expectFailure: true }).error, /review candidate missing/);
|
||||||
rmSync(incomplete, { recursive: true, force: true });
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -238,15 +238,17 @@ function runStepRebase(work) {
|
|||||||
const to = body.indexOf('# #635 r2:');
|
const to = body.indexOf('# #635 r2:');
|
||||||
assert.ok(from >= 0 && to > from, 'ребейзная часть шага найдена');
|
assert.ok(from >= 0 && to > from, 'ребейзная часть шага найдена');
|
||||||
const temp = mkdtempSync(join(tmpdir(), 'hp-runner-'));
|
const temp = mkdtempSync(join(tmpdir(), 'hp-runner-'));
|
||||||
|
try {
|
||||||
const output = join(temp, 'output');
|
const output = join(temp, 'output');
|
||||||
writeFileSync(output, '');
|
writeFileSync(output, '');
|
||||||
const script = `${body.slice(from, to)}\necho REBASED\n`;
|
const script = `${body.slice(from, to)}\necho REBASED\n`;
|
||||||
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
|
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
|
||||||
cwd: work, encoding: 'utf8', env: { ...ENV, RUNNER_TEMP: temp, GITHUB_OUTPUT: output, BRANCH: 'issue/9-fix' },
|
cwd: work, encoding: 'utf8', env: { ...ENV, RUNNER_TEMP: temp, GITHUB_OUTPUT: output, BRANCH: 'issue/9-fix' },
|
||||||
});
|
});
|
||||||
const result = { status: r.status, stdout: r.stdout, stderr: r.stderr, output: readFileSync(output, 'utf8') };
|
return { status: r.status, stdout: r.stdout, stderr: r.stderr, output: readFileSync(output, 'utf8') };
|
||||||
|
} finally {
|
||||||
rmSync(temp, { recursive: true, force: true });
|
rmSync(temp, { recursive: true, force: true });
|
||||||
return result;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
|
const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { extname, join, relative } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
|
// #646. Тест, создавший временный каталог, обязан его убрать — даже когда
|
||||||
|
// падает. `golden-capture-provenance` оставлял по ≈20 МБ за прогон
|
||||||
|
// (`hp-golden-571-baselines-*`), и за сутки параллельной работы агентов общий
|
||||||
|
// TMPDIR вырос до 8,5 ГБ: ENOSPC, упавшая сборка и ложно красные юниты с
|
||||||
|
// временными git-репозиториями.
|
||||||
|
//
|
||||||
|
// Проверка статическая, по исходникам: рантайм видит утечку только на том
|
||||||
|
// пути, который исполнился, а исходник показывает намерение на всех путях.
|
||||||
|
//
|
||||||
|
// Правило. Каждый вызов `mkdtemp`/`mkdtempSync` в `test/**/*.mjs`:
|
||||||
|
// 1. связан с именем (`const dir = mkdtempSync(...)`) либо сразу возвращён;
|
||||||
|
// 2. это имя убирается вызовом `rm`/`rmSync`/`rmdirSync(<имя>, ...)`, стоящим
|
||||||
|
// в блоке `finally` или в колбэке `after`/`afterEach`/`t.after` внутри
|
||||||
|
// той же функции — очистка, которая не исполнится при упавшем assert, не
|
||||||
|
// считается;
|
||||||
|
// 3. либо функция — именованный помощник, который возвращает каталог (сам или
|
||||||
|
// полем объекта), и тогда правило 2 применяется к КАЖДОМУ месту его вызова
|
||||||
|
// (цепочка помощников — до MAX_DEPTH звеньев);
|
||||||
|
// 4. исключение — комментарий `// tmp-ok: <причина>` на строке вызова или на
|
||||||
|
// строке над ним. Причина обязательна: пустой `tmp-ok:` не принимается.
|
||||||
|
|
||||||
|
const ROOT = fileURLToPath(new URL('../', import.meta.url));
|
||||||
|
const TEST_DIR = join(ROOT, 'test');
|
||||||
|
const MAX_DEPTH = 3;
|
||||||
|
const CREATORS = new Set(['mkdtemp', 'mkdtempSync']);
|
||||||
|
const REMOVERS = new Set(['rm', 'rmSync', 'rmdirSync', 'rmdir']);
|
||||||
|
const HOOKS = new Set(['after', 'afterEach']);
|
||||||
|
|
||||||
|
const calleeName = (call) => {
|
||||||
|
const callee = call.expression;
|
||||||
|
if (ts.isIdentifier(callee)) return callee.text;
|
||||||
|
if (ts.isPropertyAccessExpression(callee)) return callee.name.text;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFunctionLike = (node) => ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)
|
||||||
|
|| ts.isArrowFunction(node) || ts.isMethodDeclaration(node);
|
||||||
|
|
||||||
|
const enclosingFunction = (node) => {
|
||||||
|
for (let cur = node.parent; cur; cur = cur.parent) if (isFunctionLike(cur)) return cur;
|
||||||
|
return node.getSourceFile();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Имя функции: объявление либо `const name = () => ...`. */
|
||||||
|
function functionName(fn) {
|
||||||
|
if (ts.isFunctionDeclaration(fn) && fn.name) return fn.name.text;
|
||||||
|
if (ts.isMethodDeclaration(fn) && ts.isIdentifier(fn.name)) return fn.name.text;
|
||||||
|
const parent = fn.parent;
|
||||||
|
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return parent.name.text;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Куда уходит значение выражения: `{ name }`, `{ returned: true }` или `null`. */
|
||||||
|
function binding(expr) {
|
||||||
|
let node = expr;
|
||||||
|
while (node.parent && (ts.isAwaitExpression(node.parent) || ts.isParenthesizedExpression(node.parent))) {
|
||||||
|
node = node.parent;
|
||||||
|
}
|
||||||
|
const parent = node.parent;
|
||||||
|
if (ts.isVariableDeclaration(parent) && parent.initializer === node) {
|
||||||
|
if (ts.isIdentifier(parent.name)) return { name: parent.name.text };
|
||||||
|
if (ts.isObjectBindingPattern(parent.name)) {
|
||||||
|
const fields = new Map();
|
||||||
|
for (const element of parent.name.elements) {
|
||||||
|
const key = element.propertyName ?? element.name;
|
||||||
|
if (ts.isIdentifier(key) && ts.isIdentifier(element.name)) fields.set(key.text, element.name.text);
|
||||||
|
}
|
||||||
|
return { fields };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (ts.isReturnStatement(parent)) return { returned: true };
|
||||||
|
if (ts.isArrowFunction(parent) && parent.body === node) return { returned: true };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Узел `node` лежит в `finally` либо в колбэке хука очистки, не выходя за `scope`. */
|
||||||
|
function inCleanupPosition(node, scope) {
|
||||||
|
for (let cur = node; cur && cur !== scope; cur = cur.parent) {
|
||||||
|
const parent = cur.parent;
|
||||||
|
if (!parent) break;
|
||||||
|
if (ts.isTryStatement(parent) && parent.finallyBlock === cur) return true;
|
||||||
|
if (isFunctionLike(cur) && ts.isCallExpression(parent) && parent.arguments.includes(cur)
|
||||||
|
&& HOOKS.has(calleeName(parent))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** В `scope` есть очистка каталога с именем `name` в безопасной позиции. */
|
||||||
|
function cleansUp(scope, name) {
|
||||||
|
let found = false;
|
||||||
|
const visit = (node) => {
|
||||||
|
if (found) return;
|
||||||
|
if (ts.isCallExpression(node) && REMOVERS.has(calleeName(node))) {
|
||||||
|
const [first] = node.arguments;
|
||||||
|
if (first && ts.isIdentifier(first) && first.text === name && inCleanupPosition(node, scope)) {
|
||||||
|
found = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
};
|
||||||
|
ts.forEachChild(scope, visit);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возвращает ли `fn` значение `name`: целиком (`''`) или полями объекта (их ключи). */
|
||||||
|
function returnedAs(fn, name) {
|
||||||
|
const keys = new Set();
|
||||||
|
const visit = (node) => {
|
||||||
|
if (node !== fn && isFunctionLike(node)) return;
|
||||||
|
const value = ts.isReturnStatement(node) ? node.expression
|
||||||
|
: (node === fn && ts.isArrowFunction(fn) && !ts.isBlock(fn.body) ? fn.body : null);
|
||||||
|
if (value) {
|
||||||
|
let expr = value;
|
||||||
|
while (ts.isParenthesizedExpression(expr)) expr = expr.expression;
|
||||||
|
if (ts.isIdentifier(expr) && expr.text === name) keys.add('');
|
||||||
|
if (ts.isObjectLiteralExpression(expr)) {
|
||||||
|
for (const prop of expr.properties) {
|
||||||
|
if (ts.isShorthandPropertyAssignment(prop) && prop.name.text === name) keys.add(prop.name.text);
|
||||||
|
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.initializer)
|
||||||
|
&& prop.initializer.text === name && ts.isIdentifier(prop.name)) keys.add(prop.name.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
};
|
||||||
|
visit(fn);
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exempt(lines, line) {
|
||||||
|
const reason = /\/\/\s*tmp-ok:\s*(\S.*)$/;
|
||||||
|
return reason.test(lines[line] ?? '') || reason.test(lines[line - 1] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нарушения в одном исходнике: `[{ line, message }]`, строки с 1.
|
||||||
|
* Экспортируется не наружу, а в самопроверку ниже: правило обязано краснеть
|
||||||
|
* на плохом примере, иначе зелёный результат ничего не доказывает.
|
||||||
|
*/
|
||||||
|
export function tempDirViolations(source, fileName = 'x.mjs') {
|
||||||
|
const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
||||||
|
const lines = source.split('\n');
|
||||||
|
const lineOf = (node) => file.getLineAndCharacterOfPosition(node.getStart(file)).line;
|
||||||
|
const calls = [];
|
||||||
|
const collect = (node) => {
|
||||||
|
if (ts.isCallExpression(node)) calls.push(node);
|
||||||
|
ts.forEachChild(node, collect);
|
||||||
|
};
|
||||||
|
collect(file);
|
||||||
|
|
||||||
|
const violations = [];
|
||||||
|
// Проверка значения `expr`, созданного на строке `origin`: убрано ли оно.
|
||||||
|
const check = (expr, depth, origin, path) => {
|
||||||
|
const bound = binding(expr);
|
||||||
|
const scope = enclosingFunction(expr);
|
||||||
|
const where = `${path} (строка ${lineOf(expr) + 1})`;
|
||||||
|
if (!bound) return [`${where}: каталог не связан с именем — убрать его нечем`];
|
||||||
|
if (bound.name && cleansUp(scope, bound.name)) return [];
|
||||||
|
const names = bound.name ? [bound.name] : bound.fields ? [...bound.fields.values()] : [];
|
||||||
|
if (names.some((name) => cleansUp(scope, name))) return [];
|
||||||
|
// Не убрано здесь — может быть, это помощник, отдающий каталог наружу.
|
||||||
|
const returnedKeys = bound.returned ? new Set(['']) : bound.name ? returnedAs(scope, bound.name) : new Set();
|
||||||
|
if (!returnedKeys.size) {
|
||||||
|
return [`${where}: нет rm*/rmSync(${names[0] ?? '…'}) в finally или after-хуке той же функции`];
|
||||||
|
}
|
||||||
|
if (depth >= MAX_DEPTH) return [`${where}: цепочка помощников длиннее ${MAX_DEPTH}`];
|
||||||
|
const helper = scope === file ? null : functionName(scope);
|
||||||
|
if (!helper) return [`${where}: каталог возвращается из безымянной функции — вызовы не проследить`];
|
||||||
|
const sites = calls.filter((call) => ts.isIdentifier(call.expression) && call.expression.text === helper);
|
||||||
|
const problems = [];
|
||||||
|
for (const site of sites) {
|
||||||
|
const siteBound = binding(site);
|
||||||
|
// Поле объекта, которое вызывающий не забрал, — утечка: каталог никто не удалит.
|
||||||
|
if (siteBound?.fields && ![...returnedKeys].some((key) => key && siteBound.fields.has(key))) {
|
||||||
|
problems.push(`${path} (строка ${lineOf(site) + 1}): ${helper}() отдаёт каталог полем `
|
||||||
|
+ `{${[...returnedKeys].filter(Boolean).join(', ')}}, вызывающий его не забирает`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (siteBound?.fields) {
|
||||||
|
const taken = [...returnedKeys].filter((key) => key && siteBound.fields.has(key))
|
||||||
|
.map((key) => siteBound.fields.get(key));
|
||||||
|
const siteScope = enclosingFunction(site);
|
||||||
|
if (taken.some((name) => cleansUp(siteScope, name))) continue;
|
||||||
|
problems.push(`${path} (строка ${lineOf(site) + 1}): нет очистки {${taken.join(', ')}} из ${helper}() `
|
||||||
|
+ 'в finally или after-хуке');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
problems.push(...check(site, depth + 1, origin, `${path} → ${helper}()`));
|
||||||
|
}
|
||||||
|
return problems;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const call of calls) {
|
||||||
|
if (!CREATORS.has(calleeName(call))) continue;
|
||||||
|
const line = lineOf(call);
|
||||||
|
if (exempt(lines, line)) continue;
|
||||||
|
for (const message of check(call, 0, line, `${fileName}:${line + 1}`)) {
|
||||||
|
violations.push({ line: line + 1, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testSources = (dir) => readdirSync(dir, { withFileTypes: true })
|
||||||
|
.flatMap((entry) => entry.isDirectory()
|
||||||
|
? testSources(join(dir, entry.name))
|
||||||
|
: extname(entry.name) === '.mjs' ? [join(dir, entry.name)] : []);
|
||||||
|
|
||||||
|
test('#646 AC2: каждый mkdtemp в тестах убирается в finally или after-хуке', () => {
|
||||||
|
const files = testSources(TEST_DIR);
|
||||||
|
let sites = 0;
|
||||||
|
const problems = [];
|
||||||
|
for (const path of files) {
|
||||||
|
const source = readFileSync(path, 'utf8');
|
||||||
|
if (!/mkdtemp/.test(source)) continue;
|
||||||
|
const name = relative(ROOT, path).replaceAll('\\', '/');
|
||||||
|
sites += (source.match(/\bmkdtemp(?:Sync)?\s*\(/g) ?? []).length;
|
||||||
|
problems.push(...tempDirViolations(source, name).map((v) => v.message));
|
||||||
|
}
|
||||||
|
// Разбор, не нашедший ни одного вызова, доказал бы только то, что он сломан.
|
||||||
|
assert.ok(sites >= 20, `ожидались десятки вызовов mkdtemp в test/, найдено ${sites}`);
|
||||||
|
assert.deepEqual(problems, [], `временные каталоги без очистки:\n${problems.join('\n')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#646 AC2: правило краснеет на утечке и молчит на корректной очистке', () => {
|
||||||
|
const bad = (src) => tempDirViolations(src).length > 0;
|
||||||
|
// Очистка без finally не исполнится после упавшего assert.
|
||||||
|
assert.ok(bad(`test('x', () => { const d = mkdtempSync('a'); work(d); rmSync(d, { recursive: true }); });`));
|
||||||
|
// Очистки нет вовсе.
|
||||||
|
assert.ok(bad(`test('x', () => { const d = mkdtempSync('a'); work(d); });`));
|
||||||
|
// Очищен не тот каталог.
|
||||||
|
assert.ok(bad(`test('x', () => { const d = mkdtempSync('a'); const e = 'b'; try {} finally { rmSync(e); } });`));
|
||||||
|
// Результат не связан с именем.
|
||||||
|
assert.ok(bad(`test('x', () => { work(mkdtempSync('a')); });`));
|
||||||
|
// Помощник отдаёт каталог, вызывающий не убирает.
|
||||||
|
assert.ok(bad(`function f() { const d = mkdtempSync('a'); return d; }
|
||||||
|
test('x', () => { const d = f(); try {} finally {} });`));
|
||||||
|
// Помощник отдаёт каталог полем объекта, вызывающий это поле не забирает (#646: accept()).
|
||||||
|
assert.ok(bad(`function f() { const sandbox = mkdtempSync('a'); return { index: 1, sandbox }; }
|
||||||
|
test('x', () => { const { index } = f(); });`));
|
||||||
|
// Пустая причина исключения не принимается.
|
||||||
|
assert.ok(bad(`test('x', () => { const d = mkdtempSync('a'); // tmp-ok:
|
||||||
|
});`));
|
||||||
|
|
||||||
|
const good = (src) => assert.deepEqual(tempDirViolations(src), [], src);
|
||||||
|
good(`test('x', () => { const d = mkdtempSync('a'); try { work(d); } finally { rmSync(d, { recursive: true, force: true }); } });`);
|
||||||
|
good(`test('x', (t) => { const d = mkdtempSync('a'); t.after(() => rmSync(d, { recursive: true, force: true })); });`);
|
||||||
|
good(`test('x', async () => { const d = await mkdtemp('a'); try {} finally { await fs.rm(d, { recursive: true }); } });`);
|
||||||
|
good(`function f() { const d = mkdtempSync('a'); return d; }
|
||||||
|
test('x', () => { const d = f(); try {} finally { rmSync(d, { recursive: true }); } });`);
|
||||||
|
good(`const f = () => mkdtempSync('a');
|
||||||
|
test('x', (t) => { const r = f(); t.after(() => rmSync(r, { recursive: true })); });`);
|
||||||
|
good(`function f() { const root = mkdtempSync('a'); return { root, x: 1 }; }
|
||||||
|
test('x', () => { const { root } = f(); try {} finally { rmSync(root, { recursive: true }); } });`);
|
||||||
|
good(`test('x', () => { const d = mkdtempSync('a'); // tmp-ok: каталог уносит дочерний процесс
|
||||||
|
});`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user