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: + +![Animated demo: an agent publishes a diagram that appears live in the viewer, the user types a question under it, and the agent revises the snippet to a second version and replies in the thread](docs/sideshow-demo.gif) + ## 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 = ` + + + + + + + + + + + + + + + + + + + Client + + /api (guarded) + + /auth/refresh + + request + expired JWT + + + 401 token_expired + + + refresh token (httpOnly cookie) + + + new JWT + rotated refresh token + + + retry with new JWT + +`; + +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. +

`; + +const BACKOFF = ` +
+
+ Base delay + + 200 ms +
+ +
+
+`; + +const QUEUE_METRICS = ` +
+
+
+
12 ms
+
p50 wait
+
+
+
86 ms
+
p95 wait
+
+
+
−71%
+
p95 vs yesterday
+
+
+
1.4k
+
jobs / min
+
+
+ + + + batched dequeue deployed + p95 queue wait, last 24h + +
+`; + +// Seeded in order; the viewer sorts sessions by last activity, so the last +// session here ends up on top. +export const DEMO_SESSIONS = [ + { + agent: "pi", + title: "Queue profiling", + snippets: [ + { + title: "Queue latency after batched dequeue", + html: QUEUE_METRICS, + }, + ], + }, + { + agent: "claude-code", + title: "Auth refactor", + snippets: [ + { + title: "JWT refresh flow", + html: JWT_DIAGRAM, + followups: [ + { comment: { author: "user", text: "Where does the access token live client-side?" } }, + { update: { html: JWT_DIAGRAM + JWT_EXPLAINER } }, + { + comment: { + author: "claude-code", + text: "In memory only — never localStorage. Updated the diagram to show it.", + }, + }, + ], + }, + { + title: "Exponential backoff, intuitively", + html: BACKOFF, + }, + ], + }, +]; diff --git a/bin/sideshow.js b/bin/sideshow.js index 81597fb..cb59036 100644 --- a/bin/sideshow.js +++ b/bin/sideshow.js @@ -31,6 +31,7 @@ usage: --author defaults to agent name sideshow list [--session |--all] list snippets sideshow sessions list sessions + sideshow demo seed two example sessions to explore the viewer sideshow guide print the design contract for snippets sideshow setup print the AGENTS.md integration block sideshow mcp run the stdio MCP server (for agent configs) @@ -320,6 +321,37 @@ const commands = { out(await api("/api/sessions")); }, + async demo() { + const { DEMO_SESSIONS } = await import("./demoData.js"); + for (const demo of DEMO_SESSIONS) { + const session = await api("/api/sessions", { + method: "POST", + body: JSON.stringify({ agent: demo.agent, title: demo.title }), + }); + for (const snip of demo.snippets) { + const snippet = await api("/api/snippets", { + method: "POST", + body: JSON.stringify({ session: session.id, title: snip.title, html: snip.html }), + }); + for (const step of snip.followups ?? []) { + if (step.update) { + await api(`/api/snippets/${snippet.id}`, { + method: "PUT", + body: JSON.stringify(step.update), + }); + } + if (step.comment) { + await api("/api/comments", { + method: "POST", + body: JSON.stringify({ snippet: snippet.id, ...step.comment }), + }); + } + } + } + } + console.log(`Seeded ${DEMO_SESSIONS.length} demo sessions — open ${BASE} to look around.`); + }, + async guide() { console.log(await fetchTextWithFallback("/guide", join(ROOT, "guide", "DESIGN_GUIDE.md"))); }, diff --git a/docs/sideshow-demo.gif b/docs/sideshow-demo.gif new file mode 100644 index 0000000..007d2d0 Binary files /dev/null and b/docs/sideshow-demo.gif differ diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..c6f37cd --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,65 @@ +import { test as base } from "@playwright/test"; +import { type ChildProcess, spawn } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; + +// Each test gets its own sideshow server on an ephemeral port with a fresh +// data file, so tests can mutate state freely and run in parallel. +export const test = base.extend<{ server: { url: string } }>({ + // oxlint-disable-next-line no-empty-pattern + server: async ({}, use) => { + const dataDir = mkdtempSync(join(tmpdir(), "sideshow-e2e-")); + const proc: ChildProcess = spawn(process.execPath, ["server/index.ts"], { + cwd: fileURLToPath(new URL("..", import.meta.url)), + env: { + ...process.env, + PORT: "0", + SIDESHOW_DATA: join(dataDir, "data.json"), + SIDESHOW_TOKEN: "", + }, + stdio: ["ignore", "pipe", "inherit"], + }); + const url = await new Promise((resolve, reject) => { + let out = ""; + proc.stdout?.on("data", (chunk: Buffer) => { + out += chunk.toString(); + const match = out.match(/listening on (http:\/\/localhost:\d+)/); + if (match) resolve(match[1]); + }); + proc.on("exit", (code) => reject(new Error(`server exited early with code ${code}`))); + setTimeout(() => reject(new Error(`server did not boot in time; output: ${out}`)), 15_000); + }); + await use({ url }); + proc.kill(); + }, +}); + +export { expect } from "@playwright/test"; + +export async function publish( + serverUrl: string, + body: { html: string; title?: string; agent?: string; session?: string }, +): Promise<{ id: string; sessionId: string; version: number }> { + const res = await fetch(`${serverUrl}/api/snippets`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`publish failed: ${res.status}`); + return res.json() as Promise<{ id: string; sessionId: string; version: number }>; +} + +export async function update( + serverUrl: string, + id: string, + body: { html?: string; title?: string }, +): Promise { + const res = await fetch(`${serverUrl}/api/snippets/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`update failed: ${res.status}`); +} diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts new file mode 100644 index 0000000..c9bb6fe --- /dev/null +++ b/e2e/viewer.spec.ts @@ -0,0 +1,61 @@ +import { expect, publish, test, update } from "./fixtures.ts"; + +test("snippet published over HTTP appears live via SSE, no reload", async ({ page, server }) => { + await page.goto(server.url); + await expect(page.locator("#onboard")).toBeVisible(); + + await publish(server.url, { html: "

It works

", title: "Live test", agent: "e2e" }); + + // the card streams in over SSE — the page is never reloaded + await expect(page.locator(".card-title")).toHaveText("Live test"); + await expect(page.locator("#onboard")).toBeHidden(); + await expect(page.locator(".sess-title")).toContainText("e2e session"); +}); + +test("resize bridge grows the iframe beyond its 120px default", async ({ page, server }) => { + const tall = `
tall content
`; + await publish(server.url, { html: tall, title: "Tall", agent: "e2e" }); + + await page.goto(server.url); + const iframe = page.locator(".card iframe"); + await expect(iframe).toBeVisible(); + // the sandboxed bridge must report content height via postMessage; this is + // the WebKit-quirk regression test (see CLAUDE.md) + await expect + .poll(async () => (await iframe.boundingBox())?.height ?? 0, { timeout: 15_000 }) + .toBeGreaterThan(300); +}); + +test("comment typed in the composer round-trips to the API", async ({ page, server }) => { + const snippet = await publish(server.url, { html: "

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 -i -vf "fps=12,scale=880:-1:flags=lanczos,\ +// split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=\ +// dither=bayer:bayer_scale=5:diff_mode=rectangle" docs/sideshow-demo.gif + +import { chromium } from "@playwright/test"; +import { spawn } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { DEMO_SESSIONS } from "../bin/demoData.js"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const dataDir = mkdtempSync(join(tmpdir(), "sideshow-rec-")); +const proc = spawn(process.execPath, [join(ROOT, "server", "index.ts")], { + env: { ...process.env, PORT: "0", SIDESHOW_DATA: join(dataDir, "data.json") }, + stdio: ["ignore", "pipe", "inherit"], +}); +const base = await new Promise((resolve, reject) => { + let out = ""; + proc.stdout.on("data", (chunk) => { + out += chunk; + const m = out.match(/listening on (http:\/\/localhost:\d+)/); + if (m) resolve(m[1]); + }); + setTimeout(() => reject(new Error("server did not boot")), 10_000); +}); + +const api = (path, body, init = {}) => + fetch(`${base}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + ...init, + }).then((r) => r.json()); + +const demo = DEMO_SESSIONS.find((d) => d.title === "Auth refactor"); +const snip = demo.snippets[0]; +const [userComment, v2, agentReply] = snip.followups; + +const size = { width: 1180, height: 740 }; +const browser = await chromium.launch(); +const context = await browser.newContext({ + viewport: size, + recordVideo: { dir: dataDir, size }, + colorScheme: "light", +}); +const page = await context.newPage(); +await page.goto(base); +await sleep(1300); + +// the agent publishes — the card streams in live +const session = await api("/api/sessions", { agent: demo.agent, title: demo.title }); +await sleep(600); +const snippet = await api("/api/snippets", { + session: session.id, + title: snip.title, + html: snip.html, +}); +await page.locator(".card iframe").waitFor(); +await sleep(2200); + +// the user asks a question under it +const input = page.locator(".composer input"); +await input.click(); +await input.pressSequentially(userComment.comment.text, { delay: 36 }); +await sleep(350); +await input.press("Enter"); +await page.locator(".cmt").first().waitFor(); +await sleep(1500); + +// the agent revises the snippet (v2) and replies in the thread +await api(`/api/snippets/${snippet.id}`, v2.update, { method: "PUT" }); +await page.locator("select.vbadge").waitFor(); +await sleep(1400); +await api("/api/comments", { snippet: snippet.id, ...agentReply.comment }); +await page.locator(".cmt").nth(1).waitFor(); +await sleep(3000); + +const video = page.video(); +await context.close(); +await browser.close(); +proc.kill(); +console.log(await video.path()); diff --git a/test/jsonFileStore.test.ts b/test/jsonFileStore.test.ts new file mode 100644 index 0000000..ccc74df --- /dev/null +++ b/test/jsonFileStore.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { JsonFileStore } from "../server/storage.ts"; +import { runStoreContract } from "./storeContract.ts"; + +const freshPath = () => join(mkdtempSync(join(tmpdir(), "sideshow-store-")), "data.json"); + +runStoreContract("JsonFileStore", () => new JsonFileStore(freshPath())); + +test("JsonFileStore: data survives a reload from disk", async () => { + const path = freshPath(); + const store = new JsonFileStore(path); + const session = await store.createSession({ agent: "pi", title: "Persisted" }); + const snippet = await store.createSnippet({ sessionId: session.id, html: "

x

" }); + 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 Record[]); + }, + }; +} + +function cursor(rows: Record[]): SqlStorageCursor { + return { + toArray: () => rows, + one: () => { + if (rows.length !== 1) throw new Error(`Expected exactly one row, got ${rows.length}`); + return rows[0]; + }, + }; +} diff --git a/test/sqlStore.test.ts b/test/sqlStore.test.ts new file mode 100644 index 0000000..b179340 --- /dev/null +++ b/test/sqlStore.test.ts @@ -0,0 +1,5 @@ +import { SqlStore } from "../workers/sqlStore.ts"; +import { createSqlStorage } from "./sqlStorageShim.ts"; +import { runStoreContract } from "./storeContract.ts"; + +runStoreContract("SqlStore", () => new SqlStore(createSqlStorage())); diff --git a/test/storeContract.ts b/test/storeContract.ts new file mode 100644 index 0000000..16550ab --- /dev/null +++ b/test/storeContract.ts @@ -0,0 +1,329 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { HISTORY_LIMIT, type Store } from "../server/types.ts"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Reusable contract suite: every Store implementation must pass it. +// makeStore must return a fresh, empty store on each call. +export function runStoreContract(name: string, makeStore: () => Store | Promise) { + const contract = (title: string, fn: (store: Store) => Promise) => + test(`${name}: ${title}`, async () => { + await fn(await makeStore()); + }); + + // --- sessions --- + + contract("creates sessions with trimmed fields and defaults", async (store) => { + const session = await store.createSession({ agent: " pi ", title: " Auth flow " }); + assert.equal(session.agent, "pi"); + assert.equal(session.title, "Auth flow"); + assert.equal(session.lastActiveAt, session.createdAt); + + const blank = await store.createSession({ agent: " " }); + assert.equal(blank.agent, "agent"); + assert.equal(blank.title, null); + assert.equal(blank.cwd, null); + + assert.deepEqual(await store.getSession(session.id), session); + assert.equal(await store.getSession("missing"), null); + }); + + contract("renames sessions; blank title clears it; unknown id is null", async (store) => { + const session = await store.createSession({ agent: "pi", title: "Old" }); + const renamed = await store.renameSession(session.id, " New "); + assert.equal(renamed?.title, "New"); + assert.equal((await store.getSession(session.id))?.title, "New"); + + const cleared = await store.renameSession(session.id, " "); + assert.equal(cleared?.title, null); + + assert.equal(await store.renameSession("missing", "X"), null); + }); + + contract("lists sessions by lastActiveAt, newest first; activity reorders", async (store) => { + const a = await store.createSession({ agent: "a" }); + await sleep(10); + const b = await store.createSession({ agent: "b" }); + assert.deepEqual( + (await store.listSessions()).map((s) => s.id), + [b.id, a.id], + ); + + // publishing into the older session bumps it to the front + await sleep(10); + await store.createSnippet({ sessionId: a.id, html: "

x

" }); + 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[]; + one(): Record; +} + +interface SqlStorage { + exec(query: string, ...bindings: SqlStorageValue[]): SqlStorageCursor; +}