mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +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',
|
||||
}],
|
||||
},
|
||||
{
|
||||
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',
|
||||
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-ресурса и тянет хешированный чанк');
|
||||
});
|
||||
|
||||
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 { tmpdir } = await import('node:os');
|
||||
const { join } = await import('node:path');
|
||||
const { verifyBundleTree, sha256Bytes } = await import('../scripts/bundle-tree.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'hp-tree-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(join(root, 'houseplan-assets'));
|
||||
const entryCode = 'try{await import("./houseplan-assets/main-abc.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 { 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 { join } from 'node:path';
|
||||
import { acceptIdentical, acceptedDocsManifest, identicalDecision, identicalDocsManifest, verifyDocsCandidate } from '../scripts/docs-accept.mjs';
|
||||
@@ -213,8 +213,9 @@ test('#455 принятый манифест несёт среду приёмк
|
||||
|
||||
// ---------- #512: --identical ----------
|
||||
|
||||
const identicalRoot = (frames) => {
|
||||
const identicalRoot = (t, frames) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'hp-identical-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(join(root, 'docs', 'images'), { recursive: true });
|
||||
const scenarios = {};
|
||||
for (const scenario of DOC_SCREENSHOTS) {
|
||||
@@ -241,8 +242,8 @@ const identicalRoot = (frames) => {
|
||||
return { root, manifest, capture, compare };
|
||||
};
|
||||
|
||||
test('#512 AC3: identical frames accept only the source fingerprint; bytes and provenance stay committed', async () => {
|
||||
const { root, manifest, capture, compare } = identicalRoot({});
|
||||
test('#512 AC3: identical frames accept only the source fingerprint; bytes and provenance stay committed', async (t) => {
|
||||
const { root, manifest, capture, compare } = identicalRoot(t, {});
|
||||
const log = [];
|
||||
const code = await acceptIdentical({ root, capture, compare, log: (line) => log.push(line) });
|
||||
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], /попиксельно совпали/);
|
||||
});
|
||||
|
||||
test('#512 AC3: one differing pixel refuses, names the frame and leaves everything committed as it was', async () => {
|
||||
const { root, manifest, capture, compare } = identicalRoot({ 'view-desktop': { identical: false, differing: 3, sizeMismatch: false } });
|
||||
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(t, { 'view-desktop': { identical: false, differing: 3, sizeMismatch: false } });
|
||||
const log = [];
|
||||
const code = await acceptIdentical({ root, capture, compare, log: (line) => log.push(line) });
|
||||
assert.equal(code, 1);
|
||||
|
||||
@@ -87,34 +87,47 @@ 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-'));
|
||||
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,
|
||||
};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
test('#571 AC1: артефакт Linux принимается, обе стороны провенанса записаны', () => {
|
||||
const from = fixture({ platform: 'linux' });
|
||||
/** Артефакт съёмки, который убирается по завершении теста, даже упавшего (#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', 'платформа КАДРОВ — из отчёта');
|
||||
@@ -124,61 +137,55 @@ test('#571 AC1: артефакт Linux принимается, обе сторо
|
||||
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 хост теста оставляет явный след осознанного обхода');
|
||||
rmSync(from, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('#641: локальная Linux-съёмка без CI и WSL-аттестации отвергается до записи', {
|
||||
skip: process.platform !== 'linux' && 'целевой guard исполняется в канонической Linux-среде приёмки',
|
||||
}, () => {
|
||||
const from = fixture({ platform: 'linux', captureEnv: {} });
|
||||
}, (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, 'отказ обязан произойти до изменения эталонов и индекса');
|
||||
rmSync(from, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('#571 AC2: чужая среда съёмки без причины — отказ до записи', () => {
|
||||
const from = fixture({ platform: 'win32' });
|
||||
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,
|
||||
'индекс обязан остаться нетронутым: отказ до записи');
|
||||
rmSync(from, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('#571 AC1: чужая среда съёмки с причиной — причина уезжает в индекс', () => {
|
||||
const from = fixture({ platform: 'win32' });
|
||||
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, /Чужая среда разрешена осознанно/);
|
||||
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: { 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 });
|
||||
assert.match(error, /не называет платформу съёмки/);
|
||||
rmSync(from, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('#571 старая схема — отдельная явная ветка, а не подстановка своей платформы', () => {
|
||||
test('#571 старая схема — отдельная явная ветка, а не подстановка своей платформы', (t) => {
|
||||
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);
|
||||
assert.equal(index.capturedOn, null, 'выдумывать платформу кадров нельзя');
|
||||
assert.equal(index.acceptedOn, process.platform);
|
||||
assert.match(stdout, /Отчёт старой схемы/);
|
||||
rmSync(from, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('#571 индекс любой схемы читается одним правилом', () => {
|
||||
@@ -188,15 +195,13 @@ test('#571 индекс любой схемы читается одним пра
|
||||
assert.equal(indexCapturedOn(null), null);
|
||||
});
|
||||
|
||||
test('#571 подмена PNG и неполный артефакт по-прежнему fail-closed', () => {
|
||||
const tampered = fixture();
|
||||
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/);
|
||||
rmSync(tampered, { recursive: true, force: true });
|
||||
|
||||
const incomplete = fixture();
|
||||
const incomplete = artifact(t);
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -238,15 +238,17 @@ function runStepRebase(work) {
|
||||
const to = body.indexOf('# #635 r2:');
|
||||
assert.ok(from >= 0 && to > from, 'ребейзная часть шага найдена');
|
||||
const temp = mkdtempSync(join(tmpdir(), 'hp-runner-'));
|
||||
const output = join(temp, 'output');
|
||||
writeFileSync(output, '');
|
||||
const script = `${body.slice(from, to)}\necho REBASED\n`;
|
||||
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' },
|
||||
});
|
||||
const result = { status: r.status, stdout: r.stdout, stderr: r.stderr, output: readFileSync(output, 'utf8') };
|
||||
rmSync(temp, { recursive: true, force: true });
|
||||
return result;
|
||||
try {
|
||||
const output = join(temp, 'output');
|
||||
writeFileSync(output, '');
|
||||
const script = `${body.slice(from, to)}\necho REBASED\n`;
|
||||
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' },
|
||||
});
|
||||
return { status: r.status, stdout: r.stdout, stderr: r.stderr, output: readFileSync(output, 'utf8') };
|
||||
} finally {
|
||||
rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
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