@dzhechkov/p-replicator 1.12.0 → 1.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +281 -65
- package/CHANGELOG.md +148 -1
- package/LICENSE +21 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +2 -2
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/05_architecture.md +1 -1
- package/README/eng/README.md +2 -1
- package/README/ru/01_quickstart.md +2 -2
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +2 -1
- package/README/ru/html/index.html +8 -8
- package/README.md +150 -9
- package/bin/cli.js +0 -0
- package/package.json +10 -11
- package/sbom.json +604 -64
- package/scripts/check-pipeline-gaps.sh +510 -21
- package/src/commands/init.js +1 -1
- package/src/rule-components.json +5 -1
- package/src/utils.js +33 -3
- package/templates/.claude/agents/product-discoverer.md +38 -0
- package/templates/.claude/agents/replicate-coordinator.md +11 -1
- package/templates/.claude/commands/feature.md +72 -6
- package/templates/.claude/commands/go.md +6 -8
- package/templates/.claude/commands/harvest.md +5 -7
- package/templates/.claude/commands/replicate.md +169 -44
- package/templates/.claude/commands/start.md +28 -7
- package/templates/.claude/hooks/capture-source-path.cjs +795 -0
- package/templates/.claude/hooks/check-canon.cjs +493 -0
- package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
- package/templates/.claude/hooks/check-external-deps.cjs +288 -0
- package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
- package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
- package/templates/.claude/hooks/check-job-contract.cjs +501 -0
- package/templates/.claude/hooks/check-look-origin.cjs +240 -0
- package/templates/.claude/hooks/check-look-trace.cjs +385 -0
- package/templates/.claude/hooks/check-metric-source.cjs +296 -0
- package/templates/.claude/hooks/check-model-cost.cjs +470 -0
- package/templates/.claude/hooks/check-ports.cjs +27 -6
- package/templates/.claude/hooks/check-review-contract.cjs +205 -0
- package/templates/.claude/hooks/check-source-version.cjs +312 -0
- package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
- package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
- package/templates/.claude/hooks/statusline.cjs +2 -2
- package/templates/.claude/rules/embeddable-widget.md +73 -0
- package/templates/.claude/rules/feature-lifecycle.md +5 -6
- package/templates/.claude/rules/incoming-webhooks.md +99 -0
- package/templates/.claude/rules/long-running-job.md +73 -0
- package/templates/.claude/rules/model-call-cost.md +85 -0
- package/templates/.claude/rules/replicate-pipeline.md +122 -52
- package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
- package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
- package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
- package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
- package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
- package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
- package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
- package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
- package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
- package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
- package/templates/.claude/skills/requirements-validator/SKILL.md +18 -5
- package/templates/.claude/skills/requirements-validator/references/feature-report-contracts.md +67 -0
- package/templates/.claude/skills/requirements-validator/references/scoring-system.md +15 -6
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +173 -725
- package/tests/e2e/feature-contour.test.js +176 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/01_specification.md +15 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/02_pseudocode.md +19 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/03_architecture.md +3 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/04_refinement.md +3 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/05_completion.md +7 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/review-report.md +11 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/validation-report.md +10 -0
- package/tests/fixtures/feature-contour/tests/demo.test.js +16 -0
- package/tests/snapshot/baseline.json +64 -40
- package/tests/unit/capture-source-path.test.js +492 -0
- package/tests/unit/check-canon.test.js +403 -0
- package/tests/unit/check-embed-contract.test.js +422 -0
- package/tests/unit/check-external-deps.test.js +363 -0
- package/tests/unit/check-file-ownership.test.js +388 -0
- package/tests/unit/check-handoff-manifest.test.js +410 -0
- package/tests/unit/check-job-contract.test.js +514 -0
- package/tests/unit/check-look-origin.test.js +180 -0
- package/tests/unit/check-look-trace.test.js +420 -0
- package/tests/unit/check-metric-source.test.js +325 -0
- package/tests/unit/check-model-cost.test.js +425 -0
- package/tests/unit/check-ports.test.js +46 -2
- package/tests/unit/check-review-contract.test.js +181 -0
- package/tests/unit/check-source-version.test.js +344 -0
- package/tests/unit/check-swarm-receipts.test.js +231 -0
- package/tests/unit/check-webhook-contract.test.js +536 -0
- package/tests/unit/db-port-rule.test.js +8 -2
- package/tests/unit/detection-ladder-registry.test.js +2 -2
- package/tests/unit/generator-swarm-contract.test.js +287 -0
- package/tests/unit/guard-honest-input-meta.test.js +64 -0
- package/tests/unit/honest-failure-rules.test.js +106 -9
- package/tests/unit/look-phase-contract.test.js +231 -0
- package/tests/unit/negative-conclusion-gate.test.js +300 -0
- package/tests/unit/quote-provenance.test.js +122 -0
- package/tests/unit/sync-templates-guard.test.js +46 -3
- package/tests/unit/traceability-completion-gate.test.js +267 -0
- package/tests/unit/utils.test.js +40 -3
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Инструмент оси «путь» Фазы 0.5. Слой 1 — детерминированная половина.
|
|
4
|
+
//
|
|
5
|
+
// Суть проверяемого свойства одна и та же во всех случаях: инструмент, который на неизвестном
|
|
6
|
+
// состоянии отвечает «снято», превращает незнание в заверение. У него ТРИ исхода, и ни один не
|
|
7
|
+
// имеет права выглядеть как другой:
|
|
8
|
+
//
|
|
9
|
+
// 0 СНЯТ путь прокликан, строки FR-LOOK-nnn с осью «путь» выданы
|
|
10
|
+
// 1 ИСТОЧНИКА НЕТ источник ОТКРЫЛСЯ, перехода нет — доказанный отрицательный ответ
|
|
11
|
+
// 2 НЕ ИЗМЕРЕНО причина из ЗАКРЫТОГО списка, каждая означает СВОЙ ремонт
|
|
12
|
+
//
|
|
13
|
+
// Файл разделён НАМЕРЕННО. Чистые половины (robots.txt, шаг сетки, нумерация, формат строки,
|
|
14
|
+
// закрытый список) проверяются всегда и без браузера. Живые прогоны требуют Playwright — ВНЕШНЕГО
|
|
15
|
+
// предусловия пакета с нулём зависимостей, — и когда его нет, они ПРОПУСКАЮТСЯ с названной
|
|
16
|
+
// причиной, а не проходят: «не смогли проверить» и «проверили, всё хорошо» — разные факты.
|
|
17
|
+
//
|
|
18
|
+
// ИЗМЕРЕНО 2026-09-01 при живом прогоне: если поднять оснастку-сервер в ЭТОМ процессе и звать
|
|
19
|
+
// инструмент через spawnSync, сервер не ответит ни разу — spawnSync блокирует цикл событий
|
|
20
|
+
// родителя, и каждая проба упирается в таймаут robots.txt. Поэтому инструмент здесь запускается
|
|
21
|
+
// АСИНХРОННО, и это не стиль, а условие работоспособности оснастки.
|
|
22
|
+
|
|
23
|
+
const { test, describe } = require('node:test');
|
|
24
|
+
const assert = require('node:assert/strict');
|
|
25
|
+
const { spawn, execFileSync } = require('node:child_process');
|
|
26
|
+
const http = require('node:http');
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const os = require('node:os');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
|
|
31
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
32
|
+
const TOOL = path.join(PKG, 'templates', '.claude', 'hooks', 'capture-source-path.cjs');
|
|
33
|
+
const lib = require(TOOL);
|
|
34
|
+
|
|
35
|
+
// ───────────────────────────────────────────────────────── внешнее предусловие
|
|
36
|
+
|
|
37
|
+
/** Ищем Playwright ТЕМ ЖЕ порядком, что и инструмент: env → проект → глобальный npm root. */
|
|
38
|
+
function findPlaywright() {
|
|
39
|
+
const candidates = [];
|
|
40
|
+
if (process.env.PLAYWRIGHT_MODULE) candidates.push(process.env.PLAYWRIGHT_MODULE);
|
|
41
|
+
for (const name of ['playwright', 'playwright-core']) {
|
|
42
|
+
candidates.push(name);
|
|
43
|
+
candidates.push(path.join(PKG, 'node_modules', name));
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const root = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', timeout: 15000 }).trim();
|
|
47
|
+
for (const name of ['playwright', 'playwright-core']) candidates.push(path.join(root, name));
|
|
48
|
+
} catch { /* npm может отсутствовать */ }
|
|
49
|
+
for (const c of candidates) {
|
|
50
|
+
try { if (require(c).chromium) return c; } catch { /* дальше */ }
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PW = findPlaywright();
|
|
56
|
+
const LIVE_SKIP = PW ? false
|
|
57
|
+
: 'Playwright не найден на машине. Это ВНЕШНЕЕ предусловие пакета с нулём зависимостей, поэтому '
|
|
58
|
+
+ 'живые прогоны ПРОПУЩЕНЫ, а не зачтены. Включить: npm i -D playwright && npx playwright '
|
|
59
|
+
+ 'install chromium, либо PLAYWRIGHT_MODULE=/путь/к/node_modules/playwright';
|
|
60
|
+
|
|
61
|
+
// ───────────────────────────────────────────────────────── оснастка
|
|
62
|
+
|
|
63
|
+
const LANDING = `<html><head><title>Acme</title><style>
|
|
64
|
+
:root{--a:1;--b:2;--c:3}
|
|
65
|
+
body{margin:0;font-family:system-ui}
|
|
66
|
+
h1{font-size:48px;font-weight:700;padding:16px}
|
|
67
|
+
h2{font-size:24px;font-weight:600;padding:8px}
|
|
68
|
+
p{font-size:16px;padding:8px;margin:8px}
|
|
69
|
+
a.cta{display:inline-block;background:#123456;color:#fff;padding:12px 24px;border-radius:8px;font-weight:600}
|
|
70
|
+
@media (max-width: 768px){h1{font-size:32px}}
|
|
71
|
+
@media (min-width: 1024px){h1{font-size:56px}}
|
|
72
|
+
</style></head><body>
|
|
73
|
+
<h1>Ship faster</h1><h2>Analytics</h2><p>Copy.</p>
|
|
74
|
+
<a class="cta" href="/signup">Get started</a><a href="/pricing">Pricing</a>
|
|
75
|
+
</body></html>`;
|
|
76
|
+
|
|
77
|
+
const SIGNUP = `<html><head><title>Sign up</title><style>
|
|
78
|
+
body{font-family:system-ui;margin:0}h1{font-size:32px;font-weight:700;padding:16px}
|
|
79
|
+
input{padding:8px;margin:8px}button{background:#123456;color:#fff;padding:12px 24px;font-weight:600}
|
|
80
|
+
</style></head><body><h1>Create account</h1>
|
|
81
|
+
<form><label for="e">Email</label><input id="e" type="email">
|
|
82
|
+
<label for="p">Password</label><input id="p" type="password">
|
|
83
|
+
<button type="submit">Create</button></form></body></html>`;
|
|
84
|
+
|
|
85
|
+
const LONELY = '<html><head><title>Lonely</title></head><body><h1>Only screen</h1>'
|
|
86
|
+
+ '<p>Никаких переходов.</p></body></html>';
|
|
87
|
+
|
|
88
|
+
const PAGES = { '/': LANDING, '/signup': SIGNUP, '/pricing': '<html><body><h1>Pricing</h1></body></html>',
|
|
89
|
+
'/lonely': LONELY };
|
|
90
|
+
|
|
91
|
+
/** Оснастка-сервер. Возвращает журнал попаданий — по нему проверяется ВЕЖЛИВОСТЬ. */
|
|
92
|
+
function site({ robots = null, forbid = [] } = {}) {
|
|
93
|
+
const hits = [];
|
|
94
|
+
const srv = http.createServer((req, res) => {
|
|
95
|
+
const url = req.url.split('?')[0];
|
|
96
|
+
hits.push({ url, t: Date.now(), ua: req.headers['user-agent'] || '' });
|
|
97
|
+
if (url === '/robots.txt') {
|
|
98
|
+
if (robots === null) { res.writeHead(404); return res.end('nf'); }
|
|
99
|
+
if (robots === 500) { res.writeHead(500); return res.end('err'); }
|
|
100
|
+
res.writeHead(200, { 'content-type': 'text/plain' }); return res.end(robots);
|
|
101
|
+
}
|
|
102
|
+
if (forbid.includes(url)) { res.writeHead(403); return res.end('blocked'); }
|
|
103
|
+
const body = PAGES[url];
|
|
104
|
+
if (!body) { res.writeHead(404); return res.end('nf'); }
|
|
105
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(body);
|
|
106
|
+
});
|
|
107
|
+
return { srv, hits, listen: () => new Promise((r) => srv.listen(0, '127.0.0.1', () => r(srv.address().port))) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** АСИНХРОННЫЙ запуск — см. измерение в шапке файла. */
|
|
111
|
+
function run(args, env) {
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
const child = spawn(process.execPath, [TOOL, ...args], {
|
|
114
|
+
env: { ...process.env, ...(PW ? { PLAYWRIGHT_MODULE: PW } : {}), ...env },
|
|
115
|
+
});
|
|
116
|
+
let out = '';
|
|
117
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
118
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
119
|
+
child.on('close', (code) => resolve({ code, out }));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function tmp() {
|
|
124
|
+
return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-path-')));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ───────────────────────────────────────────────────────── слой 1: без браузера
|
|
128
|
+
|
|
129
|
+
describe('ось «путь»: детерминированная половина, работает без браузера', () => {
|
|
130
|
+
test('P1 - закрытый список причин совпадает с тем, что держит check-look-trace', () => {
|
|
131
|
+
// Два файла, один список. Разойдись они — одна половина принимала бы написание, которое
|
|
132
|
+
// другая отвергает, и «честный отказ» стал бы вопросом того, кто первый посмотрел.
|
|
133
|
+
const checker = fs.readFileSync(
|
|
134
|
+
path.join(PKG, 'templates', '.claude', 'hooks', 'check-look-trace.cjs'), 'utf-8');
|
|
135
|
+
for (const reason of lib.REASONS) {
|
|
136
|
+
assert.ok(checker.includes("'" + reason + "'"),
|
|
137
|
+
'причина «' + reason + '» есть у инструмента, но её не знает check-look-trace.cjs — '
|
|
138
|
+
+ 'запишут её в профиль, и проверка объявит её свободным текстом');
|
|
139
|
+
}
|
|
140
|
+
// Четыре причины — именно те, что добавила съёмка браузером. Каждая означает СВОЙ ремонт.
|
|
141
|
+
for (const reason of ['no-browser', 'bot-protected', 'timeout', 'robots-disallowed']) {
|
|
142
|
+
assert.ok(lib.REASONS.includes(reason), 'инструмент обязан уметь причину ' + reason);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('P2 - НЕГАТИВНЫЙ: причина вне закрытого списка не выпускается вовсе', async () => {
|
|
147
|
+
// Обязанность списка держится КОДОМ, а не дисциплиной вызывающего. Проверяем не «в исходнике
|
|
148
|
+
// нет плохих слов», а поведение: единственная дверь к коду 2 отвергает чужую причину.
|
|
149
|
+
const src = fs.readFileSync(TOOL, 'utf-8');
|
|
150
|
+
const guard = /if \(!REASONS\.includes\(reason\)\)/;
|
|
151
|
+
assert.match(src, guard, 'функция notMeasured обязана проверять причину по списку');
|
|
152
|
+
|
|
153
|
+
// И живая проба того, что через эту дверь проходят только они: неизвестный флаг отвергается
|
|
154
|
+
// причиной ИЗ списка, а не выдуманной.
|
|
155
|
+
const r = await run(['--нет-такого-флага']);
|
|
156
|
+
assert.equal(r.code, 2, r.out);
|
|
157
|
+
const named = lib.REASONS.filter((x) => r.out.includes('НЕ ИЗМЕРЕНО: ' + x));
|
|
158
|
+
assert.equal(named.length, 1, 'ровно одна причина из закрытого списка: ' + r.out);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('P3 - robots.txt: запрет читается, разрешение читается, длиннейшее правило побеждает', () => {
|
|
162
|
+
const rules = lib.parseRobots([
|
|
163
|
+
'User-agent: *',
|
|
164
|
+
'Disallow: /admin',
|
|
165
|
+
'Allow: /admin/public',
|
|
166
|
+
'',
|
|
167
|
+
'User-agent: SomeoneElse',
|
|
168
|
+
'Disallow: /',
|
|
169
|
+
].join('\n'));
|
|
170
|
+
assert.equal(lib.robotsAllows(rules, '/'), true, 'корень не запрещён');
|
|
171
|
+
assert.equal(lib.robotsAllows(rules, '/admin'), false, 'запрет должен сработать');
|
|
172
|
+
assert.equal(lib.robotsAllows(rules, '/admin/public/x'), true,
|
|
173
|
+
'длиннейшее правило Allow обязано побеждать более короткий Disallow');
|
|
174
|
+
|
|
175
|
+
// Группа под НАШ агент важнее звёздочки — иначе сайт не может дать нам отдельные правила.
|
|
176
|
+
const mine = lib.parseRobots([
|
|
177
|
+
'User-agent: *', 'Disallow:',
|
|
178
|
+
'User-agent: ' + lib.ROBOTS_AGENT, 'Disallow: /',
|
|
179
|
+
].join('\n'));
|
|
180
|
+
assert.equal(lib.robotsAllows(mine, '/anything'), false,
|
|
181
|
+
'правила, адресованные нашему агенту, обязаны применяться к нам');
|
|
182
|
+
|
|
183
|
+
// Пустой Disallow — это разрешение, а не запрет всего.
|
|
184
|
+
assert.equal(lib.robotsAllows(lib.parseRobots('User-agent: *\nDisallow:\n'), '/x'), true);
|
|
185
|
+
// Джокер и якорь конца.
|
|
186
|
+
const wild = lib.parseRobots('User-agent: *\nDisallow: /*.pdf$\n');
|
|
187
|
+
assert.equal(lib.robotsAllows(wild, '/docs/a.pdf'), false);
|
|
188
|
+
assert.equal(lib.robotsAllows(wild, '/docs/a.pdf.html'), true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('P4 - шаг сетки выводится ДОЛЕЙ, и НОД здесь был бы неверен', () => {
|
|
192
|
+
// ИЗМЕРЕНО: одно браузерное умолчание в em (у h1 при кегле 56px margin выходит 38px) обнуляет
|
|
193
|
+
// НОД до 1-2 на сайте с честной сеткой 4px. Доля устойчива к чужим умолчаниям.
|
|
194
|
+
const hist = { 8: 10, 16: 4, 12: 2, 20: 2, 24: 2, 38: 2 };
|
|
195
|
+
const got = lib.spacingStep(hist);
|
|
196
|
+
assert.equal(got.step, 4, 'шаг сетки на этой гистограмме равен 4: ' + JSON.stringify(got));
|
|
197
|
+
assert.ok(got.share >= 0.9, 'доля обязана печататься и быть выше порога: ' + JSON.stringify(got));
|
|
198
|
+
assert.ok(got.share <= 1, 'доля — это доля');
|
|
199
|
+
|
|
200
|
+
// Кратное не должно выигрывать у настоящего шага: 8 покрывает 73% и обязано проиграть.
|
|
201
|
+
assert.notEqual(got.step, 8, 'кратное не является шагом сетки');
|
|
202
|
+
// Слишком мало данных — честный null, а не выдуманное «1px».
|
|
203
|
+
assert.equal(lib.spacingStep({ 7: 1 }), null, 'на трёх точках шаг не выводится');
|
|
204
|
+
// Мусорная гистограмма без общего делителя — тоже null, а не 1.
|
|
205
|
+
assert.equal(lib.spacingStep({ 7: 5, 11: 5, 13: 5, 17: 5 }), null);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('P5 - одно семейство идентификаторов: нумерация продолжает СУЩЕСТВУЮЩИЙ профиль', () => {
|
|
209
|
+
const dir = tmp();
|
|
210
|
+
try {
|
|
211
|
+
assert.deepEqual(lib.nextId(dir), { next: 1, profileFound: false },
|
|
212
|
+
'профиля нет — начинаем с 001 и говорим об этом');
|
|
213
|
+
fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
|
|
214
|
+
fs.writeFileSync(path.join(dir, 'docs', 'source-product-profile.md'),
|
|
215
|
+
'| FR-LOOK-001 | a | облик | / | x | ЧЕРНОВИК |\n'
|
|
216
|
+
+ '| FR-LOOK-007 | b | облик | / | x | ЧЕРНОВИК |\n');
|
|
217
|
+
assert.deepEqual(lib.nextId(dir), { next: 8, profileFound: true },
|
|
218
|
+
'номер не переиспользуется: следующий за максимальным, а не за последним');
|
|
219
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('P6 - строки выпускаются в ОДНОМ семействе с осью «путь» и без чужих значений', () => {
|
|
223
|
+
const step = {
|
|
224
|
+
index: 2, path: '/signup', at: '2026-09-01T00:00:00Z', authWall: true,
|
|
225
|
+
regularities: { fieldCount: 2, prominentCtas: 1, ctaCount: 3, headingLevels: 1 },
|
|
226
|
+
};
|
|
227
|
+
const row = lib.stepRow(7, step, 3, 'step-02.aria.txt');
|
|
228
|
+
assert.match(row, /^\| FR-LOOK-007 \|/, 'ОДНО семейство: ' + row);
|
|
229
|
+
assert.match(row, /\| путь \|/, 'ось проставлена колонкой, а не вторым пространством имён');
|
|
230
|
+
assert.match(row, /ЧЕРНОВИК/, 'строка выпускается черновиком — её проверяет человек');
|
|
231
|
+
assert.match(row, /экран входа/, 'пейволл обязан быть виден в строке');
|
|
232
|
+
assert.equal(row.split('|').length - 1, 7, 'ровно шесть колонок таблицы: ' + row);
|
|
233
|
+
assert.ok(!/FR-FLOW/.test(row), 'второго семейства нет');
|
|
234
|
+
|
|
235
|
+
const seq = lib.sequenceRow(9, [
|
|
236
|
+
{ path: '/', at: '2026-09-01T00:00:00Z', authWall: false },
|
|
237
|
+
{ path: '/signup', at: '2026-09-01T00:00:01Z', authWall: true },
|
|
238
|
+
]);
|
|
239
|
+
assert.match(seq, /2 экрана\(ов\), \/ → \/signup/, 'длина пути — закономерность: ' + seq);
|
|
240
|
+
assert.equal(seq.split('|').length - 1, 7, 'и та же форма строки: ' + seq);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('P7 - вертикальная черта в чужом тексте не ломает таблицу', () => {
|
|
244
|
+
// Доступное имя чужой кнопки может содержать «|». Незаэкранированная, она превратила бы одну
|
|
245
|
+
// строку в две колонки мусора — и проверка прослеживаемости читала бы битую таблицу.
|
|
246
|
+
const row = lib.stepRow(1, {
|
|
247
|
+
index: 1, path: '/a|b', at: 'T', authWall: false,
|
|
248
|
+
regularities: { fieldCount: 0, prominentCtas: 0, ctaCount: 0, headingLevels: 1 },
|
|
249
|
+
}, 1, 'e');
|
|
250
|
+
assert.equal(row.split('|').length - 1, 7, 'колонок по-прежнему шесть: ' + row);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('P8 - выбор перехода опирается на УСТОЙЧИВОЕ, а не на имена классов', () => {
|
|
254
|
+
// Имена классов у сборщиков меняются каждой сборкой чужого сайта. Опора на них — это проверка,
|
|
255
|
+
// которая краснеет от чужого релиза.
|
|
256
|
+
const src = fs.readFileSync(TOOL, 'utf-8');
|
|
257
|
+
for (const forbidden of ['className', 'classList', 'getAttribute(\'class\')', '[class']) {
|
|
258
|
+
assert.ok(!src.includes(forbidden),
|
|
259
|
+
'инструмент не имеет права читать имена классов, найдено: ' + forbidden);
|
|
260
|
+
}
|
|
261
|
+
const measure = {
|
|
262
|
+
ctas: [
|
|
263
|
+
{ name: 'Docs', href: '/docs', tag: 'a', area: 100, top: 10, weight: 400, filled: false, aboveFold: true },
|
|
264
|
+
{ name: 'Get started', href: '/signup', tag: 'a', area: 300, top: 20, weight: 600, filled: true, aboveFold: true },
|
|
265
|
+
{ name: 'Twitter', href: 'https://x.test/acme', tag: 'a', area: 900, top: 30, weight: 700, filled: true, aboveFold: true },
|
|
266
|
+
],
|
|
267
|
+
};
|
|
268
|
+
const pick = lib.pickNext(measure, 'https://acme.test/', new Set(), []);
|
|
269
|
+
assert.equal(pick.url, 'https://acme.test/signup',
|
|
270
|
+
'заметный призыв того же источника побеждает; чужой домен не берётся вовсе');
|
|
271
|
+
|
|
272
|
+
// Явный список доступных ИМЁН важнее заметности — это ручка для модели.
|
|
273
|
+
const forced = lib.pickNext(measure, 'https://acme.test/', new Set(), ['Docs']);
|
|
274
|
+
assert.equal(forced.url, 'https://acme.test/docs', '--follow обязан побеждать эвристику');
|
|
275
|
+
|
|
276
|
+
// Уже пройденное не берётся снова: иначе обход зациклится на самом заметном призыве.
|
|
277
|
+
const visited = new Set(['https://acme.test/signup']);
|
|
278
|
+
assert.equal(lib.pickNext(measure, 'https://acme.test/', visited, []).url,
|
|
279
|
+
'https://acme.test/docs', 'посещённое не предлагается повторно');
|
|
280
|
+
|
|
281
|
+
// Ни одного перехода — честный null, из которого рождается исход «ИСТОЧНИКА НЕТ».
|
|
282
|
+
assert.equal(lib.pickNext({ ctas: [] }, 'https://acme.test/', new Set(), []), null);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('P9 - НЕ ИЗМЕРЕНО печатает готовую строку профиля, а не только диагноз', async () => {
|
|
286
|
+
// Причина, которую некуда записать, теряется на следующем ходу. Инструмент печатает ровно те
|
|
287
|
+
// строки, которые ждёт check-look-trace.cjs.
|
|
288
|
+
const r = await run(['file:///etc/passwd']);
|
|
289
|
+
assert.equal(r.code, 2, r.out);
|
|
290
|
+
assert.match(r.out, /НЕ ИЗМЕРЕНО: out-of-scope/, r.out);
|
|
291
|
+
assert.match(r.out, /\*\*Статус съёмки \(путь\):\*\* НЕ ИЗМЕРЕНО/, r.out);
|
|
292
|
+
assert.match(r.out, /\*\*Причина \(путь\):\*\* out-of-scope/, r.out);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('P10 - Playwright отсутствует → честный no-browser, а не падение', async () => {
|
|
296
|
+
// Внешнее предусловие. У пакета НОЛЬ зависимостей, и эта утилита их не заводит: отсутствие
|
|
297
|
+
// инструмента обязано быть ИСХОДОМ, а не стеком вызовов.
|
|
298
|
+
const r = await run(['http://127.0.0.1:1/'],
|
|
299
|
+
{ PLAYWRIGHT_MODULE: '/nonexistent/playwright', PATH: '/nonexistent' });
|
|
300
|
+
assert.equal(r.code, 2, 'отсутствие браузера — код 2, никогда 0: ' + r.out);
|
|
301
|
+
assert.match(r.out, /НЕ ИЗМЕРЕНО: no-browser/, r.out);
|
|
302
|
+
assert.match(r.out, /playwright install chromium/, 'и ремонт обязан быть назван: ' + r.out);
|
|
303
|
+
assert.ok(!/Error:|TypeError|at Object/.test(r.out), 'это исход, а не стек: ' + r.out);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('P11 - потолки вежливости зажимаются ВСЛУХ', async () => {
|
|
307
|
+
const loud = await run(['http://127.0.0.1:1/', '--max-pages', '999', '--delay-ms', '1'],
|
|
308
|
+
{ PLAYWRIGHT_MODULE: '/nonexistent/playwright', PATH: '/nonexistent' });
|
|
309
|
+
assert.match(loud.out, new RegExp('зажат до потолка вежливости ' + lib.MAX_PAGES_CAP),
|
|
310
|
+
'молча зажатый потолок — это несогласованное поведение: ' + loud.out);
|
|
311
|
+
assert.match(loud.out, new RegExp('поднят до минимума вежливости ' + lib.MIN_DELAY_MS), loud.out);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('P12 - зарегистрирован как утилита, ни к какому событию не привязан, счётчики сходятся', () => {
|
|
315
|
+
const { COMPONENTS } = require(path.join(PKG, 'src', 'utils.js'));
|
|
316
|
+
assert.ok(COMPONENTS.hooks.items['capture-source-path'],
|
|
317
|
+
'без регистрации doctor/verify его не потребуют');
|
|
318
|
+
const settings = fs.readFileSync(path.join(PKG, 'templates', '.claude', 'settings.json'), 'utf-8');
|
|
319
|
+
assert.ok(!settings.includes('capture-source-path'),
|
|
320
|
+
'хуки этого пакета не блокируют по контракту — привязанный, он мог бы только печатать');
|
|
321
|
+
const statusline = fs.readFileSync(
|
|
322
|
+
path.join(PKG, 'templates', '.claude', 'hooks', 'statusline.cjs'), 'utf-8');
|
|
323
|
+
const m = statusline.match(/hooksExpected:\s*(\d+)/);
|
|
324
|
+
assert.equal(Number(m[1]), Object.keys(COMPONENTS.hooks.items).length,
|
|
325
|
+
'статусная строка сообщила бы о призрачной нехватке хука');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('P13 - у пакета по-прежнему НОЛЬ зависимостей', () => {
|
|
329
|
+
// Playwright — внешнее предусловие. Затащи его в dependencies — и ноль-зависимый пакет
|
|
330
|
+
// перестанет быть ноль-зависимым ради утилиты, которая честно живёт без него.
|
|
331
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(PKG, 'package.json'), 'utf-8'));
|
|
332
|
+
assert.equal(pkg.dependencies, undefined, 'dependencies обязаны отсутствовать');
|
|
333
|
+
const src = fs.readFileSync(TOOL, 'utf-8');
|
|
334
|
+
assert.ok(!/^const .* = require\('playwright/m.test(src),
|
|
335
|
+
'модуль обязан искаться во время запуска, а не подключаться статически');
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ───────────────────────────────────────────────────────── живые прогоны
|
|
340
|
+
|
|
341
|
+
describe('ось «путь»: живые прогоны браузером по локальной оснастке', { skip: LIVE_SKIP }, () => {
|
|
342
|
+
test('P14 - СНЯТ: путь прокликан, строки выданы, нумерация продолжена', async () => {
|
|
343
|
+
const { srv, listen } = site();
|
|
344
|
+
const port = await listen();
|
|
345
|
+
const dir = tmp();
|
|
346
|
+
try {
|
|
347
|
+
fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
|
|
348
|
+
fs.writeFileSync(path.join(dir, 'docs', 'source-product-profile.md'),
|
|
349
|
+
'| FR-LOOK-004 | палитра | облик | / | скриншот | ЧЕРНОВИК |\n');
|
|
350
|
+
const r = await run([`http://127.0.0.1:${port}/`, '--project', dir,
|
|
351
|
+
'--out', 'evidence', '--max-pages', '3', '--delay-ms', '250']);
|
|
352
|
+
assert.equal(r.code, 0, 'путь снят — код 0: ' + r.out);
|
|
353
|
+
assert.match(r.out, /FR-LOOK-005 \|/, 'нумерация продолжает профиль: ' + r.out);
|
|
354
|
+
assert.match(r.out, /\| путь \|/, 'ось проставлена: ' + r.out);
|
|
355
|
+
assert.ok(!/FR-LOOK-004 \|/.test(r.out), 'занятый номер не переиспользуется: ' + r.out);
|
|
356
|
+
// Экран входа — законная последняя точка. За вход не ходим.
|
|
357
|
+
assert.match(r.out, /экран входа/, 'пейволл обязан быть записан шагом: ' + r.out);
|
|
358
|
+
// И измеренные закономерности стартового экрана, а не значения чужого оформления.
|
|
359
|
+
assert.match(r.out, /шаг сетки отступов: 4px/, 'сетка 4px обязана вывестись: ' + r.out);
|
|
360
|
+
assert.match(r.out, /брейкпоинты из настоящих @media: 768, 1024/, r.out);
|
|
361
|
+
} finally { srv.close(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('P15 - доказательства помечены чужим материалом и закрыты своим .gitignore', async () => {
|
|
365
|
+
const { srv, listen } = site();
|
|
366
|
+
const port = await listen();
|
|
367
|
+
const dir = tmp();
|
|
368
|
+
try {
|
|
369
|
+
await run([`http://127.0.0.1:${port}/`, '--project', dir, '--out', 'evidence',
|
|
370
|
+
'--max-pages', '2', '--delay-ms', '250']);
|
|
371
|
+
const out = path.join(dir, 'evidence');
|
|
372
|
+
assert.equal(fs.readFileSync(path.join(out, '.gitignore'), 'utf-8').trim(), '*',
|
|
373
|
+
'каталог обязан закрываться САМ, а не чужой дисциплиной');
|
|
374
|
+
assert.match(fs.readFileSync(path.join(out, 'README.txt'), 'utf-8'), /ЧУЖОГО сайта/,
|
|
375
|
+
'материал обязан быть помечен');
|
|
376
|
+
// DOM чужого сайта — чужой код. По умолчанию его тут нет.
|
|
377
|
+
const files = fs.readdirSync(out);
|
|
378
|
+
assert.ok(!files.some((f) => f.endsWith('.dom.html')),
|
|
379
|
+
'DOM по умолчанию не сохраняется: ' + files.join(', '));
|
|
380
|
+
assert.ok(files.some((f) => f.endsWith('.aria.txt')), 'семантический слепок — наш вывод');
|
|
381
|
+
assert.ok(files.includes('capture.json'), 'измерения сохраняются');
|
|
382
|
+
} finally { srv.close(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('P16 - ВЕЖЛИВОСТЬ: один поток, пауза выдержана, потолок страниц соблюдён', async () => {
|
|
386
|
+
const { srv, hits, listen } = site();
|
|
387
|
+
const port = await listen();
|
|
388
|
+
const dir = tmp();
|
|
389
|
+
try {
|
|
390
|
+
const r = await run([`http://127.0.0.1:${port}/`, '--project', dir, '--out', 'e',
|
|
391
|
+
'--max-pages', '2', '--delay-ms', '1200']);
|
|
392
|
+
assert.equal(r.code, 0, r.out);
|
|
393
|
+
const pages = hits.filter((h) => h.url !== '/robots.txt');
|
|
394
|
+
assert.equal(pages.length, 2, '--max-pages 2 значит ровно два экрана: '
|
|
395
|
+
+ JSON.stringify(pages.map((p) => p.url)));
|
|
396
|
+
assert.ok(pages[1].t - pages[0].t >= 1200,
|
|
397
|
+
'пауза между экранами обязана выдерживаться, измерено ' + (pages[1].t - pages[0].t) + ' мс');
|
|
398
|
+
assert.match(pages[0].ua, new RegExp(lib.ROBOTS_AGENT),
|
|
399
|
+
'User-Agent честный: маскировка под обычный браузер была бы обходом защиты');
|
|
400
|
+
} finally { srv.close(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('P17 - robots.txt спрашивается ПЕРЕД обходом, и запрет — это исход', async () => {
|
|
404
|
+
const dir = tmp();
|
|
405
|
+
try {
|
|
406
|
+
const deny = site({ robots: 'User-agent: *\nDisallow: /\n' });
|
|
407
|
+
const p1 = await deny.listen();
|
|
408
|
+
const r1 = await run([`http://127.0.0.1:${p1}/`, '--project', dir, '--out', 'e',
|
|
409
|
+
'--max-pages', '3']);
|
|
410
|
+
assert.equal(r1.code, 2, 'запрет — код 2: ' + r1.out);
|
|
411
|
+
assert.match(r1.out, /НЕ ИЗМЕРЕНО: robots-disallowed/, r1.out);
|
|
412
|
+
assert.equal(deny.hits.filter((h) => h.url !== '/robots.txt').length, 0,
|
|
413
|
+
'ни одной страницы не тронуто: robots.txt читается ДО обхода, а не после');
|
|
414
|
+
deny.srv.close();
|
|
415
|
+
|
|
416
|
+
// Нечитаемый robots.txt — запрет, а не разрешение: «не смогли спросить» ≠ «нам разрешили».
|
|
417
|
+
const broken = site({ robots: 500 });
|
|
418
|
+
const p2 = await broken.listen();
|
|
419
|
+
const r2 = await run([`http://127.0.0.1:${p2}/`, '--project', dir, '--out', 'e',
|
|
420
|
+
'--max-pages', '3']);
|
|
421
|
+
assert.equal(r2.code, 2, r2.out);
|
|
422
|
+
assert.match(r2.out, /robots-disallowed/, r2.out);
|
|
423
|
+
assert.match(r2.out, /трактуется как запрет/, r2.out);
|
|
424
|
+
broken.srv.close();
|
|
425
|
+
|
|
426
|
+
// ОДНА страница — не обход, robots.txt не запрашивается вовсе.
|
|
427
|
+
const one = site({ robots: 'User-agent: *\nDisallow: /\n' });
|
|
428
|
+
const p3 = await one.listen();
|
|
429
|
+
const r3 = await run([`http://127.0.0.1:${p3}/lonely`, '--project', dir, '--out', 'e',
|
|
430
|
+
'--max-pages', '1']);
|
|
431
|
+
assert.equal(one.hits.filter((h) => h.url === '/robots.txt').length, 0,
|
|
432
|
+
'обхода нет — robots.txt не спрашивается: ' + r3.out);
|
|
433
|
+
one.srv.close();
|
|
434
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test('P18 - 403 и экран входа — исходы с НАЗВАННОЙ причиной, никогда не обход', async () => {
|
|
438
|
+
const dir = tmp();
|
|
439
|
+
try {
|
|
440
|
+
const blocked = site({ forbid: ['/'] });
|
|
441
|
+
const p1 = await blocked.listen();
|
|
442
|
+
const r1 = await run([`http://127.0.0.1:${p1}/`, '--project', dir, '--out', 'e',
|
|
443
|
+
'--max-pages', '1']);
|
|
444
|
+
assert.equal(r1.code, 2, r1.out);
|
|
445
|
+
assert.match(r1.out, /НЕ ИЗМЕРЕНО: bot-protected/, r1.out);
|
|
446
|
+
assert.match(r1.out, /ЗАПРЕЩЕНО/, 'и запрет на обход защиты обязан быть сказан: ' + r1.out);
|
|
447
|
+
blocked.srv.close();
|
|
448
|
+
|
|
449
|
+
const walled = site();
|
|
450
|
+
const p2 = await walled.listen();
|
|
451
|
+
const r2 = await run([`http://127.0.0.1:${p2}/signup`, '--project', dir, '--out', 'e',
|
|
452
|
+
'--max-pages', '2']);
|
|
453
|
+
assert.equal(r2.code, 2, 'стартовый экран за входом — не «снято»: ' + r2.out);
|
|
454
|
+
assert.match(r2.out, /НЕ ИЗМЕРЕНО: auth-required/, r2.out);
|
|
455
|
+
walled.srv.close();
|
|
456
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test('P19 - ИСТОЧНИКА НЕТ: экран открылся, перехода нет — доказанный отрицательный ответ', async () => {
|
|
460
|
+
const { srv, listen } = site();
|
|
461
|
+
const port = await listen();
|
|
462
|
+
const dir = tmp();
|
|
463
|
+
try {
|
|
464
|
+
const r = await run([`http://127.0.0.1:${port}/lonely`, '--project', dir, '--out', 'e',
|
|
465
|
+
'--max-pages', '3', '--delay-ms', '250']);
|
|
466
|
+
assert.equal(r.code, 1, 'третий исход имеет СВОЙ код: ' + r.out);
|
|
467
|
+
assert.match(r.out, /ИСТОЧНИКА НЕТ/, r.out);
|
|
468
|
+
assert.match(r.out, /\*\*Статус съёмки \(путь\):\*\* ИСТОЧНИКА НЕТ/,
|
|
469
|
+
'и печатает готовую строку профиля: ' + r.out);
|
|
470
|
+
assert.ok(!/FR-LOOK-\d{3} \|/.test(r.out), 'строк не выпускается — записывать нечего: ' + r.out);
|
|
471
|
+
} finally { srv.close(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test('P20 - один двоичный файл выдаёт все три исхода в одном прогоне', async () => {
|
|
475
|
+
// Каждый случай выше утверждает ОДНО направление, поэтому реализация с постоянным ответом
|
|
476
|
+
// прошла бы подмножество. Тот же исполняемый файл обязан выдать 0, 1 и 2.
|
|
477
|
+
const { srv, listen } = site();
|
|
478
|
+
const port = await listen();
|
|
479
|
+
const dir = tmp();
|
|
480
|
+
try {
|
|
481
|
+
const seen = [
|
|
482
|
+
(await run([`http://127.0.0.1:${port}/`, '--project', dir, '--out', 'a',
|
|
483
|
+
'--max-pages', '2', '--delay-ms', '250'])).code,
|
|
484
|
+
(await run([`http://127.0.0.1:${port}/lonely`, '--project', dir, '--out', 'b',
|
|
485
|
+
'--max-pages', '2', '--delay-ms', '250'])).code,
|
|
486
|
+
(await run([`http://127.0.0.1:${port}/nope`, '--project', dir, '--out', 'c',
|
|
487
|
+
'--max-pages', '1'])).code,
|
|
488
|
+
];
|
|
489
|
+
assert.deepEqual(seen, [0, 1, 2], 'ожидались снято/пути-нет/не-измерено: ' + JSON.stringify(seen));
|
|
490
|
+
} finally { srv.close(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
491
|
+
});
|
|
492
|
+
});
|