diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91b1941..902c24c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,18 @@ jobs: - run: npm run typecheck - run: npm test + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium webkit + - run: npm run test:e2e + pack-smoke: runs-on: ubuntu-latest steps: @@ -45,4 +57,5 @@ jobs: for i in $(seq 1 20); do curl -sf localhost:4242/api/sessions > /dev/null && break; sleep 0.5; done echo '
ci smoke
' | sideshow publish - --title "CI smoke" --agent ci | tee /tmp/out.json grep -q '"sessionId"' /tmp/out.json + sideshow demo | grep -q "Seeded 2 demo sessions" curl -sf localhost:4242/guide > /dev/null diff --git a/.gitignore b/.gitignore index 636cffb..ffe0f7a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ data/ *.tgz dist/ .dev.vars +test-results/ +playwright-report/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 917e449..d3168ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable user-visible changes to this project are documented in this file. ### Added +- `sideshow demo` seeds two example sessions (a sequence diagram with a + comment thread, an interactive explainer, a metrics card) so the viewer can + be explored without an agent. + ### Changed ### Fixed diff --git a/README.md b/README.md index 191994f..d487473 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ Above: an agent published a sequence diagram while working on an auth refactor. The user asked a question under it, and the agent answered in the thread and revised the snippet. +The loop in motion — publish, live appear, comment, revise: + + + ## Quick start Requires Node 22.18 or newer. @@ -38,6 +42,9 @@ curl -s http://localhost:4242/setup >> AGENTS.md That block teaches any agent with a shell tool (pi, opencode, amp, codex, Claude Code) how to publish snippets and poll for your comments using curl. +No agent handy? `npx sideshow demo` seeds two example sessions so you can +look around the viewer. + ## Connecting agents Use whichever the agent supports: diff --git a/bin/demoData.js b/bin/demoData.js new file mode 100644 index 0000000..53fdc28 --- /dev/null +++ b/bin/demoData.js @@ -0,0 +1,187 @@ +// Seed content for `sideshow demo` — two example sessions that show what +// agents draw on the surface. Keep this file dependency-free like the CLI. + +const JWT_DIAGRAM = ` +`; + +const JWT_EXPLAINER = ` +
+ The access token lives in memory only (a JS variable) — never localStorage, so XSS
+ can't exfiltrate a long-lived credential. The client never stores the refresh token
+ in JS — it lives in an httpOnly cookie and only travels to
+ /auth/refresh.
+ Rotation means a stolen refresh token dies on first reuse.
+
v1
", title: "Doc", agent: "e2e" }); + + await page.goto(server.url); + const input = page.locator(".composer input"); + await input.fill("ship it"); + await input.press("Enter"); + + // renders in the thread (via SSE) and is persisted server-side + await expect(page.locator(".cmt .txt")).toHaveText("ship it"); + await expect(page.locator(".cmt .who")).toHaveText("you"); + await expect + .poll(async () => { + const res = await fetch(`${server.url}/api/comments?snippet=${snippet.id}`); + const data = (await res.json()) as { comments: { text: string }[] }; + return data.comments.map((c) => c.text); + }) + .toContain("ship it"); +}); + +test("version select appears live after an update", async ({ page, server }) => { + const snippet = await publish(server.url, { html: "v1
", title: "Doc", agent: "e2e" }); + + await page.goto(server.url); + await expect(page.locator(".card .vbadge")).toHaveText("v1"); + + await update(server.url, snippet.id, { html: "v2
" }); + + const select = page.locator("select.vbadge"); + await expect(select).toBeVisible(); + await expect(select).toHaveValue("2"); + await expect(select.locator("option")).toHaveText(["v2", "v1"]); +}); diff --git a/package-lock.json b/package-lock.json index 3f46b55..bcd1b77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20260611.1", + "@playwright/test": "^1.60.0", "@types/node": "^25.9.3", "lint-staged": "^17.0.7", "oxfmt": "^0.54.0", @@ -1927,6 +1928,22 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -3252,6 +3269,53 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index e8dba2d..2622bba 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "start": "node server/index.ts", "mcp": "node mcp/server.ts", "test": "node --test 'test/**/*.test.ts'", + "test:e2e": "playwright test", "format": "oxfmt --write .", "format:check": "oxfmt --check .", "lint": "oxlint . --deny-warnings", @@ -56,6 +57,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20260611.1", + "@playwright/test": "^1.60.0", "@types/node": "^25.9.3", "lint-staged": "^17.0.7", "oxfmt": "^0.54.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..23ec248 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,16 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Viewer e2e suite — runs with `npm run test:e2e`, kept out of `npm test`. +// WebKit is the point: the iframe resize bridge has WebKit-specific quirks +// (see CLAUDE.md), so both engines must pass. +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? "github" : "list", + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "webkit", use: { ...devices["Desktop Safari"] } }, + ], +}); diff --git a/scripts/record-demo.mjs b/scripts/record-demo.mjs new file mode 100644 index 0000000..900c356 --- /dev/null +++ b/scripts/record-demo.mjs @@ -0,0 +1,92 @@ +// Records the README demo clip: boots a fresh server, replays the +// "Auth refactor" demo session (publish → live appear → user comment → +// v2 update → agent reply) in a video-recording Chromium, and prints the +// path of the captured webm. Convert with ffmpeg, e.g.: +// +// node scripts/record-demo.mjs +// ffmpeg -y -ix
" }); + await store.updateSnippet(snippet?.id ?? "", { html: "v2
" }); + await store.createComment({ + sessionId: session.id, + snippetId: snippet?.id, + author: "user", + text: "hi", + }); + + const reloaded = new JsonFileStore(path); + assert.equal((await reloaded.getSession(session.id))?.title, "Persisted"); + const got = await reloaded.getSnippet(snippet?.id ?? ""); + assert.equal(got?.version, 2); + assert.equal(got?.history.length, 1); + const comments = await reloaded.listComments({}); + assert.equal(comments.length, 1); + // lastSeq is restored too: the next comment continues the sequence + const next = await reloaded.createComment({ sessionId: session.id, author: "user", text: "2" }); + assert.ok(next && next.seq > comments[0].seq); +}); diff --git a/test/sqlStorageShim.ts b/test/sqlStorageShim.ts new file mode 100644 index 0000000..1296db5 --- /dev/null +++ b/test/sqlStorageShim.ts @@ -0,0 +1,30 @@ +import { DatabaseSync } from "node:sqlite"; + +// In-memory stand-in for a Durable Object's SqlStorage, backed by node:sqlite. +// Implements only the surface SqlStore touches: exec() with positional +// bindings, cursor.toArray(), cursor.one(). +export function createSqlStorage(): SqlStorage { + const db = new DatabaseSync(":memory:"); + return { + exec(query, ...bindings) { + // Multi-statement scripts (the schema DDL) can't be prepared; they never + // bind parameters or return rows. + if (bindings.length === 0 && /;\s*\S/.test(query)) { + db.exec(query); + return cursor([]); + } + const rows = db.prepare(query).all(...(bindings as (string | number | bigint | null)[])); + return cursor(rows as Recordx
" }); + assert.deepEqual( + (await store.listSessions()).map((s) => s.id), + [a.id, b.id], + ); + + // a comment counts as activity too + await sleep(10); + await store.createComment({ sessionId: b.id, author: "user", text: "hi" }); + assert.deepEqual( + (await store.listSessions()).map((s) => s.id), + [b.id, a.id], + ); + }); + + contract("removeSession returns false for unknown ids", async (store) => { + assert.equal(await store.removeSession("missing"), false); + const session = await store.createSession({ agent: "pi" }); + assert.equal(await store.removeSession(session.id), true); + assert.equal(await store.removeSession(session.id), false); + }); + + // --- snippets --- + + contract("creates snippets with defaults; unknown session is null", async (store) => { + assert.equal(await store.createSnippet({ sessionId: "missing", html: "x
" }), null); + + const session = await store.createSession({ agent: "pi" }); + const snippet = await store.createSnippet({ sessionId: session.id, html: "x
" }); + assert.ok(snippet); + assert.equal(snippet.title, "Untitled"); + assert.equal(snippet.version, 1); + assert.deepEqual(snippet.history, []); + assert.equal(snippet.updatedAt, snippet.createdAt); + + const titled = await store.createSnippet({ + sessionId: session.id, + title: " Sketch ", + html: "y
", + }); + assert.equal(titled?.title, "Sketch"); + + assert.deepEqual(await store.getSnippet(snippet.id), snippet); + assert.equal(await store.getSnippet("missing"), null); + }); + + contract("lists snippets oldest first, optionally filtered by session", async (store) => { + const one = await store.createSession({ agent: "a" }); + const two = await store.createSession({ agent: "b" }); + const s1 = await store.createSnippet({ sessionId: one.id, html: "1
" }); + await sleep(10); + const s2 = await store.createSnippet({ sessionId: two.id, html: "2
" }); + await sleep(10); + const s3 = await store.createSnippet({ sessionId: one.id, html: "3
" }); + + assert.deepEqual( + (await store.listSnippets()).map((s) => s.id), + [s1?.id, s2?.id, s3?.id], + ); + assert.deepEqual( + (await store.listSnippets(one.id)).map((s) => s.id), + [s1?.id, s3?.id], + ); + assert.deepEqual(await store.listSnippets("missing"), []); + }); + + contract("updates bump the version and archive the previous one", async (store) => { + const session = await store.createSession({ agent: "pi" }); + const snippet = await store.createSnippet({ + sessionId: session.id, + title: "T", + html: "v1
", + }); + assert.ok(snippet); + // JsonFileStore mutates the object it returned from createSnippet, so + // capture the pre-update timestamp now + const v1UpdatedAt = snippet.updatedAt; + + const updated = await store.updateSnippet(snippet.id, { html: "v2
" }); + assert.equal(updated?.version, 2); + assert.equal(updated?.html, "v2
"); + assert.equal(updated?.title, "T"); + assert.equal(updated?.history.length, 1); + assert.deepEqual(updated?.history[0], { + version: 1, + title: "T", + html: "v1
", + at: v1UpdatedAt, + }); + + // title-only patch keeps html; blank title keeps the old title + const retitled = await store.updateSnippet(snippet.id, { title: "T2" }); + assert.equal(retitled?.title, "T2"); + assert.equal(retitled?.html, "v2
"); + const blank = await store.updateSnippet(snippet.id, { title: " ", html: "v4
" }); + assert.equal(blank?.title, "T2"); + assert.equal(blank?.version, 4); + + // the same state is visible on a fresh read + assert.deepEqual(await store.getSnippet(snippet.id), blank); + + assert.equal(await store.updateSnippet("missing", { html: "x
" }), null); + }); + + contract(`caps history at ${HISTORY_LIMIT} versions`, async (store) => { + const session = await store.createSession({ agent: "pi" }); + const snippet = await store.createSnippet({ sessionId: session.id, html: "v1
" }); + assert.ok(snippet); + const updates = HISTORY_LIMIT + 5; + for (let i = 2; i <= updates + 1; i++) { + await store.updateSnippet(snippet.id, { html: `v${i}
` }); + } + const final = await store.getSnippet(snippet.id); + assert.equal(final?.version, updates + 1); + assert.equal(final?.history.length, HISTORY_LIMIT); + // oldest entries fell off the front; the newest archived version remains + assert.equal(final?.history[0].version, updates + 1 - HISTORY_LIMIT); + assert.equal(final?.history[HISTORY_LIMIT - 1].version, updates); + assert.equal(final?.history[HISTORY_LIMIT - 1].html, `v${updates}
`); + }); + + // --- cascade deletes --- + + contract("removing a session cascades to its snippets and comments", async (store) => { + const doomed = await store.createSession({ agent: "a" }); + const kept = await store.createSession({ agent: "b" }); + const doomedSnippet = await store.createSnippet({ sessionId: doomed.id, html: "x
" }); + const keptSnippet = await store.createSnippet({ sessionId: kept.id, html: "y
" }); + await store.createComment({ + sessionId: doomed.id, + snippetId: doomedSnippet?.id, + author: "user", + text: "bye", + }); + await store.createComment({ sessionId: kept.id, author: "user", text: "stay" }); + + assert.equal(await store.removeSession(doomed.id), true); + assert.equal(await store.getSession(doomed.id), null); + assert.equal(await store.getSnippet(doomedSnippet?.id ?? ""), null); + assert.deepEqual( + (await store.listSnippets()).map((s) => s.id), + [keptSnippet?.id], + ); + const comments = await store.listComments({}); + assert.equal(comments.length, 1); + assert.equal(comments[0].text, "stay"); + }); + + contract("removing a snippet cascades to its comments only", async (store) => { + const session = await store.createSession({ agent: "pi" }); + const doomed = await store.createSnippet({ sessionId: session.id, html: "x
" }); + const kept = await store.createSnippet({ sessionId: session.id, html: "y
" }); + await store.createComment({ + sessionId: session.id, + snippetId: doomed?.id, + author: "user", + text: "on doomed", + }); + await store.createComment({ + sessionId: session.id, + snippetId: kept?.id, + author: "user", + text: "on kept", + }); + await store.createComment({ sessionId: session.id, author: "user", text: "on session" }); + + assert.equal(await store.removeSnippet(doomed?.id ?? ""), true); + assert.equal(await store.removeSnippet(doomed?.id ?? ""), false); + assert.ok(await store.getSession(session.id)); + const texts = (await store.listComments({})).map((c) => c.text); + assert.deepEqual(texts.sort(), ["on kept", "on session"]); + }); + + // --- comments --- + + contract("creates comments; unknown session is null", async (store) => { + assert.equal( + await store.createComment({ sessionId: "missing", author: "user", text: "x" }), + null, + ); + + const session = await store.createSession({ agent: "pi" }); + const snippet = await store.createSnippet({ + sessionId: session.id, + title: "Sketch", + html: "x
", + }); + const onSnippet = await store.createComment({ + sessionId: session.id, + snippetId: snippet?.id, + author: " user ", + text: "love it", + }); + assert.equal(onSnippet?.author, "user"); + assert.equal(onSnippet?.snippetId, snippet?.id); + assert.equal(onSnippet?.snippetTitle, "Sketch"); + + // a session-level comment, and one pointing at a snippet that doesn't exist + const onSession = await store.createComment({ + sessionId: session.id, + author: "", + text: "general", + }); + assert.equal(onSession?.snippetId, null); + assert.equal(onSession?.snippetTitle, null); + assert.equal(onSession?.author, "user"); + const ghost = await store.createComment({ + sessionId: session.id, + snippetId: "missing", + author: "user", + text: "ghost", + }); + assert.equal(ghost?.snippetId, null); + }); + + contract("comment seq is strictly monotonic, even across deletes", async (store) => { + const first = await store.createSession({ agent: "a" }); + const c1 = await store.createComment({ sessionId: first.id, author: "user", text: "1" }); + const c2 = await store.createComment({ sessionId: first.id, author: "user", text: "2" }); + assert.ok(c1 && c2); + assert.ok(c2.seq > c1.seq); + + // deleting everything must not let seq numbers be reused + await store.removeSession(first.id); + const second = await store.createSession({ agent: "b" }); + const c3 = await store.createComment({ sessionId: second.id, author: "user", text: "3" }); + assert.ok(c3); + assert.ok(c3.seq > c2.seq); + }); + + contract("filters comments by session, snippet, and afterSeq", async (store) => { + const one = await store.createSession({ agent: "a" }); + const two = await store.createSession({ agent: "b" }); + const snippet = await store.createSnippet({ sessionId: one.id, html: "x
" }); + const a = await store.createComment({ + sessionId: one.id, + snippetId: snippet?.id, + author: "user", + text: "a", + }); + const b = await store.createComment({ sessionId: one.id, author: "user", text: "b" }); + const c = await store.createComment({ sessionId: two.id, author: "user", text: "c" }); + assert.ok(a && b && c); + + const all = await store.listComments({}); + assert.deepEqual( + all.map((x) => x.text), + ["a", "b", "c"], + ); + // ascending seq order + const seqs = all.map((x) => x.seq); + assert.deepEqual( + seqs, + [...seqs].sort((x, y) => x - y), + ); + + assert.deepEqual( + (await store.listComments({ sessionId: one.id })).map((x) => x.text), + ["a", "b"], + ); + assert.deepEqual( + (await store.listComments({ snippetId: snippet?.id ?? "" })).map((x) => x.text), + ["a"], + ); + assert.deepEqual( + (await store.listComments({ afterSeq: a.seq })).map((x) => x.text), + ["b", "c"], + ); + assert.deepEqual( + (await store.listComments({ sessionId: one.id, afterSeq: a.seq })).map((x) => x.text), + ["b"], + ); + assert.deepEqual(await store.listComments({ sessionId: "missing" }), []); + }); +} diff --git a/test/workersSqlTypes.d.ts b/test/workersSqlTypes.d.ts new file mode 100644 index 0000000..b78c2ba --- /dev/null +++ b/test/workersSqlTypes.d.ts @@ -0,0 +1,14 @@ +// Minimal ambient mirrors of Cloudflare's SqlStorage globals, just enough for +// workers/sqlStore.ts to typecheck inside the Node test program. The real +// definitions live in @cloudflare/workers-types (tsconfig.workers.json), which +// can't be loaded here because its globals conflict with @types/node. +type SqlStorageValue = ArrayBuffer | string | number | bigint | null; + +interface SqlStorageCursor { + toArray(): Record