Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 58 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
47 changes: 47 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ dist/
.beads/
.resources/
*.log
test-results/
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 50 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion server/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
Expand Down
43 changes: 43 additions & 0 deletions test/assets.test.mjs
Original file line number Diff line number Diff line change
@@ -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`);
}
});
Loading
Loading