Files
iptvnator/tools/packaging/asar-dependency-closure.test.mjs
T
4grayandClaude Opus 4.8 23ead63b1f fix(packaging): ship ms so the updater doesn't crash the app (#1103) (#1113)
* fix(packaging): ship `ms` so the updater doesn't crash the app (#1103)

The 0.22 AppImage crashed on launch with "Cannot find module 'ms'" after
the desktop updater landed (b1119189). electron-updater requires
`debug` -> `ms` unguarded at startup, but the packaged app.asar shipped
`debug` without `ms`.

Root cause: with pnpm's isolated node-linker, electron-builder 26 uses its
PnpmNodeModulesCollector, which builds the bundle from `pnpm list --json`.
pnpm deduplicates repeated packages there, so all but one `debug@4.4.3`
occurrence report empty `dependencies` — and the collector (unlike the npm
collector) has no implicit-dependency recovery, so it drops `ms` entirely.
It stayed latent because the only prior `debug` consumer (follow-redirects
via axios) guards its require in try/catch; electron-updater is the first
packaged module to hit it unguarded.

Fix: declare `ms` as a direct dependency so it becomes a top-level,
fully-expanded node in the collector's tree and is bundled. This keeps the
isolated pnpm layout intact — `node-linker=hoisted` was rejected because it
removes `node_modules/.pnpm`, which apps/electron-backend/build-embedded-mpv.js
scans to resolve @electron/node-gyp, breaking the native build on every
platform.

Also add a packaged-asar dependency-closure guard to
verify-electron-package-layout.mjs: it audits every package shipped in the
archive and fails if any non-optional dependency is missing, so this class
of regression is caught in CI. Logic is extracted to a unit-tested module.

Verified locally: repackaged app.asar now ships `ms`, the embedded-mpv
native build succeeds, and the closure guard reports 0 missing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(packaging): harden asar dependency-closure guard from review findings

Addresses review feedback on #1113 plus an adversarial-review finding:

- Walk every ancestor directory in resolvePackagedDependency (not just
  node_modules boundaries) so a dependency hoisted to the archive root
  resolves for packages under app subdirectories, matching Node's real
  resolution (Codex review).
- Skip dependencies also declared in peerDependencies: host-provided
  peers (e.g. electron) listed in both fields are not packaging defects
  (Greptile review).
- Fix a silent no-op on Windows: @electron/asar lists entries and
  resolves extractFile paths with the host separator, so the posix-only
  matching audited zero packages on the Windows CI leg. Listings are now
  normalized to posix and lookup paths converted back to the host
  separator (pathSep is injectable for tests).
- Reject vacuous passes structurally: inspectPackagedDependencyClosure
  now reports packageCount and manifestReadFailures, and the verifier
  errors when the audit saw no packages or failed to read manifests,
  so the guard can never silently audit nothing again.

Verified: 27 packaging tests pass; the real app.asar audits 235 packages
with 0 missing under both posix and simulated win32 IO; hiding `ms` from
a win32-shaped listing correctly flags it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:58:21 +02:00

287 lines
9.4 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
collectAsarPackageDirs,
findMissingPackagedDependencies,
inspectPackagedDependencyClosure,
resolvePackagedDependency,
} from './asar-dependency-closure.mjs';
/**
* Builds a `readManifest(dir)` backed by an in-memory map of
* `{ packageDir: manifest }`, mirroring how manifests are read from an asar.
*/
function manifestReader(manifests) {
return (packageDir) => manifests[packageDir] ?? null;
}
test('collectAsarPackageDirs keeps only genuine package roots', () => {
const dirs = collectAsarPackageDirs([
'/package.json',
'/node_modules/debug/package.json',
'/node_modules/debug/src/index.js',
'/node_modules/@angular/core/package.json',
'/node_modules/fast-uri/node_modules/uri-js/package.json',
// Nested manifests that are NOT packages must be ignored.
'/node_modules/fast-uri/benchmark/package.json',
'/node_modules/@angular/core/schematics/package.json',
]);
assert.ok(dirs.has('/node_modules/debug'));
assert.ok(dirs.has('/node_modules/@angular/core'));
assert.ok(dirs.has('/node_modules/fast-uri/node_modules/uri-js'));
assert.equal(dirs.has(''), false);
assert.equal(dirs.has('/node_modules/fast-uri/benchmark'), false);
assert.equal(dirs.has('/node_modules/@angular/core/schematics'), false);
});
test('resolvePackagedDependency walks node_modules boundaries upward', () => {
const dirs = new Set([
'/node_modules/ms',
'/node_modules/electron-updater/node_modules/lazy-val',
]);
// Hoisted to top level, requested from a deeply nested package.
assert.equal(
resolvePackagedDependency(
'/node_modules/electron-updater/node_modules/builder-util-runtime',
'ms',
dirs
),
'/node_modules/ms'
);
// Nested copy preferred over walking further up.
assert.equal(
resolvePackagedDependency(
'/node_modules/electron-updater',
'lazy-val',
dirs
),
'/node_modules/electron-updater/node_modules/lazy-val'
);
assert.equal(resolvePackagedDependency('', 'missing', dirs), null);
});
test('resolvePackagedDependency reaches root node_modules from app subdirectories', () => {
// A package under an app subdirectory (e.g. /electron-backend/node_modules)
// must still resolve a dependency hoisted to the archive root, exactly as
// Node's resolver walks every ancestor directory.
const dirs = new Set([
'/electron-backend/node_modules/foo',
'/node_modules/bar',
]);
assert.equal(
resolvePackagedDependency(
'/electron-backend/node_modules/foo',
'bar',
dirs
),
'/node_modules/bar'
);
});
test('reports a deduplicated transitive dependency dropped from the archive (issue #1103)', () => {
// `debug` is packaged but its transitive `ms` was dropped by the collector.
const packageDirs = new Set([
'',
'/node_modules/electron-updater',
'/node_modules/debug',
]);
const manifests = {
'': { dependencies: { 'electron-updater': '6.8.9' } },
'/node_modules/electron-updater': { dependencies: { debug: '4.4.3' } },
'/node_modules/debug': { dependencies: { ms: '2.1.3' } },
};
const missing = findMissingPackagedDependencies(
packageDirs,
manifestReader(manifests)
);
assert.deepEqual(missing, [
{ dependency: 'ms', requiredBy: '/node_modules/debug' },
]);
});
test('passes when the full runtime closure is present', () => {
const packageDirs = new Set([
'',
'/node_modules/electron-updater',
'/node_modules/debug',
'/node_modules/ms',
]);
const manifests = {
'': { dependencies: { 'electron-updater': '6.8.9' } },
'/node_modules/electron-updater': { dependencies: { debug: '4.4.3' } },
'/node_modules/debug': { dependencies: { ms: '2.1.3' } },
'/node_modules/ms': {},
};
assert.deepEqual(
findMissingPackagedDependencies(packageDirs, manifestReader(manifests)),
[]
);
});
test('does not flag frontend-only deps the app root declares but never ships', () => {
// The app package.json lists Angular etc. (compiled into the web bundle),
// which are intentionally absent from the shipped runtime node_modules.
const packageDirs = new Set(['', '/node_modules/electron-updater']);
const manifests = {
'': {
dependencies: {
'@angular/core': '21.2.9',
'electron-updater': '6.8.9',
},
},
'/node_modules/electron-updater': {},
};
assert.deepEqual(
findMissingPackagedDependencies(packageDirs, manifestReader(manifests)),
[]
);
});
test('ignores missing optional dependencies', () => {
const packageDirs = new Set(['', '/node_modules/pkg']);
const manifests = {
'': { dependencies: { pkg: '1.0.0' } },
'/node_modules/pkg': {
dependencies: { fsevents: '2.3.0' },
optionalDependencies: { fsevents: '2.3.0' },
},
};
assert.deepEqual(
findMissingPackagedDependencies(packageDirs, manifestReader(manifests)),
[]
);
});
test('ignores host-provided deps declared in both dependencies and peerDependencies', () => {
// e.g. a package listing `electron` in dependencies for dev installs while
// the Electron runtime provides it — never shipped inside app.asar.
const packageDirs = new Set(['', '/node_modules/pkg']);
const manifests = {
'': { dependencies: { pkg: '1.0.0' } },
'/node_modules/pkg': {
dependencies: { electron: '41.0.0' },
peerDependencies: { electron: '>=30' },
},
};
assert.deepEqual(
findMissingPackagedDependencies(packageDirs, manifestReader(manifests)),
[]
);
});
test('tolerates dependency cycles without infinite recursion', () => {
const packageDirs = new Set(['', '/node_modules/a', '/node_modules/b']);
const manifests = {
'': { dependencies: { a: '1.0.0' } },
'/node_modules/a': { dependencies: { b: '1.0.0' } },
'/node_modules/b': { dependencies: { a: '1.0.0' } },
};
assert.deepEqual(
findMissingPackagedDependencies(packageDirs, manifestReader(manifests)),
[]
);
});
test('inspectPackagedDependencyClosure wires injected asar IO', () => {
const files = {
'package.json': { dependencies: { debug: '4.4.3' } },
'node_modules/debug/package.json': { dependencies: { ms: '2.1.3' } },
};
const listPackage = () => [
'/package.json',
'/node_modules/debug/package.json',
];
const extractFile = (_asarPath, relativePath) => {
const manifest = files[relativePath];
if (!manifest) {
throw new Error(`not found: ${relativePath}`);
}
return Buffer.from(JSON.stringify(manifest));
};
const { missing, packageCount, manifestReadFailures } =
inspectPackagedDependencyClosure('app.asar', {
listPackage,
extractFile,
pathSep: '/',
});
assert.deepEqual(missing, [
{ dependency: 'ms', requiredBy: '/node_modules/debug' },
]);
assert.equal(packageCount, 1);
assert.deepEqual(manifestReadFailures, []);
});
test('inspectPackagedDependencyClosure handles Windows-separator asar IO', () => {
// @electron/asar builds listing entries with the host separator and
// resolves extractFile paths by splitting on path.sep — on Windows both
// are backslash-based. The guard must normalize listings to posix and
// hand extractFile backslash paths, otherwise it audits nothing.
const files = {
'package.json': { dependencies: { debug: '4.4.3' } },
'node_modules\\debug\\package.json': {
dependencies: { ms: '2.1.3' },
},
};
const listPackage = () => [
'\\package.json',
'\\node_modules\\debug\\package.json',
'\\node_modules\\debug\\src\\index.js',
];
const extractFile = (_asarPath, relativePath) => {
// Mimic asar on win32: forward-slash lookups do not resolve.
const manifest = files[relativePath];
if (!manifest) {
throw new Error(`${relativePath} was not found in this archive`);
}
return Buffer.from(JSON.stringify(manifest));
};
const { missing, packageCount, manifestReadFailures } =
inspectPackagedDependencyClosure('app.asar', {
listPackage,
extractFile,
pathSep: '\\',
});
assert.equal(packageCount, 1);
assert.deepEqual(manifestReadFailures, []);
assert.deepEqual(missing, [
{ dependency: 'ms', requiredBy: '/node_modules/debug' },
]);
});
test('inspectPackagedDependencyClosure surfaces manifest read failures instead of swallowing them', () => {
const listPackage = () => ['/node_modules/broken/package.json'];
const extractFile = () => {
throw new Error('corrupt entry');
};
const { missing, packageCount, manifestReadFailures } =
inspectPackagedDependencyClosure('app.asar', {
listPackage,
extractFile,
pathSep: '/',
});
assert.deepEqual(missing, []);
assert.equal(packageCount, 1);
assert.deepEqual(manifestReadFailures, [
{ packageDir: '/node_modules/broken', message: 'corrupt entry' },
]);
});