diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c844f7..d7b70f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,8 +21,63 @@ 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. + # + # --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. + # @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 + # "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' + 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..d54c638 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,53 @@ 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 + # --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. + # @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 + # "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 + 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(); +});