Files
ark-stronghold-protocol/test/package.test.js
T
sganggs ec72abb0dd feedback5 WU: tools/package.mjs builds the runtime-only full zip and a lite zip without the art, with every check in --dry-run
npm run package / npm run package:lite (node tools/package.mjs [--lite] [--dry-run [--list]] [--out <dir>] [--force]
[--keep-stage] [--no-install] [--allow-dirty] [--root <dir>]) replaces the maintainer's copy-the-tree script:
Stronghold-Protocol-v<version>.zip and Stronghold-Protocol-v<version>-lite.zip, one Stronghold-Protocol/ folder each.

Ships (an allowlist over git ls-files): server/, shared/, data/, public/ but public/dev/, the start scripts, the tools a
player runs (setup, vendor, fetch-assets + tools/assets, doctor, and what setup starts: tools/local-extract,
crop-board-atlas), the four research tables the Node sim fallback and fetch-assets read (docs/research 03, 05-enemies,
05-maps, 07 JSON — the first cut left them out, so a lite zip's setup could not plan the art), package.json / lock,
LICENSE / NOTICE / THIRD-PARTY-NOTICES, README, CHANGELOG, docs/PLAYING.md, docs/DEPLOY.md; npm ci --omit=dev in the
stage adds node_modules and public/vendor. The full zip adds the files data/assets.json and data/local-assets.json list,
public/fonts and public/assets/local/ — not the rest of public/assets, so art the data dropped (焰狐龙梓兰's 15 files)
never ships. Left out: test/, maintainer tools, scripts/make-windows-bundle.mjs, the other docs and docs/img, research
notes, handoff/, .github/, types/, lint / editor / Docker files.

Checks (also in --dry-run, which writes nothing): 0.1.x's refusal list plus test, .github, AGENTS.md, public/dev,
node_modules/.cache, .env / .venv anywhere; relative and server-mount imports of shipped modules and the player npm
scripts resolve to shipped files; a full zip has every listed art file; no two paths differ only in case; no shipped
file (text or binary, art included) has a /Users/, C:\Users\ or /home/ path or this machine's account name (read from
the OS at run time, SP_PACKAGE_SCAN_USER=0 / SP_PACKAGE_SCAN_NAMES; node_modules for the name only); no shipped tracked
file has an uncommitted change. A build also checks that the stage holds exactly the plan before zipping, and refuses an
--out inside the repository.

Measured on this tree (version 0.1.3 until the bump): full zip 421,421,613 bytes (14,910 files, 589 MB unpacked), lite
zip 22,088,912 bytes (6,430 files, 85 MB); the 0.1.x layout of the same tree (every tracked file, all of public/assets)
zips to 429,644,767 bytes: 569 files and 25 MB unpacked less. Both stages started (npm start, /healthz, assets), setup
--check is green on the full one, and the lite one plans the art offline.

test/package.test.js (9 tests, about 1 s, no network, no npm install): the selection on sample paths and on the real
git ls-files (imports, npm entry points, research tables and spawned tools ship), the refusal list, a dry-run of a
temporary git checkout with fake art (full and lite lists), the scans and guards, and one --no-install build there.
2026-10-06 18:07:15 +08:00

269 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/package.test.js — tools/package.mjs, the player release zips (docs/DEPLOY.md §7). No network, no npm install, no
// zip of the repository: the selection rules on sample paths and on the real `git ls-files`, then the packager itself
// (--dry-run, the scans and guards, one --no-install build) on a small temporary git checkout with fake art.
// Run: node --test test/package.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
FOLDER, REFUSE, RUNTIME_RESEARCH, entryProblems, isRefused, packageOutIsUnsafe, plan, resolveSpecifier, scanFiles,
selectTracked,
} from '../tools/package.mjs';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const TOOL = path.join(ROOT, 'tools', 'package.mjs');
// hermetic scans: not this machine's account name, but a planted one
const ENV = { SP_PACKAGE_SCAN_USER: '0', SP_PACKAGE_SCAN_NAMES: 'plantedname' };
test('selection: runtime files in; tests, maintainer tools, other docs, dev pages, private and config files out', () => {
const { keep, drop } = selectTracked([
'server/index.js', 'server/sim/rng.js', 'shared/constants.js', 'public/index.html', 'public/js/main.js', 'public/i18n/en.json',
'data/chess.json', 'data/i18n/en.json', 'tools/setup.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs', 'tools/doctor.mjs',
'tools/crop-board-atlas.mjs', 'tools/assets/plan.mjs', 'tools/assets/local-enemy-spines.json', 'tools/local-extract/extract.py',
'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'scripts/start.sh', 'scripts/start-windows.bat', 'scripts/launch.mjs',
'scripts/install-service-windows.ps1', 'docs/PLAYING.md', 'docs/DEPLOY.md', ...RUNTIME_RESEARCH, 'package.json',
'package-lock.json', 'LICENSE', 'NOTICE.md', 'THIRD-PARTY-NOTICES.md', 'README.md', 'CHANGELOG.md',
// out
'test/version.test.js', 'tools/golden.mjs', 'tools/build-data.mjs', 'tools/check-imports.mjs', 'tools/package.mjs',
'tools/i18n/fallback-remake.json', 'scripts/make-windows-bundle.mjs', 'docs/DESIGN.md', 'docs/WINDOWS.md', 'docs/ASSETS.md',
'docs/img/combat.jpg', 'docs/research/00-INDEX.md', 'docs/research/01-core-data.json', 'docs/research/03-operators.md',
'docs/research/10-networking-hosting.md', 'public/dev/game-mock.html', 'public/assets/x.png', 'public/fonts/fonts.css',
'public/vendor/pixi.min.js', 'data/local-assets.json', 'handoff/HANDOFF.md', '.github/workflows/ci.yml', 'types/core.js',
'eslint.config.js', 'jsconfig.json', 'Dockerfile', '.dockerignore', '.gitignore', '.gitattributes', 'AGENTS.md', '.env',
'server/.env.production', 'pv/clip.mp4', '3,9,11回合情况/note.txt', 'review/a.md', '.cache/x', 'server/__pycache__/a.pyc',
'public/.DS_Store', 'tools/local-extract/.venv-extract/x.py', 'node_modules/ws/index.js',
]);
for (const f of ['server/index.js', 'public/i18n/en.json', 'data/i18n/en.json', 'tools/assets/local-enemy-spines.json',
'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'scripts/install-service-windows.ps1', 'docs/research/07-assets.json', 'CHANGELOG.md']) {
assert.ok(keep.includes(f), `keeps ${f}`);
}
assert.equal(keep.length, 34, keep.join(' '));
assert.equal(drop.length, 40, drop.join(' '));
assert.ok(!keep.some((f) => drop.includes(f)));
});
test('refusal list: 0.1.x\'s entries and what 0.2.0 leaves out, at the path or under it; .env and .venv anywhere', () => {
for (const r of ['pv', '3,9,11回合情况', 'review', 'docs/research/10-networking-hosting.md', '.cache', '.claude', '.git', 'logs',
'test/e2e/out', 'scripts/service.env.cmd', '.env', 'handoff', 'test', '.github']) assert.ok(REFUSE.includes(r), r);
for (const r of REFUSE) {
assert.ok(isRefused(r), r);
assert.ok(isRefused(`${r}/x`), `${r}/x`);
}
for (const p of ['data/.env', 'server/.env.local', '.venv-extract/lib/a.py', 'tools/.venv/x']) assert.ok(isRefused(p), p);
for (const p of ['server/index.js', 'docs/research/03-operators.json', 'testing.md', 'public/js/review.js']) assert.ok(!isRefused(p), p);
});
test('the real tracked tree: runtime in, the rest out; every shipped import, npm entry point, research table and spawned tool ships', () => {
const p = plan(ROOT, { lite: true, allowDirty: true, scan: false, measure: false });
assert.deepEqual(p.problems, []);
const got = new Set(p.files);
for (const f of ['server/index.js', 'tools/setup.mjs', 'tools/fetch-assets.mjs', 'scripts/start.sh', 'docs/PLAYING.md', 'docs/DEPLOY.md',
'package-lock.json', 'NOTICE.md', ...RUNTIME_RESEARCH]) assert.ok(got.has(f), f);
for (const f of ['tools/golden.mjs', 'tools/package.mjs', 'scripts/make-windows-bundle.mjs', 'docs/DESIGN.md', 'eslint.config.js', 'Dockerfile']) {
assert.ok(!got.has(f), f);
}
for (const f of got) assert.ok(!/^(?:test|handoff|\.github|types|public\/dev|docs\/img)\//.test(f), f);
const code = [...got].filter((f) => /\.(?:m?js|py)$/.test(f));
for (const f of code) {
const src = fs.readFileSync(path.join(ROOT, f), 'utf8');
// research tables named in shipped code (nodeData.js, fetch-assets.mjs, plan.mjs …)
for (const m of src.matchAll(/\b(\d\d-[a-z-]+\.json)\b/g)) assert.ok(got.has(`docs/research/${m[1]}`), `${f} names ${m[1]}`);
// files a shipped script starts or reads by path.join(ROOT, 'dir', …, 'file.ext') — setup's tools, launch's setup
for (const m of src.matchAll(/path\.join\(ROOT,\s*((?:'[^']+',\s*)*'[^']+\.[a-z]+')\)/g)) {
const rel = [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]).join('/');
if (/^(?:tools|scripts|server|shared|docs)\//.test(rel)) assert.ok(got.has(rel), `${f} starts ${rel}`);
}
}
});
test('import specifiers resolve like the server mounts; npm entry points must ship', () => {
assert.equal(resolveSpecifier('public/js/ui/a.js', '../b.js'), 'public/js/b.js');
assert.equal(resolveSpecifier('public/js/a.js', '/sim/battle.js?v=2'), 'server/sim/battle.js');
assert.equal(resolveSpecifier('public/js/a.js', '/shared/constants.js'), 'shared/constants.js');
assert.equal(resolveSpecifier('public/js/a.js', '/js/store.js'), 'public/js/store.js');
assert.equal(resolveSpecifier('server/a.js', 'node:fs'), null);
assert.equal(resolveSpecifier('server/a.js', 'ws'), null);
const pkg = { main: 'server/index.js', scripts: { start: 'node server/index.js', setup: 'node tools/setup.mjs', doctor: 'node tools/golden.mjs',
launch: 'node scripts/launch.mjs', postinstall: 'node tools/vendor.mjs', vendor: 'node tools/vendor.mjs', assets: 'node tools/vendor.mjs && node tools/fetch-assets.mjs' } };
const shipped = ['server/index.js', 'tools/setup.mjs', 'scripts/launch.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs'];
assert.deepEqual(entryProblems(pkg, shipped), ['npm run doctor: tools/golden.mjs is not shipped']);
assert.deepEqual(entryProblems({ ...pkg, scripts: { ...pkg.scripts, doctor: undefined } }, shipped), ['npm script "doctor" is missing']);
});
test('output directory: never the repository, inside it or a parent of it', () => {
assert.equal(packageOutIsUnsafe(ROOT, ROOT), true);
assert.equal(packageOutIsUnsafe(path.dirname(ROOT), ROOT), true);
assert.equal(packageOutIsUnsafe(path.join(ROOT, 'dist'), ROOT), true);
assert.equal(packageOutIsUnsafe(path.join(os.tmpdir(), 'sp-release'), ROOT), false);
});
// ---------------------------------------------------------------------------------------------------------------------
// A temporary checkout: tracked runtime and non-runtime files, git-ignored fake art (one listed file with a URL-encoded
// name, an orphan like 焰狐龙梓兰's old files, the local-client extraction, fonts, OS clutter), private files.
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
function fakeCheckout() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-test-'));
const put = (rel, body = `// ${rel}\n`) => {
fs.mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), body);
};
const scripts = { start: 'node server/index.js', setup: 'node tools/setup.mjs', doctor: 'node tools/doctor.mjs', launch: 'node scripts/launch.mjs',
postinstall: 'node tools/vendor.mjs', vendor: 'node tools/vendor.mjs', assets: 'node tools/vendor.mjs && node tools/fetch-assets.mjs',
test: 'node --test', golden: 'node tools/golden.mjs', package: 'node tools/package.mjs' };
put('package.json', JSON.stringify({ name: 'sp-test', version: '9.9.9', main: 'server/index.js', scripts, dependencies: {} }));
put('package-lock.json', JSON.stringify({ name: 'sp-test', version: '9.9.9', lockfileVersion: 3, packages: { '': { name: 'sp-test' } } }));
put('server/index.js', "import './sim/rng.js';\n");
put('server/sim/rng.js');
put('shared/constants.js');
put('public/index.html', '<!doctype html>\n');
put('public/js/main.js', "import { u } from './util.js';\nimport { h } from '../vendor/preact.module.js';\n");
put('public/js/util.js', 'export const u = 1;\n');
put('public/dev/mock.js');
put('data/chess.json', '{}\n');
put('data/assets.json', JSON.stringify({ chars: { a: { avatar: '/assets/char/a.png' } }, ui: { b: '/assets/ui/b%20c.png' }, fonts: { css: '/fonts/fonts.css' } }));
put('tools/setup.mjs', "import { n } from './assets/network.mjs';\n");
for (const f of ['tools/assets/network.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs', 'tools/doctor.mjs', 'tools/crop-board-atlas.mjs',
'tools/local-extract/extract.py', 'tools/golden.mjs', 'tools/build-data.mjs', 'tools/package.mjs', 'scripts/launch.mjs',
'scripts/make-windows-bundle.mjs', 'test/a.test.js', 'handoff/HANDOFF.md', '.github/workflows/ci.yml', 'types/core.js', 'eslint.config.js',
'Dockerfile', 'AGENTS.md', 'review/notes.md', 'docs/DESIGN.md', 'docs/research/00-INDEX.md', 'docs/research/10-networking-hosting.md']) put(f);
put('scripts/start.sh', '#!/usr/bin/env bash\nexec node scripts/launch.mjs\n');
fs.chmodSync(path.join(dir, 'scripts/start.sh'), 0o755);
for (const f of ['README.md', 'CHANGELOG.md', 'LICENSE', 'NOTICE.md', 'THIRD-PARTY-NOTICES.md', 'docs/PLAYING.md', 'docs/DEPLOY.md']) put(f, `# ${f}\n`);
for (const f of [...RUNTIME_RESEARCH, 'docs/research/01-core-data.json']) put(f, '{}\n');
put('docs/img/x.jpg', PNG);
put('.gitignore', 'public/assets/\npublic/fonts/\npublic/vendor/\ndata/local-assets.json\n.cache/\npv/\n');
// git-ignored: the art, the local extraction, fonts, caches, the promo folder
put('public/assets/char/a.png', PNG);
put('public/assets/ui/b c.png', PNG);
put('public/assets/char/char_1048_orchd2.png', PNG);
put('public/assets/local/g/x.webp', PNG);
put('public/assets/local/g/x.png', PNG);
put('public/assets/.DS_Store', 'junk');
put('public/fonts/fonts.css', '@font-face{}\n');
put('public/fonts/x.woff2', PNG);
put('data/local-assets.json', JSON.stringify({ version: 1, groups: { g: { x: { path: '/assets/local/g/x.webp' } } } }));
put('.cache/assets-report.json', '{}');
put('pv/clip.txt', 'promo');
const git = (...args) => spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
assert.equal(git('init', '-q').status, 0);
assert.equal(git('add', '-A').status, 0);
return { dir, put, rm: (rel) => fs.rmSync(path.join(dir, rel), { force: true }) };
}
const runTool = (args) => spawnSync(process.execPath, [TOOL, ...args], { encoding: 'utf8', env: { ...process.env, ...ENV }, maxBuffer: 16 * 1024 * 1024 });
const listed = (stdout) => stdout.split('\n').filter((l) => l.startsWith('file ')).map((l) => l.slice(5));
const SHIPPED_TRACKED = ['CHANGELOG.md', 'LICENSE', 'NOTICE.md', 'README.md', 'THIRD-PARTY-NOTICES.md', 'data/assets.json', 'data/chess.json',
'docs/DEPLOY.md', 'docs/PLAYING.md', ...RUNTIME_RESEARCH, 'package-lock.json', 'package.json', 'public/index.html', 'public/js/main.js',
'public/js/util.js', 'scripts/launch.mjs', 'scripts/start.sh', 'server/index.js', 'server/sim/rng.js', 'shared/constants.js',
'tools/assets/network.mjs', 'tools/crop-board-atlas.mjs', 'tools/doctor.mjs', 'tools/fetch-assets.mjs', 'tools/local-extract/extract.py',
'tools/setup.mjs', 'tools/vendor.mjs'].sort();
const ART = ['data/local-assets.json', 'public/assets/char/a.png', 'public/assets/local/g/x.png', 'public/assets/local/g/x.webp',
'public/assets/ui/b c.png', 'public/fonts/fonts.css', 'public/fonts/x.woff2'];
test('dry-run on a temporary checkout: the full and the lite file lists, the summary, nothing written', () => {
const { dir } = fakeCheckout();
try {
const before = fs.readdirSync(dir).sort();
const full = runTool(['--root', dir, '--dry-run', '--list', '--allow-dirty']);
assert.equal(full.status, 0, full.stdout + full.stderr);
assert.deepEqual(listed(full.stdout).sort(), [...SHIPPED_TRACKED, ...ART].sort());
assert.match(full.stdout, /^package: full · version 9\.9\.9$/m);
assert.match(full.stdout, /^left out art: 1 files under public\/assets that nothing lists/m, 'the orphan (an excluded operator\'s old file)');
assert.match(full.stdout, /^left out: .*test\/ 1 .*$/m);
assert.match(full.stdout, /^checks: ok$/m);
const lite = runTool(['--root', dir, '--dry-run', '--list', '--lite', '--allow-dirty']);
assert.equal(lite.status, 0, lite.stdout + lite.stderr);
assert.deepEqual(listed(lite.stdout), SHIPPED_TRACKED, 'lite: the same runtime files, no art, fonts or local manifest');
assert.match(lite.stdout, /^package: lite \(no game art\)/m);
assert.deepEqual(fs.readdirSync(dir).sort(), before, 'a dry-run writes nothing');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('scans and guards: home paths and the account name in shipped files (art too), missing art, a stray import, uncommitted files', () => {
const { dir, put, rm } = fakeCheckout();
const check = (opts = {}) => plan(dir, { allowDirty: true, env: ENV, ...opts }).problems;
try {
assert.deepEqual(check(), []);
assert.deepEqual(check({ lite: true }), []);
put('public/js/util.js', 'export const u = "/Users/someone/game";\n');
assert.deepEqual(check(), ['personal info: public/js/util.js (home-directory path)']);
put('public/js/util.js', 'export const u = 1;\n');
put('data/chess.json', '{"p": "C:\\\\Users\\\\Someone\\\\x"}\n');
assert.deepEqual(check({ lite: true }), ['personal info: data/chess.json (home-directory path)'], 'Windows path, JSON-escaped');
put('data/chess.json', '{}\n');
put('public/assets/char/a.png', Buffer.concat([PNG, Buffer.from('..PlantedName..')]));
assert.deepEqual(check(), ['personal info: public/assets/char/a.png (account name)'], 'binary art, any case');
assert.deepEqual(check({ lite: true }), [], 'the lite zip has no art');
put('public/assets/char/a.png', PNG);
put('test/a.test.js', '// /Users/someone plantedname\n');
assert.deepEqual(check(), [], 'files that never ship are not scanned');
rm('public/assets/ui/b c.png');
assert.match(check().join('\n'), /^missing art: public\/assets\/ui\/b c\.png/);
assert.deepEqual(check({ lite: true }), []);
put('public/assets/ui/b c.png', PNG);
put('public/js/main.js', "import { u } from './util.js';\nimport '../dev/mock.js';\n");
assert.deepEqual(check({ lite: true }), ['public/js/main.js:2 imports ../dev/mock.js (not shipped)']);
assert.ok(plan(dir, { lite: true, env: ENV, scan: false }).problems.some((x) => x.startsWith('uncommitted change: server/index.js')),
'staged, never committed: refused without --allow-dirty');
const cli = runTool(['--root', dir, '--dry-run', '--lite', '--allow-dirty']);
assert.equal(cli.status, 1, 'a problem fails the dry-run');
assert.match(cli.stdout, /^PROBLEMS \(1\):\n {2}public\/js\/main\.js:2 imports \.\.\/dev\/mock\.js/m);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// tools/package.mjs zips with `zip`, else with a bsdtar `tar --format zip` (Windows 10+, macOS; GNU tar cannot)
const hasZipTool = !spawnSync('zip', ['-v'], { encoding: 'utf8' }).error
|| /bsdtar/.test(spawnSync('tar', ['--version'], { encoding: 'utf8' }).stdout || '');
const hasUnzip = !spawnSync('unzip', ['-h'], { encoding: 'utf8' }).error;
test('a --no-install build of the temporary checkout zips exactly the plan in one Stronghold-Protocol/ folder', { skip: !hasZipTool && 'no zip / tar' }, () => {
const { dir } = fakeCheckout();
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-out-'));
try {
const r = runTool(['--root', dir, '--out', out, '--no-install', '--allow-dirty']);
assert.equal(r.status, 0, r.stdout + r.stderr);
const zip = path.join(out, `${FOLDER}-v9.9.9.zip`);
assert.ok(fs.statSync(zip).size > 0);
assert.deepEqual(fs.readdirSync(out), [path.basename(zip)], 'the stage is removed after zipping');
if (hasUnzip) {
const entries = spawnSync('unzip', ['-Z1', zip], { encoding: 'utf8' }).stdout.split('\n').filter((l) => l && !l.endsWith('/'));
assert.deepEqual(entries.sort(), [...SHIPPED_TRACKED, ...ART].map((f) => `${FOLDER}/${f}`).sort());
}
const again = runTool(['--root', dir, '--out', out, '--no-install', '--allow-dirty']);
assert.equal(again.status, 1, 'an existing zip is not replaced without --force');
assert.match(again.stderr, /already exists \(--force replaces it\)/);
const inRepo = runTool(['--root', dir, '--out', path.join(dir, 'dist'), '--no-install', '--allow-dirty', '--lite']);
assert.equal(inRepo.status, 1);
assert.match(inRepo.stderr, /refusing to write into the repository/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(out, { recursive: true, force: true });
}
});
test('the personal scan reads bytes, never reports the matched text, and skips files it cannot read', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-scan-'));
try {
fs.writeFileSync(path.join(dir, 'a.bin'), Buffer.from([0, 1, 2, ...Buffer.from('/home/someone'), 3]));
fs.writeFileSync(path.join(dir, 'b.txt'), 'see https://github.com/users/x and /usr/lib');
fs.writeFileSync(path.join(dir, 'c.txt'), 'Built by PLANTEDNAME');
assert.deepEqual(scanFiles(dir, ['a.bin', 'b.txt', 'c.txt', 'missing.txt'], { names: ['plantedname'] }),
['a.bin (home-directory path)', 'c.txt (account name)']);
assert.deepEqual(scanFiles(dir, ['a.bin', 'c.txt'], { home: false, names: [] }), [], 'node_modules: the names only');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});