Files
StepForge/tests/unit/electron-launcher.test.js
T
TylerandClaude Fable 5 0f966a5fd0
Template tests / tests (pull_request) Failing after 1m26s
Pin the Node toolchain, remove runtime npm repair, make CI and E2E truthful
Phase 0 of the improvement plan (ai_prompts/prompt4.md): make the baseline
reproducible and stop the test runner from masking real failures.

- Pin Node >= 22.12 (engines + .nvmrc + engine-strict); every entry point
  fails fast with clear guidance instead of dying late with ERR_REQUIRE_ESM
  inside the packaging dependency graph.
- electron-launcher.js is diagnostics-only: all runtime npm install/rebuild/
  repair paths are removed. npm ci on the pinned toolchain is the only
  supported install path (README + GETTING_STARTED updated).
- Refuse to silently launch unsandboxed on Linux: --no-sandbox now requires
  an explicit STEPFORGE_ALLOW_NO_SANDBOX/ELECTRON_DISABLE_SANDBOX opt-in and
  is otherwise a hard error with actionable fixes; user-namespace sandboxing
  is detected and preferred.
- Click-capture E2E no longer converts startup crashes into "SKIPPED": the
  only allowed skip is the upfront absence of a display server. A missing
  shared library or crash now fails with the startup log. Same guard added
  to the startup smoke check.
- GitHub CI: run on pull_request, pin Node from .nvmrc, drop the macOS matrix
  entry (not a support target), and audit production and full dependency
  trees as separate signals. Gitea CI: pull_request trigger + pinned Node.
- Refresh package-lock on Node 22/npm 10 and remediate the form-data and
  undici advisories (npm audit: 0 vulnerabilities, prod and full tree).
- Stop tracking generated machine-specific build reports
  (build/build_report.md, build/artifacts_manifest.json).

Verified: 203 unit tests pass; repo-structure, startup-smoke, unit-workflows,
sample-artifacts, and build-release checks pass locally with a real Electron
launch. The click self-test now truthfully reports the pre-existing Linux
arm/debounce capture failures (also red on Gitea CI main run 177) instead of
hiding behind SKIPPED; that defect is scheduled for the capture-fix PR.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 13:09:51 -07:00

160 lines
5.3 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {
buildMissingElectronError,
linuxSandboxLaunchArgs,
noSandboxExplicitlyAllowed,
resolveElectronBinary,
} = require('../../scripts/electron-launcher');
const { checkNodeVersion, assertSupportedNode } = require('../../scripts/check-node-version');
const { makeTmpDir, rmrf } = require('./helpers');
test('resolves the Electron binary from path.txt when present', (t) => {
const root = makeTmpDir('electron-path-hint');
t.after(() => rmrf(root));
fs.mkdirSync(path.join(root, 'dist'), { recursive: true });
fs.writeFileSync(path.join(root, 'path.txt'), 'electron.exe\n');
fs.writeFileSync(path.join(root, 'dist', 'electron.exe'), 'binary');
assert.equal(
resolveElectronBinary({ packageRoot: root, platform: 'win32' }),
path.join(root, 'dist', 'electron.exe')
);
});
test('falls back to the platform binary when path.txt is absent', (t) => {
const root = makeTmpDir('electron-platform-fallback');
t.after(() => rmrf(root));
fs.mkdirSync(path.join(root, 'dist'), { recursive: true });
fs.writeFileSync(path.join(root, 'dist', 'electron.exe'), 'binary');
assert.equal(
resolveElectronBinary({ packageRoot: root, platform: 'win32' }),
path.join(root, 'dist', 'electron.exe')
);
});
test('never runs npm when the runtime is missing: fails with npm ci diagnostics', (t) => {
const root = makeTmpDir('electron-missing');
t.after(() => rmrf(root));
fs.mkdirSync(path.join(root, 'dist'), { recursive: true });
// Point npm_execpath at a script that would create the binary if executed.
// The launcher must NOT execute it: runtime self-repair is forbidden.
const trapNpmCli = path.join(root, 'trap-npm-cli.js');
fs.writeFileSync(
trapNpmCli,
[
"const fs = require('node:fs');",
"const path = require('node:path');",
"fs.writeFileSync(path.join(__dirname, 'npm-was-invoked'), '1');",
].join('\n')
);
const originalNpmExecPath = process.env.npm_execpath;
process.env.npm_execpath = trapNpmCli;
t.after(() => {
if (originalNpmExecPath === undefined) delete process.env.npm_execpath;
else process.env.npm_execpath = originalNpmExecPath;
});
assert.throws(
() => resolveElectronBinary({ packageRoot: root, projectRoot: root, platform: 'win32' }),
/npm ci/
);
assert.equal(fs.existsSync(path.join(root, 'npm-was-invoked')), false);
});
test('missing electron package fails with npm ci diagnostics, not an install', (t) => {
const root = makeTmpDir('electron-no-package');
t.after(() => rmrf(root));
assert.throws(
() => resolveElectronBinary({ packageRoot: null, projectRoot: root, platform: 'win32' }),
/never installs dependencies at runtime[\s\S]*npm ci/
);
});
test('missing runtime error message explains recovery without runtime installs', (t) => {
const root = makeTmpDir('electron-missing-msg');
t.after(() => rmrf(root));
const message = buildMissingElectronError({
packageRoot: root,
distDir: path.join(root, 'dist'),
candidatePaths: [path.join(root, 'dist', 'electron.exe')],
});
assert.match(message, /Electron could not be started/);
assert.match(message, /npm ci/);
assert.doesNotMatch(message, /npm install/);
});
test('refuses an unsandboxed Linux launch unless explicitly allowed', () => {
assert.throws(
() =>
linuxSandboxLaunchArgs({
electronPath: '/tmp/stepforge/node_modules/electron/dist/electron',
platform: 'linux',
statSync: () => ({ uid: 1000, mode: 0o100755 }),
env: {},
userNamespaces: () => false,
}),
/refuses to silently launch unsandboxed[\s\S]*STEPFORGE_ALLOW_NO_SANDBOX/
);
});
test('allows --no-sandbox only with an explicit dev/CI opt-in', () => {
for (const env of [
{ STEPFORGE_ALLOW_NO_SANDBOX: '1' },
{ ELECTRON_DISABLE_SANDBOX: '1' },
]) {
const args = linuxSandboxLaunchArgs({
electronPath: '/tmp/stepforge/node_modules/electron/dist/electron',
platform: 'linux',
statSync: () => ({ uid: 1000, mode: 0o100755 }),
env,
userNamespaces: () => false,
});
assert.deepEqual(args, ['--no-sandbox']);
}
assert.equal(noSandboxExplicitlyAllowed({ STEPFORGE_ALLOW_NO_SANDBOX: '1' }), true);
assert.equal(noSandboxExplicitlyAllowed({}), false);
});
test('keeps the sandbox enabled when the Linux helper is root-owned and setuid', () => {
const args = linuxSandboxLaunchArgs({
electronPath: '/tmp/stepforge/node_modules/electron/dist/electron',
platform: 'linux',
statSync: () => ({ uid: 0, mode: 0o104755 }),
env: {},
});
assert.deepEqual(args, []);
});
test('non-Linux platforms never receive sandbox launch flags', () => {
assert.deepEqual(linuxSandboxLaunchArgs({ platform: 'win32', env: {} }), []);
assert.deepEqual(linuxSandboxLaunchArgs({ platform: 'darwin', env: {} }), []);
});
test('node toolchain check compares against package.json engines', () => {
const ok = checkNodeVersion({ currentVersion: '99.0.0' });
assert.equal(ok.ok, true);
const tooOld = checkNodeVersion({ currentVersion: '18.19.1' });
assert.equal(tooOld.ok, false);
assert.match(tooOld.required, /^\d+\.\d+\.\d+$/);
assert.throws(
() => assertSupportedNode({ currentVersion: '18.19.1' }),
/requires Node .* or newer/
);
});