From 6c3834713eb6e9778aac22613c21e4ce9a7c5dff Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 27 Jun 2026 09:53:38 -0400 Subject: [PATCH 1/2] feat(app): add GET /api/surfaces/recent (post-grained recent surfaces) Post-grained source for cross-session feeds (Org Home + per-workspace Home), distinct from the session-grained /api/sessions. Caps oversized inline text parts (truncated:true) while images stay by-reference (assetId); same auth as /api/sessions (not broadened on a session-scoped publicRead board). Co-Authored-By: Claude Opus 4.8 --- server/app.ts | 94 ++++++++++++++++ server/sqlStore.ts | 7 ++ server/storage.ts | 8 ++ server/types.ts | 2 + test/storeContract.ts | 32 ++++++ test/surfaces-recent.test.ts | 205 +++++++++++++++++++++++++++++++++++ 6 files changed, 348 insertions(+) create mode 100644 test/surfaces-recent.test.ts diff --git a/server/app.ts b/server/app.ts index 8f589ed..953d3cb 100644 --- a/server/app.ts +++ b/server/app.ts @@ -222,6 +222,55 @@ const surfaceMeta = (s: Post) => ({ parts: stripParts(s.surfaces), }); +// Cap inline text parts so /api/surfaces/recent stays cheap while still carrying a +// real (clipped) preview. Unlike stripParts (which empties html for the card list), +// this TRUNCATES the large text-bearing kinds so a feed card can render an honest +// preview. Images/assets are already by-reference (assetId) and json/trace are +// small, so we leave those untouched. When a field is clipped we set `truncated:true` +// on that part so a client can offer a "view full post" affordance honestly. +// A response is bounded by limit × (#parts × PART_TEXT_CAP). +const PART_TEXT_CAP = 8_000; // chars; ~a screenful, enough for a clipped preview +type CappedSurface = Surface & { truncated?: true }; +function capText(text: string): { value: string; truncated: boolean } { + return text.length > PART_TEXT_CAP + ? { value: text.slice(0, PART_TEXT_CAP), truncated: true } + : { value: text, truncated: false }; +} +function capParts(parts: Surface[]): CappedSurface[] { + return parts.map((p): CappedSurface => { + switch (p.kind) { + case "html": { + const { value, truncated } = capText(p.html); + return truncated ? { ...p, html: value, truncated: true } : p; + } + case "markdown": { + const { value, truncated } = capText(p.markdown); + return truncated ? { ...p, markdown: value, truncated: true } : p; + } + case "mermaid": { + const { value, truncated } = capText(p.mermaid); + return truncated ? { ...p, mermaid: value, truncated: true } : p; + } + case "code": { + const { value, truncated } = capText(p.code); + return truncated ? { ...p, code: value, truncated: true } : p; + } + case "terminal": { + const { value, truncated } = capText(p.text); + return truncated ? { ...p, text: value, truncated: true } : p; + } + case "diff": { + if (p.patch === undefined) return p; + const { value, truncated } = capText(p.patch); + return truncated ? { ...p, patch: value, truncated: true } : p; + } + // image (assetId ref), trace (small/by-ref), json (small) travel as-is. + default: + return p; + } + }); +} + function isPublicReadAllowed(path: string, mode: PublicReadMode): boolean { if (mode === "full") return true; if (path.startsWith("/session/")) return true; @@ -229,6 +278,10 @@ function isPublicReadAllowed(path: string, mode: PublicReadMode): boolean { if (path.startsWith("/p/")) return true; if (path.startsWith("/a/")) return true; if (path.startsWith("/api/sessions/")) return true; + // /api/surfaces/recent is the cross-session feed source — gate it like + // /api/sessions (NOT public on a session-scoped board), not like the + // per-surface /api/surfaces/:id reads below. + if (path === "/api/surfaces/recent") return false; if (path.startsWith("/api/surfaces/")) return true; if (path.startsWith("/api/posts/")) return true; if (path.startsWith("/api/snippets/")) return true; @@ -1005,6 +1058,47 @@ export function createApp({ return c.json(sessions.map((s) => ({ ...s, surfaceCount: counts.get(s.id) ?? 0 }))); }); + // --- recent surfaces (post-grained feed source) --- + // + // The N most-recently-updated posts across ALL sessions, newest first — one + // row per post (post-grained), distinct from the session-grained GET + // /api/sessions. This is the source a cross-session "latest posts" feed needs + // (Org Home, a per-workspace Home): each item carries its session id/title + + // agent for the feed card, the post's part kinds, and capped part previews. + // + // Previews are bounded by capParts (large inline text clipped to PART_TEXT_CAP + // with truncated:true); images travel as plain assetId refs (served at /a/:id), + // so the response stays cheap. Same auth as /api/sessions — see + // isPublicReadAllowed, which intentionally does NOT expose this path on a + // session-scoped publicRead board. + app.get("/api/surfaces/recent", async (c) => { + const limit = Math.min(Math.max(Number(c.req.query("limit") ?? "20") || 20, 1), 100); + const posts = await store.listRecentPosts(limit); + // Resolve each post's session once (agent + session title for the feed card). + const sessions = new Map(); + for (const p of posts) { + if (!sessions.has(p.sessionId)) + sessions.set(p.sessionId, await store.getSession(p.sessionId)); + } + return c.json( + posts.map((p) => { + const s = sessions.get(p.sessionId); + return { + id: p.id, + sessionId: p.sessionId, + sessionTitle: s?.title ?? null, + agent: s?.agent ?? null, + title: p.title, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + version: p.version, + partKinds: p.surfaces.map((x) => x.kind), + parts: capParts(p.surfaces), + }; + }), + ); + }); + app.post("/api/sessions", async (c) => { const body = await c.req.json().catch(() => ({})); const session = await store.createSession({ diff --git a/server/sqlStore.ts b/server/sqlStore.ts index 12838ae..874af5e 100644 --- a/server/sqlStore.ts +++ b/server/sqlStore.ts @@ -377,6 +377,13 @@ export class SqlStore implements Store { return rows.map((r) => this.rowToPost(r)); } + async listRecentPosts(limit: number) { + const rows = this.sql + .exec("SELECT * FROM posts ORDER BY updatedAt DESC LIMIT ?", limit) + .toArray(); + return rows.map((r) => this.rowToPost(r)); + } + async getPost(id: string) { const rows = this.sql.exec("SELECT * FROM posts WHERE id = ?", id).toArray(); return rows.length > 0 ? this.rowToPost(rows[0]) : null; diff --git a/server/storage.ts b/server/storage.ts index 27a6b9b..e10d01e 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -349,6 +349,14 @@ export class JsonFileStore implements Store { return all.map(clone).sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } + async listRecentPosts(limit: number) { + await this.load(); + return [...this.surfaces.values()] + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .slice(0, limit) + .map(clone); + } + async getPost(id: string) { await this.load(); return cloneOrNull(this.surfaces.get(id)); diff --git a/server/types.ts b/server/types.ts index 8c75c54..6efd855 100644 --- a/server/types.ts +++ b/server/types.ts @@ -314,6 +314,8 @@ export interface Store { setSetting(key: string, value: string): Promise; listPosts(sessionId?: string): Promise; + /** The N most-recently-updated posts across all sessions (newest first). */ + listRecentPosts(limit: number): Promise; getPost(id: string): Promise; createPost(input: CreatePostInput): Promise; updatePost(id: string, patch: UpdatePostInput): Promise; diff --git a/test/storeContract.ts b/test/storeContract.ts index 615bb21..9db5c1e 100644 --- a/test/storeContract.ts +++ b/test/storeContract.ts @@ -301,6 +301,38 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< assert.deepEqual(await store.listPosts("missing"), []); }); + contract( + "listRecentPosts returns newest-updated first across sessions, clamped to limit", + async (store) => { + const one = await store.createSession({ agent: "a" }); + const two = await store.createSession({ agent: "b" }); + const s1 = await store.createPost({ sessionId: one.id, surfaces: [htmlSurface("

1

")] }); + await sleep(10); + const s2 = await store.createPost({ sessionId: two.id, surfaces: [htmlSurface("

2

")] }); + await sleep(10); + const s3 = await store.createPost({ sessionId: one.id, surfaces: [htmlSurface("

3

")] }); + + // Newest updatedAt first — the reverse of listPosts' oldest-first order. + assert.deepEqual( + (await store.listRecentPosts(10)).map((s) => s.id), + [s3?.id, s2?.id, s1?.id], + ); + // limit slices to the N most recent. + assert.deepEqual( + (await store.listRecentPosts(2)).map((s) => s.id), + [s3?.id, s2?.id], + ); + + // Updating an older post bumps it to the front (updatedAt, not createdAt). + await sleep(10); + await store.updatePost(s1!.id, { surfaces: [htmlSurface("

1b

")] }); + assert.deepEqual( + (await store.listRecentPosts(10)).map((s) => s.id), + [s1?.id, s3?.id, s2?.id], + ); + }, + ); + contract("updates bump the version and archive the previous one", async (store) => { const session = await store.createSession({ agent: "pi" }); const surface = await store.createPost({ diff --git a/test/surfaces-recent.test.ts b/test/surfaces-recent.test.ts new file mode 100644 index 0000000..08c11aa --- /dev/null +++ b/test/surfaces-recent.test.ts @@ -0,0 +1,205 @@ +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 { createApp } from "../server/app.ts"; +import { JsonFileStore } from "../server/storage.ts"; + +function makeApp(authToken?: string, opts?: { publicRead?: "session" | "full" }) { + const dir = mkdtempSync(join(tmpdir(), "sideshow-recent-test-")); + const store = new JsonFileStore(join(dir, "data.json")); + return createApp({ + store, + viewerHtml: "viewer", + guideMarkdown: "# guide", + setupText: "# setup", + agentHowtoText: "# agent how-to", + authToken, + ...opts, + }); +} + +const json = (body: unknown) => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); + +// Publish a surface; returns the lean write response ({ id, sessionId, ... }). +async function publish(app: ReturnType, body: unknown) { + const res = await app.request("/api/surfaces", json(body)); + if (res.status !== 201) assert.fail(`publish failed (${res.status}): ${await res.text()}`); + return (await res.json()) as any; +} + +// Create a session up front so we can publish into it with a known agent/title. +async function createSession(app: ReturnType, agent: string, title?: string) { + const res = await app.request("/api/sessions", json({ agent, title })); + assert.equal(res.status, 201); + return (await res.json()) as any; +} + +test("GET /api/surfaces/recent returns posts newest-first across sessions", async () => { + const app = makeApp(); + const a = await createSession(app, "amp", "Session A"); + const b = await createSession(app, "pi", "Session B"); + + // Publish in an interleaved order; updatedAt (touched on create) drives recency. + const p1 = await publish(app, { + session: a.id, + title: "first", + parts: [{ kind: "html", html: "

1

" }], + }); + await new Promise((r) => setTimeout(r, 5)); + const p2 = await publish(app, { + session: b.id, + title: "second", + parts: [{ kind: "html", html: "

2

" }], + }); + await new Promise((r) => setTimeout(r, 5)); + const p3 = await publish(app, { + session: a.id, + title: "third", + parts: [{ kind: "html", html: "

3

" }], + }); + + const feed = (await (await app.request("/api/surfaces/recent")).json()) as any[]; + assert.equal(feed.length, 3); + assert.deepEqual( + feed.map((x) => x.id), + [p3.id, p2.id, p1.id], + ); + + // Each item carries session context for the feed card. + const top = feed[0]; + assert.equal(top.sessionId, a.id); + assert.equal(top.sessionTitle, "Session A"); + assert.equal(top.agent, "amp"); + assert.equal(top.title, "third"); + assert.deepEqual(top.partKinds, ["html"]); + assert.ok(Array.isArray(top.parts)); + + const middle = feed[1]; + assert.equal(middle.sessionId, b.id); + assert.equal(middle.agent, "pi"); +}); + +test("GET /api/surfaces/recent respects and clamps limit", async () => { + const app = makeApp(); + const s = await createSession(app, "amp"); + for (let i = 0; i < 5; i++) { + await publish(app, { session: s.id, parts: [{ kind: "html", html: `

${i}

` }] }); + } + + const two = (await (await app.request("/api/surfaces/recent?limit=2")).json()) as any[]; + assert.equal(two.length, 2); + + // a negative limit clamps up to 1. + const clampedLow = (await (await app.request("/api/surfaces/recent?limit=-5")).json()) as any[]; + assert.equal(clampedLow.length, 1); + + // garbage / 0 falls back to default (20) → all 5 returned. + const fallback = (await (await app.request("/api/surfaces/recent?limit=abc")).json()) as any[]; + assert.equal(fallback.length, 5); + const zero = (await (await app.request("/api/surfaces/recent?limit=0")).json()) as any[]; + assert.equal(zero.length, 5); + + // limit above 100 clamps to 100 (we only have 5, so this just confirms no error). + const high = (await (await app.request("/api/surfaces/recent?limit=9999")).json()) as any[]; + assert.equal(high.length, 5); +}); + +test("GET /api/surfaces/recent caps oversized text parts and flags truncation", async () => { + const app = makeApp(); + const s = await createSession(app, "amp"); + const bigHtml = "x".repeat(20_000); + const bigMarkdown = "# ".repeat(10_000); // 20k chars + const smallCode = "const a = 1;"; + await publish(app, { + session: s.id, + parts: [ + { kind: "html", html: bigHtml }, + { kind: "markdown", markdown: bigMarkdown }, + { kind: "code", code: smallCode, language: "ts" }, + ], + }); + + const feed = (await (await app.request("/api/surfaces/recent")).json()) as any[]; + const parts = feed[0].parts; + + const html = parts.find((p: any) => p.kind === "html"); + assert.equal(html.html.length, 8_000); // PART_TEXT_CAP + assert.equal(html.truncated, true); + + const md = parts.find((p: any) => p.kind === "markdown"); + assert.equal(md.markdown.length, 8_000); + assert.equal(md.truncated, true); + + // a small part is left whole, with no truncated flag. + const code = parts.find((p: any) => p.kind === "code"); + assert.equal(code.code, smallCode); + assert.equal(code.truncated, undefined); +}); + +test("GET /api/surfaces/recent leaves image parts as plain assetId refs", async () => { + const app = makeApp(); + const s = await createSession(app, "amp"); + + // Upload an asset via the JSON envelope to get a real assetId. + const upload = (await ( + await app.request("/api/assets", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + data: Buffer.from("\x89PNG\r\n\x1a\n px").toString("base64"), + contentType: "image/png", + filename: "shot.png", + kind: "image", + session: s.id, + }), + }) + ).json()) as any; + assert.ok(upload.id); + + await publish(app, { + session: s.id, + parts: [{ kind: "image", assetId: upload.id, alt: "a shot" }], + }); + + const feed = (await (await app.request("/api/surfaces/recent")).json()) as any[]; + const img = feed[0].parts.find((p: any) => p.kind === "image"); + assert.equal(img.assetId, upload.id); + assert.equal(img.alt, "a shot"); + assert.equal(img.truncated, undefined); +}); + +test("GET /api/surfaces/recent is auth-gated exactly like /api/sessions", async () => { + // With an auth token configured, both routes require it. + const guarded = makeApp("secret"); + await guarded.request("/api/surfaces", { + ...json({ parts: [{ kind: "html", html: "

x

" }] }), + headers: { "content-type": "application/json", authorization: "Bearer secret" }, + }); + assert.equal((await guarded.request("/api/sessions")).status, 401); + assert.equal((await guarded.request("/api/surfaces/recent")).status, 401); + const ok = await guarded.request("/api/surfaces/recent", { + headers: { authorization: "Bearer secret" }, + }); + assert.equal(ok.status, 200); + + // On a session-scoped publicRead board, /api/sessions is NOT public — and + // neither is /api/surfaces/recent (it must not broaden access). + const board = makeApp("secret", { publicRead: "session" }); + assert.equal((await board.request("/api/sessions")).status, 401); + assert.equal((await board.request("/api/surfaces/recent")).status, 401); + // the per-surface read IS public on a session board — recent must NOT be. + const made = (await ( + await board.request("/api/surfaces", { + ...json({ parts: [{ kind: "html", html: "

x

" }] }), + headers: { "content-type": "application/json", authorization: "Bearer secret" }, + }) + ).json()) as any; + assert.equal((await board.request(`/api/surfaces/${made.id}`)).status, 200); + assert.equal((await board.request("/api/surfaces/recent")).status, 401); +}); From 9c7654b4172af9ac680b462f4e2561ada0e5e4dd Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 29 Jun 2026 13:28:38 -0400 Subject: [PATCH 2/2] fix(app): harden recent surfaces feed --- .changeset/recent-surfaces-feed.md | 5 ++ server/app.ts | 92 ++++++++++++++++++++++++++---- test/surfaces-recent.test.ts | 37 ++++++++++++ 3 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 .changeset/recent-surfaces-feed.md diff --git a/.changeset/recent-surfaces-feed.md b/.changeset/recent-surfaces-feed.md new file mode 100644 index 0000000..e2b882b --- /dev/null +++ b/.changeset/recent-surfaces-feed.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Add a recent posts feed endpoint at `GET /api/surfaces/recent`, returning the newest updated posts across sessions with capped surface previews. diff --git a/server/app.ts b/server/app.ts index 953d3cb..5d957cb 100644 --- a/server/app.ts +++ b/server/app.ts @@ -222,14 +222,14 @@ const surfaceMeta = (s: Post) => ({ parts: stripParts(s.surfaces), }); -// Cap inline text parts so /api/surfaces/recent stays cheap while still carrying a -// real (clipped) preview. Unlike stripParts (which empties html for the card list), -// this TRUNCATES the large text-bearing kinds so a feed card can render an honest -// preview. Images/assets are already by-reference (assetId) and json/trace are -// small, so we leave those untouched. When a field is clipped we set `truncated:true` -// on that part so a client can offer a "view full post" affordance honestly. -// A response is bounded by limit × (#parts × PART_TEXT_CAP). +// Cap inline preview fields so /api/surfaces/recent stays cheap while still +// carrying a real clipped preview. Unlike stripParts (which empties html for the +// card list), this TRUNCATES large inline payloads so a feed card can render an +// honest preview. Assets stay by-reference (assetId). When a field is clipped we +// set `truncated:true` on that part so a client can offer a "view full post" +// affordance honestly. const PART_TEXT_CAP = 8_000; // chars; ~a screenful, enough for a clipped preview +const TRACE_STEP_PREVIEW_LIMIT = 25; type CappedSurface = Surface & { truncated?: true }; function capText(text: string): { value: string; truncated: boolean } { return text.length > PART_TEXT_CAP @@ -260,17 +260,85 @@ function capParts(parts: Surface[]): CappedSurface[] { return truncated ? { ...p, text: value, truncated: true } : p; } case "diff": { - if (p.patch === undefined) return p; - const { value, truncated } = capText(p.patch); - return truncated ? { ...p, patch: value, truncated: true } : p; + let truncated = false; + const next: CappedSurface = { ...p }; + if (p.patch !== undefined) { + const capped = capText(p.patch); + next.patch = capped.value; + truncated ||= capped.truncated; + } + if (p.files !== undefined) { + next.files = p.files.map((file) => { + const before = capText(file.before); + const after = capText(file.after); + const filename = capText(file.filename); + const language = file.language ? capText(file.language) : undefined; + truncated ||= before.truncated || after.truncated || filename.truncated; + if (language) truncated ||= language.truncated; + return { + ...file, + filename: filename.value, + before: before.value, + after: after.value, + ...(language && { language: language.value }), + }; + }); + } + return truncated ? { ...next, truncated: true } : p; + } + case "image": { + const alt = p.alt ? capText(p.alt) : undefined; + const caption = p.caption ? capText(p.caption) : undefined; + const truncated = !!alt?.truncated || !!caption?.truncated; + return truncated + ? { + ...p, + ...(alt && { alt: alt.value }), + ...(caption && { caption: caption.value }), + truncated: true, + } + : p; + } + case "trace": { + let truncated = false; + const title = p.title ? capText(p.title) : undefined; + if (title?.truncated) truncated = true; + const steps = p.steps?.slice(0, TRACE_STEP_PREVIEW_LIMIT).map((step) => { + const label = capText(step.label); + const kind = step.kind ? capText(step.kind) : undefined; + const detail = step.detail ? capText(step.detail) : undefined; + const ts = step.ts ? capText(step.ts) : undefined; + truncated ||= + label.truncated || !!kind?.truncated || !!detail?.truncated || !!ts?.truncated; + return { + label: label.value, + ...(kind && { kind: kind.value }), + ...(detail && { detail: detail.value }), + ...(ts && { ts: ts.value }), + }; + }); + if ((p.steps?.length ?? 0) > TRACE_STEP_PREVIEW_LIMIT) truncated = true; + return truncated + ? { ...p, ...(title && { title: title.value }), ...(steps && { steps }), truncated: true } + : p; + } + case "json": { + const serialized = JSON.stringify(p.data); + const { value, truncated } = capText(serialized); + return truncated ? { ...p, data: value, truncated: true } : p; } - // image (assetId ref), trace (small/by-ref), json (small) travel as-is. default: return p; } }); } +function parseRecentLimit(raw: string | undefined): number { + const parsed = Number(raw ?? "20"); + const limit = Number.isFinite(parsed) && parsed !== 0 ? Math.trunc(parsed) : 20; + return Math.min(Math.max(limit, 1), 100); +} + function isPublicReadAllowed(path: string, mode: PublicReadMode): boolean { if (mode === "full") return true; if (path.startsWith("/session/")) return true; @@ -1072,7 +1140,7 @@ export function createApp({ // isPublicReadAllowed, which intentionally does NOT expose this path on a // session-scoped publicRead board. app.get("/api/surfaces/recent", async (c) => { - const limit = Math.min(Math.max(Number(c.req.query("limit") ?? "20") || 20, 1), 100); + const limit = parseRecentLimit(c.req.query("limit")); const posts = await store.listRecentPosts(limit); // Resolve each post's session once (agent + session title for the feed card). const sessions = new Map(); diff --git a/test/surfaces-recent.test.ts b/test/surfaces-recent.test.ts index 08c11aa..fe83377 100644 --- a/test/surfaces-recent.test.ts +++ b/test/surfaces-recent.test.ts @@ -4,6 +4,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { createApp } from "../server/app.ts"; +import { createSqliteStorage } from "../server/sqliteStorage.ts"; +import { SqlStore } from "../server/sqlStore.ts"; import { JsonFileStore } from "../server/storage.ts"; function makeApp(authToken?: string, opts?: { publicRead?: "session" | "full" }) { @@ -95,6 +97,10 @@ test("GET /api/surfaces/recent respects and clamps limit", async () => { const two = (await (await app.request("/api/surfaces/recent?limit=2")).json()) as any[]; assert.equal(two.length, 2); + // a fractional limit truncates to an integer before it reaches SQLite LIMIT. + const fractional = (await (await app.request("/api/surfaces/recent?limit=1.5")).json()) as any[]; + assert.equal(fractional.length, 1); + // a negative limit clamps up to 1. const clampedLow = (await (await app.request("/api/surfaces/recent?limit=-5")).json()) as any[]; assert.equal(clampedLow.length, 1); @@ -110,17 +116,40 @@ test("GET /api/surfaces/recent respects and clamps limit", async () => { assert.equal(high.length, 5); }); +// SqlStore used to pass fractional values directly to SQLite LIMIT, which errors. +// Keep an endpoint-level regression test so parsing stays store-safe. +test("GET /api/surfaces/recent truncates fractional limits before querying SqlStore", async () => { + const store = new SqlStore(createSqliteStorage(":memory:")); + const app = createApp({ + store, + viewerHtml: "viewer", + guideMarkdown: "# guide", + setupText: "# setup", + agentHowtoText: "# agent how-to", + }); + const s = await createSession(app, "amp"); + await publish(app, { session: s.id, parts: [{ kind: "html", html: "

x

" }] }); + + const res = await app.request("/api/surfaces/recent?limit=1.5"); + assert.equal(res.status, 200); + assert.equal(((await res.json()) as any[]).length, 1); +}); + test("GET /api/surfaces/recent caps oversized text parts and flags truncation", async () => { const app = makeApp(); const s = await createSession(app, "amp"); const bigHtml = "x".repeat(20_000); const bigMarkdown = "# ".repeat(10_000); // 20k chars + const bigDiffSide = "-".repeat(20_000); + const bigJsonString = "j".repeat(20_000); const smallCode = "const a = 1;"; await publish(app, { session: s.id, parts: [ { kind: "html", html: bigHtml }, { kind: "markdown", markdown: bigMarkdown }, + { kind: "diff", files: [{ filename: "big.txt", before: bigDiffSide, after: "small" }] }, + { kind: "json", data: bigJsonString }, { kind: "code", code: smallCode, language: "ts" }, ], }); @@ -136,6 +165,14 @@ test("GET /api/surfaces/recent caps oversized text parts and flags truncation", assert.equal(md.markdown.length, 8_000); assert.equal(md.truncated, true); + const diff = parts.find((p: any) => p.kind === "diff"); + assert.equal(diff.files[0].before.length, 8_000); + assert.equal(diff.truncated, true); + + const jsonPart = parts.find((p: any) => p.kind === "json"); + assert.equal(jsonPart.data.length, 8_000); + assert.equal(jsonPart.truncated, true); + // a small part is left whole, with no truncated flag. const code = parts.find((p: any) => p.kind === "code"); assert.equal(code.code, smallCode);