Files
pbakaus_impeccable/tests/zip.test.mjs
T
c27a75ad41 fix(cli): replace extract-zip with fflate to fix silent install on Node v24.16.0+ (#253)
On Node v24.16.0 / v26.1.0+, `impeccable install` printed "Downloading
impeccable skills...", exited 0, and installed nothing. A Node streams
regression (nodejs/node#63487) made pause()/resume() no-ops on destroyed
streams, stalling extract-zip's yauzl/fd-slicer read stack partway through;
its promise never settled and the process exited clean with no error.

Swap extract-zip for fflate across both extraction call sites
(downloadAndExtractBundle, copyOrExtractLocalBundle) via a new extractZip
helper. fflate decompresses from an in-memory buffer and never touches the
fs stream path, so it is immune on every Node version. It is pure JS with
zero dependencies, so the Windows fix from #198 (no `unzip` binary) holds.
Unlike extract-zip, fflate is actively maintained.

Because extractZip writes entries itself, it guards against zip-slip (`../`
entries escaping the target dir). Tests add a many-file regression guard
(fails on partial extraction) and a zip-slip rejection test.

Verified end-to-end: the real 1,194-file universal bundle extracts and
installs completely.

Fixes #250.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 20:33:43 -07:00

174 lines
7.8 KiB
JavaScript

/**
* Unit tests for the release-bundle zip writer (scripts/lib/zip.js).
* Run: node --test tests/zip.test.mjs
*
* Regression guard for the silent-broken-bundle outage: archiver v8's ESM
* change made createProviderZip fail without throwing, so the build shipped a
* 0-byte universal.zip and every `npx impeccable install` failed with
* "End-of-central-directory signature not found". Nothing covered the zip
* writer, so the suite stayed green. These tests exercise the real writer and
* round-trip through the same unpacker the CLI uses (extractZip, backed by
* fflate). The many-file extraction test additionally guards the Node v24.16.0
* / v26.1.0+ silent partial-extraction regression (nodejs/node#63487) that
* made `npx impeccable install` exit 0 after writing only a fraction of the
* bundle.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, statSync, rmSync, readFileSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createProviderZip, createAllZips } from '../scripts/lib/zip.js';
import { extractZip } from '../cli/bin/commands/skills.mjs';
function makeUniversalTree(distDir) {
const skillDir = join(distDir, 'universal', 'skills', 'impeccable');
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: impeccable\n---\nhello\n');
mkdirSync(join(distDir, 'universal', '.claude'), { recursive: true });
writeFileSync(join(distDir, 'universal', '.claude', 'settings.json'), '{}\n');
}
/**
* Build a representative multi-provider universal tree carrying far more
* entries than the Node v24.16.0 extract-zip stall point (~31 files). The
* partial-extraction regression test relies on this so a regression in the
* unpacker fails the count assertion instead of shipping silently. Returns the
* number of files written.
*/
function makeLargeUniversalTree(distDir, { providers = ['.claude', '.cursor', '.agents', '.gemini', '.github', '.kiro'], scriptCount = 8, extraSkills = ['audit', 'polish'] } = {}) {
let files = 0;
for (const provider of providers) {
// The impeccable skill ships a scripts/ dir with many files, like the real
// bundle. This is where most entries live.
const scriptsDir = join(distDir, 'universal', provider, 'skills', 'impeccable', 'scripts');
mkdirSync(scriptsDir, { recursive: true });
writeFileSync(join(distDir, 'universal', provider, 'skills', 'impeccable', 'SKILL.md'), `---\nname: impeccable\n---\nprovider ${provider}\n`);
files += 1;
for (let i = 0; i < scriptCount; i++) {
writeFileSync(join(scriptsDir, `script-${i}.mjs`), `// ${provider} script ${i}\nexport default ${i};\n`);
files += 1;
}
// Sibling skills with a SKILL.md + a reference file each.
for (const name of extraSkills) {
const refDir = join(distDir, 'universal', provider, 'skills', name, 'reference');
mkdirSync(refDir, { recursive: true });
writeFileSync(join(distDir, 'universal', provider, 'skills', name, 'SKILL.md'), `---\nname: ${name}\n---\n`);
writeFileSync(join(refDir, `${name}.md`), `# ${name} reference\n`);
files += 2;
}
// Provider root config (mirrors .claude/settings.json, .cursor/hooks.json).
writeFileSync(join(distDir, 'universal', provider, 'config.json'), '{}\n');
files += 1;
}
return files;
}
/** Count regular files under a directory tree (used to assert full extraction). */
function countFiles(dir) {
let count = 0;
const walk = (d) => {
for (const entry of readdirSync(d, { withFileTypes: true })) {
if (entry.isDirectory()) walk(join(d, entry.name));
else count += 1;
}
};
walk(dir);
return count;
}
describe('release bundle zip writer', () => {
it('createAllZips produces a non-empty universal.zip that unpacks to the skill tree', async () => {
const dist = mkdtempSync(join(tmpdir(), 'imp-zip-'));
makeUniversalTree(dist);
await createAllZips(dist);
const zipPath = join(dist, 'universal.zip');
assert.ok(existsSync(zipPath), 'universal.zip was not created');
assert.ok(statSync(zipPath).size > 0, 'universal.zip is empty (0 bytes)');
// Round-trip through the same unpacker the CLI uses (extractZip). The CLI
// downloads this exact artifact and extractZip()s it.
const out = mkdtempSync(join(tmpdir(), 'imp-unzip-'));
await extractZip(zipPath, out);
const skillMd = join(out, 'skills', 'impeccable', 'SKILL.md');
assert.ok(existsSync(skillMd), 'unpacked bundle is missing skills/impeccable/SKILL.md');
assert.match(readFileSync(skillMd, 'utf8'), /name: impeccable/);
rmSync(dist, { recursive: true, force: true });
rmSync(out, { recursive: true, force: true });
});
it('REGRESSION: extracts every entry of a many-file bundle (no silent partial extraction on Node v24.16.0+)', async () => {
// Guards nodejs/node#63487: extract-zip's yauzl/fd-slicer read stack stalls
// on Node v24.16.0 / v26.1.0+ (pause/resume on a destroyed stream became a
// no-op), so extraction stops early, its promise never settles, and --
// because nothing else keeps the event loop alive -- `npx impeccable install`
// exits 0 with no error, silently installing a fraction of the bundle. This
// fixture carries 84 files (well past the ~31 entry stall point), so a
// unpacker that stops early fails the count assertion here instead of
// shipping silently. fflate decompresses in-memory and is immune.
const dist = mkdtempSync(join(tmpdir(), 'imp-zip-large-'));
const written = makeLargeUniversalTree(dist);
await createAllZips(dist);
const zipPath = join(dist, 'universal.zip');
assert.ok(existsSync(zipPath), 'universal.zip was not created');
assert.ok(statSync(zipPath).size > 0, 'universal.zip is empty (0 bytes)');
// Round-trip through the exact code path downloadAndExtractBundle runs.
const out = mkdtempSync(join(tmpdir(), 'imp-unzip-large-'));
await extractZip(zipPath, out);
const extracted = countFiles(out);
assert.equal(extracted, written, `partial extraction: only ${extracted} of ${written} files unpacked`);
rmSync(dist, { recursive: true, force: true });
rmSync(out, { recursive: true, force: true });
});
it('rejects a zip entry whose path escapes the target dir (zip-slip)', async () => {
// extractZip writes entries itself, so it must refuse `../` traversal that a
// malicious or malformed archive could use to land files outside targetDir.
const dist = mkdtempSync(join(tmpdir(), 'imp-zip-slip-'));
const { zipSync, strToU8 } = await import('fflate');
const zipped = zipSync({ '../escaped.txt': strToU8('pwned\n') });
const zipPath = join(dist, 'evil.zip');
writeFileSync(zipPath, zipped);
const out = mkdtempSync(join(tmpdir(), 'imp-unzip-slip-'));
await assert.rejects(() => extractZip(zipPath, out), /outside target dir/);
assert.ok(!existsSync(join(dist, 'escaped.txt')), 'zip-slip entry escaped the target dir');
rmSync(dist, { recursive: true, force: true });
rmSync(out, { recursive: true, force: true });
});
it('createProviderZip throws when the source has no files (no silent 0-byte artifact)', async () => {
const dist = mkdtempSync(join(tmpdir(), 'imp-zip-empty-'));
mkdirSync(join(dist, 'universal'), { recursive: true });
await assert.rejects(
() => createProviderZip(join(dist, 'universal'), dist, 'universal'),
/no entries|0 bytes/i,
);
rmSync(dist, { recursive: true, force: true });
});
it('createProviderZip throws when the source directory is missing', async () => {
const dist = mkdtempSync(join(tmpdir(), 'imp-zip-missing-'));
await assert.rejects(
() => createProviderZip(join(dist, 'does-not-exist'), dist, 'universal'),
/not found/i,
);
rmSync(dist, { recursive: true, force: true });
});
});