Files
SeedPass/js/scripts/smoke-cli.mjs
T
thePR0M3TH3ANandClaude Opus 5 864229bad1 Add local CI: a preflight that refuses to lie, and a gate under a minute
Today cost roughly sixteen push-and-wait cycles discovering serially what one
local run could have surfaced at once. This is the local half of that, built
around one rule: a local gate is worth having only if it reaches the same
verdict CI would.

THE PREFLIGHT IS THE POINT

scripts/ci_preflight.py checks that this machine agrees with the lockfiles,
and refuses everything downstream when it does not. That is not theoretical
hygiene. Run right now it says:

    black 26.1.0 != locked 24.10.0
    pytest 9.0.2 != locked 8.4.1
    coverage 7.10.1 != locked 7.10.2

which is exactly today's failure: `poetry run black --check .` passes here and
fails on GitHub, and an earlier commit titled "satisfy the black gate" had
already reformatted five files with the WRONG black, making the gate worse
while looking like a fix. A green local gate that disagrees with CI is not a
weak check, it is a harmful one, because people stop reading the real result.

It is stdlib-only and run by the system interpreter, so it works when the
environment it is checking is broken -- which is when it is needed. And it
only ever CHECKS. scripts/dev_sync.sh repairs. A gate that silently fixes
your environment hides the drift it exists to surface.

TOOLS COME FROM THE REPOSITORY, NEVER FROM PATH

Every invocation is `poetry run ...` or `pnpm -C js ...`. A bare tool name is
precisely how a local gate ends up disagreeing with CI, so the preflight
inspects those environments rather than PATH.

THE BUDGET IS THE DESIGN

Measured here: preflight 2.6s, lock check 0.6s, black 2.5s, typecheck 7.3s,
core vitest 24.0s, determinism subset 5.4s — about 44s. What that excludes
was measured too: the cli suite alone is 176.3s, three times the whole
budget, and full pytest is ~350s. Those go in ci_parity.sh, which is what you
run before calling a branch ready. A hook that takes minutes becomes
`git push --no-verify` inside a week, at which point it is a control everyone
believes in and nobody runs.

The cli suite is slow for a good reason — real daemons, real sockets, real
subprocesses instead of mocks — so the answer is to run it less often, not to
make it lie faster.

THE SMOKE TEST IS NOW NODE, NOT BASH

js/scripts/smoke-cli.mjs replaces an inline shell test that used mktemp,
export and `test -n`, and therefore could only run on Unix — leaving the one
artifact users actually execute never smoke-tested on Windows. Every Windows
failure today was a Unix assumption leaking. It also checks more than the
original: that the bundle runs with no node_modules in sight, that the secret
honours the requested length, and that revealing twice yields the same value,
because determinism is the product rather than an incidental property.

The lint step is written but commented out: `pnpm -C js lint` still exits
non-zero on 35 known assertions, and shipping a red check into the push gate
is how gates get routed around. It goes in when it goes green.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-20 13:21:38 -04:00

121 lines
4.4 KiB
JavaScript

/**
* Smoke-test the shipped CLI bundle, on any operating system.
*
* WHY THIS IS NOT A SHELL SCRIPT
*
* The version this replaces lived inline in ts-parity.yml and used mktemp,
* export and `test -n`, so it could only ever run on Unix -- which meant the
* one artifact users actually execute was never smoke-tested on Windows at
* all. That mattered: on 2026-08-20 every Windows CI failure was a Unix
* assumption leaking, from MSYS2 shadowing `python` to pwsh swallowing exit
* codes to shlex eating the backslashes out of Windows paths. Written in
* Node, the same file runs on Linux, macOS, Windows, GitHub and a laptop.
*
* WHAT IT PROVES
*
* 1. The bundle runs with NO node_modules anywhere near it. It executes from
* a fresh temp directory, so a dependency that failed to bundle shows up
* here rather than in a user's install.
* 2. A vault can be created and an entry added and revealed end to end.
* 3. The revealed secret is DETERMINISTIC -- revealing twice gives the same
* value. For a deterministic password manager that is the product, not an
* incidental property, and "some output appeared" would not have caught a
* derivation that silently changed.
* 4. The secret honours the requested length.
*/
import { spawnSync } from "node:child_process";
import { mkdtempSync, existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import process from "node:process";
const HERE = dirname(fileURLToPath(import.meta.url));
const BUNDLE = resolve(HERE, "..", "packages", "cli", "dist", "seedpass-js.mjs");
// The 12-word all-`abandon` vector, used throughout the fixtures.
const MNEMONIC =
"abandon abandon abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon about";
const PASSWORD = "ci-smoke-password";
const LENGTH = 16;
function fail(message) {
console.error(`\nsmoke-cli: ${message}`);
process.exit(1);
}
/** Run the bundle from `cwd`, which deliberately has no node_modules. */
function cli(args, { cwd, env, expectOk = true }) {
const result = spawnSync(process.execPath, [BUNDLE, ...args], {
cwd,
env,
encoding: "utf8",
// No shell: argument quoting differs between cmd.exe and sh, and this
// test exists precisely to stop shell differences from mattering.
shell: false,
});
if (result.error) fail(`could not run the bundle: ${result.error.message}`);
if (expectOk && result.status !== 0) {
fail(
`\`${args.join(" ")}\` exited ${result.status}\n` +
`stdout: ${result.stdout}\nstderr: ${result.stderr}`,
);
}
return result;
}
if (!existsSync(BUNDLE)) {
fail(`no bundle at ${BUNDLE} — run \`pnpm -C js build\` first`);
}
const appDir = mkdtempSync(join(tmpdir(), "seedpass-smoke-app-"));
// Somewhere with no package.json and no node_modules above it, so a missing
// bundled dependency cannot be papered over by the workspace's own tree.
const runDir = mkdtempSync(join(tmpdir(), "seedpass-smoke-run-"));
try {
const bare = { ...process.env, SEEDPASS_APP_DIR: appDir };
delete bare.SEEDPASS_MNEMONIC;
delete bare.SEEDPASS_PASSWORD;
const version = cli(["--version"], { cwd: runDir, env: bare });
if (!/\d+\.\d+/.test(version.stdout)) {
fail(`--version printed something unversion-like: ${version.stdout.trim()}`);
}
cli(["capabilities"], { cwd: runDir, env: bare });
const env = {
...bare,
SEEDPASS_MNEMONIC: MNEMONIC,
SEEDPASS_PASSWORD: PASSWORD,
};
cli(["fingerprint", "add", "--name", "ci"], { cwd: runDir, env });
cli(["entry", "add", "password", "ci-site", "--length", String(LENGTH)], {
cwd: runDir,
env,
});
const first = cli(["entry", "reveal", "ci-site"], { cwd: runDir, env }).stdout.trim();
if (!first) fail("revealing the entry produced no output");
if (first.length !== LENGTH) {
fail(`asked for a ${LENGTH}-character password and got ${first.length}`);
}
// The product is determinism. Same seed, same entry, same secret.
const second = cli(["entry", "reveal", "ci-site"], { cwd: runDir, env }).stdout.trim();
if (first !== second) {
fail("revealing the same entry twice produced two different secrets");
}
console.log(
`smoke-cli: ok — bundle runs standalone, ${LENGTH}-character secret ` +
`derived deterministically`,
);
} finally {
rmSync(appDir, { recursive: true, force: true });
rmSync(runDir, { recursive: true, force: true });
}