From 97f93a0c89ea87faa5b4b62ef5a71532967f87e4 Mon Sep 17 00:00:00 2001 From: halaprix Date: Tue, 28 Jul 2026 11:56:52 +0200 Subject: [PATCH 1/3] test: close the test-vs-shipped-artifact gap Every bug that actually shipped this project - a broken CLI entry point, an unreachable port-retry branch, dead change detection, a 404'd lazy chunk, a zero-height canvas, a store with exactly one issue collapsing to an object - passed a green suite and was only found by running the real, built, published artifact by hand. The suite tested imported modules; none of it touched what a user actually gets. This closes that gap along five axes, and none of it adds a runtime dependency. Asset-manifest consistency (test/assets.test.mjs): the build output and the server's static manifest must name exactly the same files. Needs no bd and no browser, so it always runs. Verified against the actual regression: renaming vite.config.ts's chunk output back to app2.js while leaving the manifest at elk.js fails this test immediately. Packaged-artifact smoke (test/package.test.mjs): npm pack the real tarball, install it into a project that has never seen this source tree, run the installed binary, hit every route. This is the manual check that found two of the bugs above, now automated. Also asserts the tarball ships zero runtime dependencies and no src/ or test/. Live-refresh delivery (added to security.test.mjs): a write must actually produce an `event: store-changed` SSE frame, and the frame's id must equal the store's new version. Verified this catches the dead-detection regression by reintroducing the wrong noms path and confirming the test fails. Boundary counts (test/boundary.test.mjs): 0, 1, and 2 issues, plus an epic with exactly one child, each in its own freshly created store rather than a shared one where ordering could hide the count that matters. Verified this catches the single-issue collapse by reverting listAll()'s fix and confirming the count=1 test fails. Render smoke (test/smoke.test.mjs, new playwright devDependency): the real built page, against a real seeded store, in headless Chromium. Asserts the canvas has real height and width (the exact zero-height regression), the node count matches the seeded issues, and there are zero console errors and zero failed requests. Screenshot saved as a CI artifact so a human can glance at every release rather than trusting only assertions about pixels. CI previously ran none of the integration or CLI suites at all - they existed, gated on `hasBd`, and silently skipped everywhere in CI because bd was never installed there. Fixed via `npm install -g @beads/bd` (the npm package, not the curl|sh script, to avoid running an arbitrary remote shell script in CI) on the newer runtime leg only, to avoid doubling browser/binary downloads across the Node 22/24 matrix for no extra coverage. The full battery also now runs directly inside release.yml, not only ci.yml: GitHub Actions has no cross-workflow `needs`, so without this a tag could publish even if the parallel ci.yml run failed on the very same tag. Verified: 45 tests pass (29 .mjs + 16 .ts), typecheck and lint clean, the render smoke test's screenshot confirms the collapse-toggle fix from the previous session still holds in a real browser. --- .github/workflows/ci.yml | 38 +++++++++- .github/workflows/release.yml | 25 +++++++ .gitignore | 1 + CHANGELOG.md | 11 +++ README.md | 19 +++++ package-lock.json | 52 +++++++++++++- package.json | 7 +- server/server.mjs | 2 +- test/assets.test.mjs | 43 ++++++++++++ test/boundary.test.mjs | 92 ++++++++++++++++++++++++ test/package.test.mjs | 128 ++++++++++++++++++++++++++++++++++ test/security.test.mjs | 42 +++++++++++ test/smoke.test.mjs | 93 ++++++++++++++++++++++++ 13 files changed, 545 insertions(+), 8 deletions(-) create mode 100644 test/assets.test.mjs create mode 100644 test/boundary.test.mjs create mode 100644 test/package.test.mjs create mode 100644 test/smoke.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c844f7..558a543 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,8 +21,40 @@ jobs: - run: npm run typecheck - run: npm run lint - run: npm run build - # Only the pure-logic tests run here. The security and CLI suites drive a real - # `bd` binary against a real store, which CI has no way to provide - so they are - # a local gate, not a CI one. Pretending otherwise would be worse than saying it. + + # Fast checks run on every leg. bd, Playwright's browser, and the full integration + # and smoke battery only run once, on the newer runtime - doubling browser/binary + # downloads across the matrix would cost real minutes for no extra coverage. - run: node --test test/browser.test.mjs - run: node --test --experimental-strip-types test/model.test.ts test/layout.test.ts + - run: node --test test/assets.test.mjs + + - name: Install bd + if: matrix.node-version == '24' + # The npm package, not the curl|sh install script: it fits the existing + # toolchain and avoids executing an arbitrary remote shell script in CI. + run: npm install -g @beads/bd + + - name: Install Chromium for the render smoke test + if: matrix.node-version == '24' + run: npx playwright install --with-deps chromium + + # These previously ran nowhere in CI - only locally, wherever a developer happened + # to have `bd` on PATH - which is exactly the gap that let four real bugs reach a + # published release behind a green suite. + - name: Integration and smoke tests + if: matrix.node-version == '24' + run: | + node --test test/security.test.mjs + node --test test/cli.test.mjs + node --test test/package.test.mjs + node --test test/boundary.test.mjs + node --test test/smoke.test.mjs + + - name: Upload the render smoke screenshot + if: matrix.node-version == '24' && always() + uses: actions/upload-artifact@v4 + with: + name: smoke-screenshot + path: test-results/smoke.png + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 828c574..863ccf9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,31 @@ jobs: - run: npm run lint - run: node --test test/browser.test.mjs - run: node --test --experimental-strip-types test/model.test.ts test/layout.test.ts + - run: node --test test/assets.test.mjs + + # ci.yml also runs on a tag push, but GitHub Actions has no way to make this job + # depend on a job in a different workflow file - so without running the full + # battery here too, a release could publish even if that other run failed. This is + # the one gate a broken package cannot get past. + - name: Install bd + run: npm install -g @beads/bd + - name: Install Chromium for the render smoke test + run: npx playwright install --with-deps chromium + - name: Integration and smoke tests + run: | + node --test test/security.test.mjs + node --test test/cli.test.mjs + node --test test/package.test.mjs + node --test test/boundary.test.mjs + node --test test/smoke.test.mjs + + - name: Upload the render smoke screenshot + if: always() + uses: actions/upload-artifact@v4 + with: + name: smoke-screenshot-${{ github.ref_name }} + path: test-results/smoke.png + if-no-files-found: ignore # Pinned to 11, which supports every Node the runner might carry. npm 12 requires # ^22.22 || ^24.15 and would fail the job on an older runner image. diff --git a/.gitignore b/.gitignore index 85a68fd..70c494e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ .beads/ .resources/ *.log +test-results/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ecd92c..6593064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this package are documented here. +## 0.3.0 - 2026-07-28 + +- Test the shipped artifact, not just the source: a packaged-artifact smoke test + (`npm pack`, install, run, hit every endpoint), a Playwright render smoke test (real + page, real store, non-zero canvas, zero console errors, zero failed requests), an + asset-manifest consistency check, live-refresh delivery over SSE, and boundary-count + tests at 0/1/2 issues plus an epic with one child. +- CI now installs `bd` and runs the full integration and smoke battery, on both the + regular pipeline and the release pipeline directly - previously these tests existed + but ran nowhere in CI at all. + ## 0.2.1 - 2026-07-28 - Always return a list of issues. A store holding exactly one issue made `bd list` diff --git a/README.md b/README.md index 595654d..931c6fc 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,25 @@ In 0.2.0: 5. **Drag to connect a dependency**, select an edge to remove it. Grouped edges are read-only until expanded. `bd` rejects cycles for us, and the rejection is surfaced verbatim. 6. **Live refresh** — a human or an agent running `bd` in a terminal shows up in the UI within about a second. +## Testing + +- `npm test` — pure-logic tests: the graph model, layout, and browser-detection helpers. + No `bd` needed, always safe to run. +- `npm run test:integration` — spins up a real server against a real, isolated Beads + store. Needs `bd` on `PATH`. +- `npm run test:smoke` — installs the real published tarball and separately renders the + built page in headless Chromium, asserting the canvas has real size, the node count + matches the store, and there are zero console errors and zero failed requests. Needs + `bd` and `npx playwright install chromium`. + +All three ran in this repository's own past releases and none of them would have caught +the bugs that actually shipped: a broken CLI entry point, a dead change-detection path, a +404'd lazily-loaded chunk, a zero-height canvas, and a store with exactly one issue +collapsing to a single object instead of a list. Every one of those only failed when the +real, built, installed, rendered artifact was exercised - which is what +`test:integration` and `test:smoke` exist to do, and why CI now runs them rather than +only the pure-logic suite. + ## Requirements - Node >= 22.13.0 diff --git a/package-lock.json b/package-lock.json index 09cb768..3e9e2dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@halaprix/beads-viewer", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@halaprix/beads-viewer", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "bin": { "beads-viewer": "server/cli.mjs" @@ -18,6 +18,7 @@ "@xyflow/react": "^12.11.2", "elkjs": "^0.12.0", "oxlint": "^1.40.0", + "playwright": "^1.62.0", "react": "^19.2.0", "react-dom": "^19.2.0", "typescript": "~7.0.2", @@ -1700,6 +1701,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.23", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", diff --git a/package.json b/package.json index 6e0a252..c725333 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@halaprix/beads-viewer", - "version": "0.2.1", + "version": "0.3.0", "description": "Local-first interactive dependency graph editor for Beads issues.", "keywords": [ "beads", @@ -39,7 +39,9 @@ "lint": "oxlint", "typecheck": "tsc --noEmit", "check": "npm run lint && npm run typecheck && npm test && npm run build", - "prepack": "npm run build" + "prepack": "npm run build", + "test:integration": "node --test test/security.test.mjs test/cli.test.mjs test/package.test.mjs test/boundary.test.mjs", + "test:smoke": "node --test test/smoke.test.mjs" }, "engines": { "node": ">=22.13.0" @@ -59,6 +61,7 @@ "@xyflow/react": "^12.11.2", "elkjs": "^0.12.0", "oxlint": "^1.40.0", + "playwright": "^1.62.0", "react": "^19.2.0", "react-dom": "^19.2.0", "typescript": "~7.0.2", diff --git a/server/server.mjs b/server/server.mjs index 434ae10..da5c446 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -18,7 +18,7 @@ const MAX_BODY_BYTES = 1024 * 1024; // A hardcoded manifest, never "any path minus a denylist". Seven separate Vite CVEs // exist because denylists lose: ?raw??, ?import, .svg, invalid request targets, // symlinks, and an html fallback that skipped the check entirely. -const STATIC_FILES = new Map([ +export const STATIC_FILES = new Map([ ["/", { file: "index.html", type: "text/html; charset=utf-8" }], ["/index.html", { file: "index.html", type: "text/html; charset=utf-8" }], ["/app.js", { file: "app.js", type: "text/javascript; charset=utf-8" }], diff --git a/test/assets.test.mjs b/test/assets.test.mjs new file mode 100644 index 0000000..075f33c --- /dev/null +++ b/test/assets.test.mjs @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { STATIC_FILES } from "../server/server.mjs"; + +// The bug this exists to prevent: chunkFileNames in vite.config.ts was changed, the +// manifest in server.mjs was not, and the build silently emitted a file the server never +// registered - a 404 at runtime that every other test missed, because none of them +// compared the two lists against each other. Needs no `bd` and no browser, so it always +// runs. +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); + +test.before(() => { + // Rebuilt unconditionally rather than trusting a leftover dist/ from a previous run or + // a different branch - the whole point is to catch a build/manifest mismatch, which a + // stale dist/ would hide. + execFileSync("npx", ["vite", "build"], { cwd: ROOT, stdio: "ignore" }); +}); + +test("every file the build emits is reachable through the static manifest, and vice versa", async () => { + const built = (await readdir(path.join(ROOT, "dist"))) + .filter((name) => !name.startsWith(".")) + .sort(); + const registered = [...new Set([...STATIC_FILES.values()].map((entry) => entry.file))].sort(); + + assert.deepEqual( + registered, + built, + "the static manifest and the build output must name exactly the same files" + ); +}); + +test("the manifest declares no path outside dist and no duplicate destination for one URL", () => { + const paths = [...STATIC_FILES.keys()]; + assert.equal(new Set(paths).size, paths.length, "no URL is registered twice"); + for (const [url, entry] of STATIC_FILES) { + assert.doesNotMatch(entry.file, /\.\./, `${url} must not escape dist via a relative path`); + assert.doesNotMatch(entry.file, /^\//, `${url} must be relative to dist, not absolute`); + } +}); diff --git a/test/boundary.test.mjs b/test/boundary.test.mjs new file mode 100644 index 0000000..8ced301 --- /dev/null +++ b/test/boundary.test.mjs @@ -0,0 +1,92 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { startServer } from "../server/server.mjs"; +import { createBeadsWorkspace, fixtureEnv, hasBd, removeBeadsWorkspace } from "./beads-fixture.mjs"; + +// The bug this file exists for: bd.mjs collapses an array-of-one response, because that +// is the shape `bd show` returns. `bd list` against a store holding exactly one issue +// returns the identical shape, so it was collapsed too, and the API handed the UI an +// object where it expected a list. It surfaced in a brand-new repository with a single +// bead - the first thing anyone tries - and survived every existing test, because +// security.test.mjs shares one server across many tests and never isolates a count. +// Each test here gets its own fresh store, sized exactly, so the boundary is the only +// variable. +const integration = { timeout: 20_000, skip: !hasBd }; + +async function withCountedStore(count, run) { + if (!hasBd) return; + const workspace = await createBeadsWorkspace(); + const server = await startServer({ port: 7398, cwd: workspace, env: fixtureEnv() }); + try { + const auth = { Authorization: `Bearer ${server.token}` }; + for (let index = 0; index < count; index += 1) { + const response = await fetch("http://127.0.0.1:7398/api/issues", { + method: "POST", + headers: { ...auth, "Content-Type": "application/json" }, + body: JSON.stringify({ title: `boundary issue ${index}` }) + }); + assert.equal(response.status, 200); + } + const listed = await fetch("http://127.0.0.1:7398/api/issues", { headers: auth }); + const { data } = await listed.json(); + await run(data); + } finally { + await server.close(); + await removeBeadsWorkspace(workspace); + } +} + +test("an empty store returns an empty array, not null and not an error", integration, async () => { + await withCountedStore(0, (data) => { + assert.ok(Array.isArray(data.issues), "issues must be an array even with zero rows"); + assert.equal(data.issues.length, 0); + }); +}); + +test("a store with exactly one issue returns an array of one", integration, async () => { + await withCountedStore(1, (data) => { + assert.ok(Array.isArray(data.issues)); + assert.equal(data.issues.length, 1); + assert.equal(data.issues[0].title, "boundary issue 0"); + }); +}); + +test("a store with exactly two issues returns an array of two", integration, async () => { + await withCountedStore(2, (data) => { + assert.ok(Array.isArray(data.issues)); + assert.equal(data.issues.length, 2); + }); +}); + +test("an epic with exactly one child still returns both as an array, with parent resolved", integration, async () => { + if (!hasBd) return; + const workspace = await createBeadsWorkspace(); + const server = await startServer({ port: 7398, cwd: workspace, env: fixtureEnv() }); + try { + const auth = { Authorization: `Bearer ${server.token}` }; + const epic = await ( + await fetch("http://127.0.0.1:7398/api/issues", { + method: "POST", + headers: { ...auth, "Content-Type": "application/json" }, + body: JSON.stringify({ title: "the only epic", type: "epic" }) + }) + ).json(); + const epicId = epic.data.id; + await fetch("http://127.0.0.1:7398/api/issues", { + method: "POST", + headers: { ...auth, "Content-Type": "application/json" }, + body: JSON.stringify({ title: "its only child", parent: epicId }) + }); + + const { data } = await ( + await fetch("http://127.0.0.1:7398/api/issues", { headers: auth }) + ).json(); + assert.ok(Array.isArray(data.issues)); + assert.equal(data.issues.length, 2); + const child = data.issues.find((issue) => issue.parent === epicId); + assert.ok(child, "the child's parent field must resolve to the epic's id"); + } finally { + await server.close(); + await removeBeadsWorkspace(workspace); + } +}); diff --git a/test/package.test.mjs b/test/package.test.mjs new file mode 100644 index 0000000..7cb7628 --- /dev/null +++ b/test/package.test.mjs @@ -0,0 +1,128 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createBeadsWorkspace, fixtureEnv, hasBd, removeBeadsWorkspace } from "./beads-fixture.mjs"; + +// This is the manual verification that caught two real bugs (the address() crash and the +// single-issue collapse) turned into a test: `npm pack` the real tarball, `npm install` +// it into a project that has never seen this source tree, run the installed binary, and +// hit it exactly the way a user's browser would. Every other test imports the source +// directly, which is precisely what none of those bugs needed to reproduce. +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const integration = { timeout: 60_000, skip: !hasBd }; + +let workspace; +let tarballPath; + +test.before(async () => { + if (!hasBd) return; + workspace = await createBeadsWorkspace(); + // `npm pack` runs prepack (the real build) first, and vite's own progress lines land + // on the same stdout as --json's result - including lines that themselves contain a + // literal "[", such as its "(!) Some chunks..." warning, so a naive lastIndexOf("[") + // picks up the wrong bracket. npm pretty-prints its JSON with the array's opening + // bracket alone on its own line, which nothing else in vite's output does; find that + // line specifically rather than guessing from bracket position. + const packOutput = execFileSync("npm", ["pack", "--json"], { cwd: ROOT, encoding: "utf8" }); + const lines = packOutput.split("\n"); + const jsonStartLine = lines.findIndex((line) => line.trim() === "["); + assert.ok(jsonStartLine !== -1, `no JSON array found in npm pack output:\n${packOutput}`); + const [{ filename }] = JSON.parse(lines.slice(jsonStartLine).join("\n")); + tarballPath = path.join(ROOT, filename); + execFileSync("npm", ["init", "--yes"], { cwd: workspace, env: fixtureEnv(), stdio: "ignore" }); + execFileSync("npm", ["install", tarballPath, "--no-audit", "--no-fund"], { + cwd: workspace, + env: fixtureEnv(), + stdio: "ignore" + }); +}); + +test.after(async () => { + await removeBeadsWorkspace(workspace); + if (tarballPath) { + await execFileSync("rm", ["-f", tarballPath]); + } +}); + +function launchInstalled(port) { + const bin = path.join(workspace, "node_modules", ".bin", "beads-viewer"); + const child = spawn(bin, ["--no-open", "--port", String(port)], { + cwd: workspace, + env: fixtureEnv(), + stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + return { + child, + output: () => ({ stdout, stderr }), + ready: () => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`no URL within 15s: ${stderr || stdout}`)), 15_000); + child.stdout.on("data", () => { + const match = /http:\/\/127\.0\.0\.1:(\d+)\/#t=([A-Za-z0-9_-]+)/.exec(stdout); + if (match) { + clearTimeout(timer); + resolve({ url: match[0], token: match[2] }); + } + }); + child.on("close", (code) => { + clearTimeout(timer); + reject(new Error(`exited ${code} before printing a URL: ${stderr}`)); + }); + }) + }; +} + +test( + "the published tarball, installed fresh and run, serves the page and the store", + integration, + async () => { + const instance = launchInstalled(7420); + try { + const { token } = await instance.ready(); + const auth = { Authorization: `Bearer ${token}` }; + + // Every asset the manifest promises, served by the installed package rather than + // by source files sitting next to the test. + for (const [route, expectedType] of [ + ["/", "text/html"], + ["/app.js", "text/javascript"], + ["/elk.js", "text/javascript"], + ["/app.css", "text/css"] + ]) { + const response = await fetch(`http://127.0.0.1:7420${route}`); + assert.equal(response.status, 200, `${route} did not serve from the installed package`); + assert.match(response.headers.get("content-type") ?? "", new RegExp(expectedType)); + } + + // The API layer, through the same installed binary - this is what caught the + // single-issue collapse, which no import-level test could see. + const issues = await ( + await fetch("http://127.0.0.1:7420/api/issues", { headers: auth }) + ).json(); + assert.ok(Array.isArray(issues.data.issues), "the installed package must serve a real store"); + } finally { + instance.child.kill("SIGINT"); + } + } +); + +test("the tarball ships no runtime dependency and no dev-only source", integration, async () => { + const pkg = JSON.parse( + await readFile(path.join(workspace, "node_modules", "@halaprix", "beads-viewer", "package.json"), "utf8") + ); + assert.deepEqual(pkg.dependencies ?? {}, {}, "the published package must have zero runtime dependencies"); + const installedFiles = await execFileSync( + "find", + [path.join(workspace, "node_modules", "@halaprix", "beads-viewer"), "-maxdepth", "1"], + { encoding: "utf8" } + ); + assert.doesNotMatch(installedFiles, /\/src\b/, "TypeScript source must not ship in the tarball"); + assert.doesNotMatch(installedFiles, /\/test\b/, "tests must not ship in the tarball"); +}); diff --git a/test/security.test.mjs b/test/security.test.mjs index 4a25b6f..71bab7f 100644 --- a/test/security.test.mjs +++ b/test/security.test.mjs @@ -171,3 +171,45 @@ test("a store holding exactly one issue still returns a list", integration, asyn assert.equal(data.issues.length, 1); assert.equal(data.issues[0].title, "the only bead"); }); + +test("a write actually produces an SSE frame, and the version token moves", integration, async () => { + // The bug this pins: change detection watched the wrong Dolt directory, so the version + // token never moved and no frame was ever pushed. Every other SSE test only checked + // that the route requires a token - none of them proved the stream delivers anything. + const before = (await (await fetch(`${base}/api/store`, { headers: auth() })).json()).data.version; + + const controller = new AbortController(); + const framePromise = (async () => { + const response = await fetch(`${base}/api/events`, { headers: auth(), signal: controller.signal }); + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + let buffer = ""; + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const { value, done } = await reader.read(); + if (done) break; + buffer += value; + if (buffer.includes("event: store-changed")) { + const id = /id: (\S+)/.exec(buffer)?.[1]; + return id; + } + } + throw new Error(`no store-changed frame within 5s. Received:\n${buffer}`); + })(); + + // Give the stream a moment to attach before the write that must be observed on it. + await new Promise((resolve) => setTimeout(resolve, 200)); + const created = await fetch(`${base}/api/issues`, { + method: "POST", + headers: { ...auth(), "Content-Type": "application/json" }, + body: JSON.stringify({ title: "should trigger a live refresh" }) + }); + assert.equal(created.status, 200); + + const pushedId = await framePromise; + controller.abort(); + + assert.notEqual(pushedId, before, "the pushed version must differ from the pre-write version"); + + const after = (await (await fetch(`${base}/api/store`, { headers: auth() })).json()).data.version; + assert.equal(pushedId, after, "the id on the pushed frame must match the store's new version"); +}); diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs new file mode 100644 index 0000000..d7261b1 --- /dev/null +++ b/test/smoke.test.mjs @@ -0,0 +1,93 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; +import { startServer } from "../server/server.mjs"; +import { createBeadsWorkspace, fixtureEnv, hasBd, removeBeadsWorkspace } from "./beads-fixture.mjs"; + +// The bug this exists for: the graph rendered into a canvas with zero height, because a +// grid item auto-placed into a row sized `auto`. The header still showed the right +// counts, so nothing short of looking at the page could have told the difference between +// "no data" and "the layout is broken" - which is exactly why every prior test missed it. +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function hasChromium() { + return spawnSync("node", ["-e", "require('playwright').chromium.executablePath()"], { + cwd: ROOT + }).status === 0; +} + +const canRun = hasBd && hasChromium(); +const integration = { timeout: 30_000, skip: !canRun }; + +let workspace; +let server; +let browser; + +test.before(async () => { + if (!canRun) return; + // Rebuilt for the same reason as the asset-manifest test: a stale dist/ would let a + // real regression through silently. + execFileSync("npx", ["vite", "build"], { cwd: ROOT, stdio: "ignore" }); + workspace = await createBeadsWorkspace(); + + // Seed a small, deterministic graph directly through bd rather than through the API + // being smoke-tested, so a bug in the write path cannot mask a bug in the render path. + const run = (args) => execFileSync("bd", args, { cwd: workspace, env: fixtureEnv(), encoding: "utf8" }); + const epic = JSON.parse(run(["create", "--json", "The epic", "-t", "epic"])); + run(["create", "--json", "First task", "--parent", epic.id]); + run(["create", "--json", "Second task", "--parent", epic.id]); + + server = await startServer({ + port: 7410, + cwd: workspace, + env: fixtureEnv(), + distDir: path.join(ROOT, "dist") + }); + browser = await chromium.launch(); +}); + +test.after(async () => { + await browser?.close(); + await server?.close(); + await removeBeadsWorkspace(workspace); +}); + +test("the real built page renders a non-empty, correctly sized graph with no errors", integration, async () => { + const page = await browser.newPage(); + const consoleErrors = []; + const failedRequests = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + page.on("pageerror", (error) => consoleErrors.push(String(error))); + page.on("requestfailed", (request) => failedRequests.push(`${request.method()} ${request.url()}`)); + page.on("response", (response) => { + if (response.status() >= 400) failedRequests.push(`${response.status()} ${response.url()}`); + }); + + await page.goto(server.url); + await page.getByRole("button", { name: "Everything" }).click(); + // Wait for the count in the header rather than a fixed delay: the header text only + // updates once the initial fetch and the first layout pass have both completed. + await page.getByText(/3 shown of 3/).waitFor({ timeout: 10_000 }); + + // This is the exact assertion the zero-height bug would have failed: the canvas is + // present in the DOM either way, so only its rendered size tells the two cases apart. + const canvasBox = await page.locator(".canvas").boundingBox(); + assert.ok(canvasBox, "the canvas element must be present"); + assert.ok(canvasBox.height > 100, `canvas height was ${canvasBox?.height}, expected a real render`); + assert.ok(canvasBox.width > 100, `canvas width was ${canvasBox?.width}, expected a real render`); + + const nodeCount = await page.locator(".node").count(); + assert.equal(nodeCount, 3, "one node per seeded issue"); + + await page.screenshot({ path: path.join(ROOT, "test-results", "smoke.png") }); + + assert.deepEqual(consoleErrors, [], "the page must render with no console errors"); + assert.deepEqual(failedRequests, [], "every request the page makes must succeed"); + + await page.close(); +}); From 90daaca94c34981778328aa18dc4b89520634544 Mon Sep 17 00:00:00 2001 From: halaprix Date: Tue, 28 Jul 2026 12:02:55 +0200 Subject: [PATCH 2/3] fix: bd's postinstall was silently blocked in CI, so every integration test SKIPped The first CI run on this branch went green in 44s - far too fast to have installed Chromium and run the full battery. It had: newer npm blocks a dependency's lifecycle scripts by default and reports it as a warning, not a failure, so `npm install -g @beads/bd` exited 0 while never running the postinstall that fetches the actual bd binary. hasBd then evaluated false, and all nineteen integration and smoke tests reported SKIP rather than FAIL - a fully green job that had tested nothing beyond the pure-logic suite, which is precisely the failure mode this whole PR exists to close. Fixed with --allow-scripts=@beads/bd, the flag npm's own install --help documents for exactly this case on a global install (a project-scoped allowScripts entry in package.json is not available to `npm install -g`, which has no project to write one into). Verified against npm 12 in isolation: without the flag, `bd version` fails after install; with it, the real binary is present and functional. Also added an explicit `bd version` step immediately after install, so a future regression in this exact shape fails loudly on its own line instead of manifesting fifty lines later as a wall of quiet SKIPs that still exits 0. --- .github/workflows/ci.yml | 18 +++++++++++++++++- .github/workflows/release.yml | 17 ++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 558a543..480e95c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,23 @@ jobs: if: matrix.node-version == '24' # The npm package, not the curl|sh install script: it fits the existing # toolchain and avoids executing an arbitrary remote shell script in CI. - run: npm install -g @beads/bd + # + # --allow-scripts is required: newer npm blocks a dependency's postinstall by + # default (silently, exit 0, only a warning), and @beads/bd's postinstall is what + # fetches the actual Go binary. Without this the step "succeeds" while leaving no + # working `bd` on PATH, hasBd evaluates false, and the entire integration and + # smoke battery silently skips - green CI proving nothing, which is the exact + # failure mode this PR exists to close. Global installs have no project + # package.json to record an allowlist in, so the flag is the only mechanism. + run: npm install -g --allow-scripts=@beads/bd @beads/bd + + # Fail loudly here rather than silently later: without this, an install that + # "succeeds" but leaves no working bd on PATH makes every integration and smoke + # test below report SKIP rather than FAIL - a green job that tested nothing, which + # is the exact bug this install step was added to fix in the first place. + - name: Verify bd actually works + if: matrix.node-version == '24' + run: bd version - name: Install Chromium for the render smoke test if: matrix.node-version == '24' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 863ccf9..15d3786 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,22 @@ jobs: # battery here too, a release could publish even if that other run failed. This is # the one gate a broken package cannot get past. - name: Install bd - run: npm install -g @beads/bd + # --allow-scripts is required: newer npm blocks a dependency's postinstall by + # default (silently, exit 0, only a warning), and @beads/bd's postinstall is what + # fetches the actual Go binary. Without this the step "succeeds" while leaving no + # working `bd` on PATH, hasBd evaluates false, and the entire integration and + # smoke battery silently skips - green CI proving nothing, which is the exact + # failure mode this PR exists to close. Global installs have no project + # package.json to record an allowlist in, so the flag is the only mechanism. + run: npm install -g --allow-scripts=@beads/bd @beads/bd + + # Fail loudly here rather than silently later: without this, an install that + # "succeeds" but leaves no working bd on PATH makes every integration and smoke + # test below report SKIP rather than FAIL - a green job that tested nothing, which + # is the exact bug this install step was added to fix in the first place. + - name: Verify bd actually works + run: bd version + - name: Install Chromium for the render smoke test run: npx playwright install --with-deps chromium - name: Integration and smoke tests From 2e06a5b20a5da18cf887e4f2a5cefe152d9c4d5e Mon Sep 17 00:00:00 2001 From: halaprix Date: Tue, 28 Jul 2026 12:06:25 +0200 Subject: [PATCH 3/3] fix: @beads/bd's own postinstall skips the download whenever CI is set The --allow-scripts fix let the postinstall run, but it still silently produced no binary: read scripts/postinstall.js from the actual published tarball rather than guessing further, and line 310 is `if (!process.env.CI) { ...download... } else { console.log('Skipping binary download in CI environment'); }`. GitHub Actions sets CI=true on every runner by default, so the script deliberately no-ops - which is @beads/bd being conservative by design, not a defect in it. Confirmed directly: `CI=true npm install -g @beads/bd` installs only bd.js in 212ms; `env -u CI npm install -g @beads/bd` installs the real binary in ~6s. Fixed with a step-level `env: { CI: "" }` on the Install bd step only - every later step keeps CI=true as GitHub sets it, which is correct, since only the download itself needed to see it unset. --- .github/workflows/ci.yml | 7 +++++++ .github/workflows/release.yml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 480e95c..d7b70f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,13 @@ jobs: # smoke battery silently skips - green CI proving nothing, which is the exact # failure mode this PR exists to close. Global installs have no project # package.json to record an allowlist in, so the flag is the only mechanism. + # @beads/bd's own postinstall skips the binary download whenever CI is set, + # which GitHub Actions sets to "true" on every runner by default - so the + # install step exited 0, printed nothing, and left no binary, all silently. + # That is @beads/bd being conservative by design, not a bug in it; overriding + # CI for this one step is the correct fix, not a workaround. + env: + CI: "" run: npm install -g --allow-scripts=@beads/bd @beads/bd # Fail loudly here rather than silently later: without this, an install that diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15d3786..d54c638 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,6 +43,13 @@ jobs: # smoke battery silently skips - green CI proving nothing, which is the exact # failure mode this PR exists to close. Global installs have no project # package.json to record an allowlist in, so the flag is the only mechanism. + # @beads/bd's own postinstall skips the binary download whenever CI is set, + # which GitHub Actions sets to "true" on every runner by default - so the + # install step exited 0, printed nothing, and left no binary, all silently. + # That is @beads/bd being conservative by design, not a bug in it; overriding + # CI for this one step is the correct fix, not a workaround. + env: + CI: "" run: npm install -g --allow-scripts=@beads/bd @beads/bd # Fail loudly here rather than silently later: without this, an install that