From c2a88d3611242c49466e097a6a3da8240ccd5ae5 Mon Sep 17 00:00:00 2001 From: hshum Date: Tue, 2 Jun 2026 22:26:44 -0700 Subject: [PATCH 1/2] chore: document ScratchNode viral loop gaps --- AGENT_COORDINATION.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/AGENT_COORDINATION.md b/AGENT_COORDINATION.md index a3b19705..ea40dd2b 100644 --- a/AGENT_COORDINATION.md +++ b/AGENT_COORDINATION.md @@ -38,6 +38,28 @@ Keep entries short and honest. Newest on top within each section. ## Hand-offs (built + ready for the other agent to call) +- **2026-06-03 · Codex → Claude/Codex next builder** — Viral journey audit after #483: + - **Not end-to-end yet.** Entry/create, landing big number, post-create share moment, + public directory, open-room join, and request-card plumbing are live on + `scratchnode.live`. The back half is still incomplete. + - **Highest-leverage next slice:** public post-event wiki payoff. Build a public + route only for host-published wiki versions, with private notes excluded. Current + `scratchnode.live/e/:slug/wiki` still rewrites to the SPA shell; docs still mark + SEO wiki + OG + sitemap as future work. + - **Fast honesty fix:** answer-card `Share` buttons currently toast "Link copied" + without copying or generating a URL. Either copy a real event/answer deep link or + change the UI text until the route exists. + - **NodeBench bridge gap:** ScratchNode builds + `nodebenchai.com/sign-in?return=/events/:slug/private?...`, but live NodeBench has + no matching `/sign-in` or `/events/:slug/private` consumer. Existing real route is + `/scratchnode-events`; `/events/:eventId` is a corpus placeholder. Do not claim + ScratchNode to NodeBench continuation is live until the returned context renders in + NodeBench with the event, public wiki artifact, and private-note continuation. + - **Bouncer self-serve gap:** backend supports `joinPolicy: "request"` and the + directory can request approval, but the landing create form still hardcodes + `joinPolicy: "open"`. Add a host-facing create/control toggle before calling this + request-room flow self-serve. + - **2026-06-02 · Claude →** Door-policy **backend is LIVE on prod** (#480). Frontend can wire: - `events:requestJoinEvent({ slug, sessionId, displayName, note? })` → `{ ok, status: 'open' | 'pending' | 'approved' | 'already_member', requestId?, eventId, slug }` From 3f3dd317fa98034c27ebe89c05ed3f50b291fecb Mon Sep 17 00:00:00 2001 From: hshum Date: Tue, 2 Jun 2026 23:17:34 -0700 Subject: [PATCH 2/2] feat: ship ScratchNode public wiki loop --- AGENT_COORDINATION.md | 20 +- api/scratchnode-wiki.js | 196 ++++++++++++++++++ .../scratchnode.publicWikiRead.test.ts | 180 ++++++++++++++++ convex/events.ts | 26 ++- public/proto/home-v5.html | 69 +++++- .../scratchnode-live-route-honesty.spec.ts | 88 +++++++- vercel.json | 13 ++ 7 files changed, 576 insertions(+), 16 deletions(-) create mode 100644 api/scratchnode-wiki.js create mode 100644 convex/__tests__/scratchnode.publicWikiRead.test.ts diff --git a/AGENT_COORDINATION.md b/AGENT_COORDINATION.md index ea40dd2b..48f8ea71 100644 --- a/AGENT_COORDINATION.md +++ b/AGENT_COORDINATION.md @@ -34,10 +34,28 @@ Keep entries short and honest. Newest on top within each section. ## Active claims (who is editing what RIGHT NOW) -- _(none right now — `home-v5.html#directory` released; shipped, see Recently shipped.)_ +- _(Released: Codex packaged `codex/scratchnode-public-wiki-loop` for PR review; no active edit lock remains.)_ + +- **Codex · `home-v5.html#wiki-share`, `convex/events.ts#wiki-public-read`, `vercel.json#scratchnode-wiki-route`, `tests/e2e/scratchnode-live-route-honesty.spec.ts#wiki-route` · build published-only public wiki payoff + honest answer share · branch `codex/scratchnode-public-wiki-loop`.** ## Hand-offs (built + ready for the other agent to call) +- **2026-06-03 - Codex -> Claude/Codex next builder** - Public wiki payoff ready on + branch `codex/scratchnode-public-wiki-loop`: + - Convex public read: `events:getPublishedWikiBySlug({ slug })` returns only the + latest `status: "published"` wiki snapshot for a slug or room code: + `{ event: { eventId, slug, name, roomCode, status }, wiki: { wikiId, version, title, bodyHtml, sourceAnswerCount, sourceCount, publishedAt } }`. + - Vercel route: `scratchnode.live/e/:slug/wiki` rewrites to + `api/scratchnode-wiki.js`; unpublished/missing rooms return a 404 "No public + wiki yet" shell and does not expose room data. + - Room UI: after `snPublishWiki`, the Share sheet surfaces "Public wiki is live" + with a copyable `/wiki` URL; wiki sheet has open/copy public actions. + - Answer cards: live answer `Share` copies a real addressable URL + `/e/:slug#answer-` instead of only showing a toast. + - Verification: `npx vitest run convex/__tests__/scratchnode.publicWikiRead.test.ts`, + `npx playwright test tests/e2e/scratchnode-live-route-honesty.spec.ts --project=chromium --workers=1`, + `npx tsc --noEmit --pretty false`, `npm run build`. + - **2026-06-03 · Codex → Claude/Codex next builder** — Viral journey audit after #483: - **Not end-to-end yet.** Entry/create, landing big number, post-create share moment, public directory, open-room join, and request-card plumbing are live on diff --git a/api/scratchnode-wiki.js b/api/scratchnode-wiki.js new file mode 100644 index 00000000..39407c7e --- /dev/null +++ b/api/scratchnode-wiki.js @@ -0,0 +1,196 @@ +import { ConvexHttpClient } from "convex/browser"; +import { api } from "../convex/_generated/api.js"; + +let convexClient = null; + +function getConvexClient() { + const convexUrl = process.env.CONVEX_URL || process.env.VITE_CONVEX_URL || ""; + if (!convexUrl) return null; + if (!convexClient) convexClient = new ConvexHttpClient(convexUrl); + return convexClient; +} + +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function stripHtml(value) { + return String(value ?? "") + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function wikiUrl(slug) { + return `https://scratchnode.live/e/${encodeURIComponent(slug)}/wiki`; +} + +function renderShell({ title, statusCode, body, description, canonicalUrl }) { + const safeTitle = escapeHtml(title || "ScratchNode Wiki"); + const safeDescription = escapeHtml( + description || + "A public ScratchNode event wiki generated from host-published public answers and public sources.", + ); + const safeCanonical = escapeHtml(canonicalUrl || "https://scratchnode.live/"); + return ` + + + + + ${safeTitle} + + + + + + + + + + + +
${body}
+ +`; +} + +function renderPublished(payload) { + const event = payload.event; + const wiki = payload.wiki; + const title = wiki.title || `${event.name} Wiki`; + const description = stripHtml(wiki.bodyHtml).slice(0, 180) || + "Host-published public wiki from ScratchNode."; + const canonicalUrl = wikiUrl(event.slug); + const publishedDate = wiki.publishedAt ? new Date(wiki.publishedAt).toISOString().slice(0, 10) : "published"; + const body = ` +
+ ScratchNode + Open live room +
+
+
Public event wiki
+

${escapeHtml(title)}

+

A host-published record built from public room answers and public sources. Private notes are excluded.

+
+ Room ${escapeHtml(event.roomCode)} + Version ${escapeHtml(wiki.version)} + ${escapeHtml(publishedDate)} + ${escapeHtml(wiki.sourceAnswerCount)} public answers + ${escapeHtml(wiki.sourceCount)} sources +
+
+
+
${wiki.bodyHtml}
+ +
`; + return renderShell({ title, description, canonicalUrl, statusCode: "published", body }); +} + +function renderUnavailable(slug) { + const safeSlug = escapeHtml(slug || "room"); + const body = ` +
+ ScratchNode + Back to room +
+
+
Wiki not published
+

No public wiki yet

+

The room ${safeSlug} has no host-published wiki snapshot available at this URL.

+
+
+

Hosts can publish the public event wiki from the ScratchNode room. Until then, this route does not expose room data.

+
`; + return renderShell({ + title: "ScratchNode wiki not published", + statusCode: "unpublished", + body, + description: "This ScratchNode room has no host-published public wiki yet.", + canonicalUrl: `https://scratchnode.live/e/${encodeURIComponent(slug || "")}/wiki`, + }); +} + +export default async function handler(req, res) { + const url = new URL(req.url || "/", "https://scratchnode.live"); + const slug = String(url.searchParams.get("slug") || "").trim(); + res.setHeader("Access-Control-Allow-Origin", "*"); + if (!slug || slug.length > 120) { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.status(404).send(renderUnavailable(slug)); + return; + } + const client = getConvexClient(); + if (!client) { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.status(503).send(renderUnavailable(slug)); + return; + } + try { + const payload = await client.query(api.events.getPublishedWikiBySlug, { slug }); + if (url.searchParams.get("format") === "json") { + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "public, max-age=60, s-maxage=300"); + res.status(payload ? 200 : 404).json(payload || { ok: false, status: "unpublished" }); + return; + } + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Cache-Control", "public, max-age=60, s-maxage=300"); + if (!payload) { + res.status(404).send(renderUnavailable(slug)); + return; + } + res.status(200).send(renderPublished(payload)); + } catch (error) { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.status(500).send(renderShell({ + title: "ScratchNode wiki unavailable", + statusCode: "error", + description: "ScratchNode could not load this public wiki.", + canonicalUrl: `https://scratchnode.live/e/${encodeURIComponent(slug)}/wiki`, + body: ` +
ScratchNode
+
Wiki unavailable

Could not load this wiki

${escapeHtml(error?.message || "Unexpected wiki route error.")}

+ `, + })); + } +} diff --git a/convex/__tests__/scratchnode.publicWikiRead.test.ts b/convex/__tests__/scratchnode.publicWikiRead.test.ts new file mode 100644 index 00000000..5d2406cb --- /dev/null +++ b/convex/__tests__/scratchnode.publicWikiRead.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; + +import { getPublishedWikiBySlug } from "../events"; + +type Row = Record; +type Tables = Record; + +class MockIndexBuilder { + private filters: Array<{ field: string; value: unknown }> = []; + + eq(field: string, value: unknown) { + this.filters.push({ field, value }); + return this; + } + + getFilters() { + return this.filters; + } +} + +class MockQueryChain { + private orderDirection: "asc" | "desc" = "asc"; + + constructor( + private readonly rows: Row[], + private readonly filters: Array<{ field: string; value: unknown }>, + ) {} + + order(direction: "asc" | "desc") { + this.orderDirection = direction; + return this; + } + + async first() { + const rows = await this.take(1); + return rows[0] ?? null; + } + + async take(limit: number) { + const filtered = this.rows.filter((row) => + this.filters.every((filter) => row[filter.field] === filter.value), + ); + const sorted = [...filtered].sort((a, b) => { + const left = sortValue(a); + const right = sortValue(b); + return this.orderDirection === "desc" ? right - left : left - right; + }); + return sorted.slice(0, limit); + } +} + +function sortValue(row: Row) { + return row.version ?? row.publishedAt ?? row.createdAt ?? 0; +} + +class MockDb { + constructor(private readonly tables: Tables) {} + + query(table: string) { + const rows = this.tables[table] ?? []; + return { + withIndex: ( + _indexName: string, + build: (builder: MockIndexBuilder) => MockIndexBuilder, + ) => { + const builder = build(new MockIndexBuilder()); + return new MockQueryChain(rows, builder.getFilters()); + }, + }; + } +} + +function createCtx(tables: Tables) { + return { db: new MockDb(tables) }; +} + +async function getPublicWiki(ctx: ReturnType, slug: string) { + return await (getPublishedWikiBySlug as any)._handler(ctx, { slug }); +} + +describe("getPublishedWikiBySlug", () => { + it("returns the latest published snapshot without exposing draft/private-note data", async () => { + const ctx = createCtx({ + liveEvents: [ + { + _id: "liveEvents:public1", + slug: "demo-day", + name: "Demo Day", + roomCode: "ORBITAL", + status: "ended", + }, + ], + liveEventWikiVersions: [ + { + _id: "liveEventWikiVersions:old", + eventId: "liveEvents:public1", + version: 1, + status: "published", + title: "Older Wiki", + bodyHtml: "

Older public snapshot.

", + sourceAnswerIds: ["liveEventAnswers:old"], + sourceIds: ["liveEventSources:old"], + createdAt: 1770000000000, + publishedAt: 1770000000001, + }, + { + _id: "liveEventWikiVersions:new", + eventId: "liveEvents:public1", + version: 2, + status: "published", + title: "Demo Day Wiki", + bodyHtml: "

Demo Day Wiki

Public answer only.

", + sourceAnswerIds: ["liveEventAnswers:1", "liveEventAnswers:2"], + sourceIds: ["liveEventSources:1", "liveEventSources:2"], + createdAt: 1770000001000, + publishedAt: 1770000001001, + }, + { + _id: "liveEventWikiVersions:draft", + eventId: "liveEvents:public1", + version: 3, + status: "draft", + title: "Draft Wiki", + bodyHtml: "

PRIVATE LATENCY SECRET

", + sourceAnswerIds: ["liveEventAnswers:private"], + sourceIds: ["liveEventSources:private"], + createdAt: 1770000002000, + }, + ], + userNotes: [ + { + _id: "userNotes:private", + eventId: "liveEvents:public1", + body: "PRIVATE LATENCY SECRET", + }, + ], + }); + + const bySlug = await getPublicWiki(ctx, "demo-day"); + const byRoomCode = await getPublicWiki(ctx, " orbital "); + + expect(bySlug?.event.slug).toBe("demo-day"); + expect(bySlug?.wiki.version).toBe(2); + expect(bySlug?.wiki.sourceAnswerCount).toBe(2); + expect(bySlug?.wiki.sourceCount).toBe(2); + expect(bySlug?.wiki.bodyHtml).toContain("Public answer only"); + expect(bySlug?.wiki.bodyHtml).not.toContain("PRIVATE LATENCY SECRET"); + expect(byRoomCode?.wiki.wikiId).toBe("liveEventWikiVersions:new"); + }); + + it("returns null when the event is missing or only has draft wiki rows", async () => { + const ctx = createCtx({ + liveEvents: [ + { + _id: "liveEvents:draftOnly", + slug: "draft-only", + name: "Draft Only", + roomCode: "DRAFT", + status: "live", + }, + ], + liveEventWikiVersions: [ + { + _id: "liveEventWikiVersions:draft", + eventId: "liveEvents:draftOnly", + version: 1, + status: "draft", + title: "Draft Wiki", + bodyHtml: "

not public

", + sourceAnswerIds: [], + sourceIds: [], + createdAt: 1770000000000, + }, + ], + }); + + await expect(getPublicWiki(ctx, "draft-only")).resolves.toBeNull(); + await expect(getPublicWiki(ctx, "missing-room")).resolves.toBeNull(); + }); +}); diff --git a/convex/events.ts b/convex/events.ts index 094695a1..f22fda5f 100644 --- a/convex/events.ts +++ b/convex/events.ts @@ -1376,8 +1376,9 @@ export const getPublishedWiki = query({ export const getPublishedWikiBySlug = query({ args: { slug: v.string() }, handler: async (ctx, { slug }) => { - if (!slug || slug.length > 120) return null; - const event = await resolveEventBySlugOrRoomCode(ctx, slug); + const cleanSlug = String(slug || "").trim().toLowerCase(); + if (!cleanSlug || cleanSlug.length > 120) return null; + const event = await resolveEventBySlugOrRoomCode(ctx, cleanSlug); if (!event) return null; const rows = await ctx.db .query("liveEventWikiVersions") @@ -1386,6 +1387,9 @@ export const getPublishedWikiBySlug = query({ .take(1); const wiki = rows[0]; if (!wiki) return null; + const sourceAnswerIds = Array.isArray(wiki.sourceAnswerIds) ? wiki.sourceAnswerIds : []; + const sourceIds = Array.isArray(wiki.sourceIds) ? wiki.sourceIds : []; + const publishedAt = wiki.publishedAt ?? wiki.createdAt ?? null; return { eventName: event.name, eventSlug: event.slug, @@ -1394,7 +1398,23 @@ export const getPublishedWikiBySlug = query({ title: wiki.title, bodyHtml: wiki.bodyHtml, version: wiki.version, - publishedAt: wiki.publishedAt ?? wiki.createdAt ?? null, + publishedAt, + event: { + eventId: event._id, + slug: event.slug, + name: event.name, + roomCode: event.roomCode, + status: event.status, + }, + wiki: { + wikiId: wiki._id, + version: wiki.version, + title: wiki.title, + bodyHtml: wiki.bodyHtml, + sourceAnswerCount: sourceAnswerIds.length, + sourceCount: sourceIds.length, + publishedAt, + }, }; }, }); diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 8338bc1b..7de8e7d9 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -3578,6 +3578,41 @@

Keyboard shortcuts

EVENT_URL = PUBLIC_BASE_URL + '/e/' + encodeURIComponent(EVENT_SLUG); } +function getPublicWikiUrl() { + refreshEventUrl(); + return EVENT_URL + '/wiki'; +} + +function copyTextOrToast(text, successTitle, successDetail) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text).then(function() { + toast(successTitle || 'Copied', successDetail || text); + haptic && haptic(); + return true; + }).catch(function() { + toast('Copy unavailable', text); + return false; + }); + } + toast('Copy unavailable', text); + return Promise.resolve(false); +} + +function answerAnchorId(answerId) { + return 'answer-' + encodeURIComponent(String(answerId || '').trim() || 'current'); +} + +function shareAnswerLink(answerId) { + refreshEventUrl(); + var id = String(answerId || '').trim(); + var url = EVENT_URL + (id ? '#' + answerAnchorId(id) : ''); + return copyTextOrToast(url, 'Answer link copied', url); +} + +function copyPublicWikiUrl() { + return copyTextOrToast(getPublicWikiUrl(), 'Wiki link copied', 'Share the public event wiki.'); +} + function setScratchNodeLiveEventContext(event) { if (!event) return; EVENT_SLUG = event.slug || EVENT_SLUG; @@ -3891,7 +3926,7 @@

Keyboard shortcuts

'
Answered from event wiki·' + sim + ' similar·' + reused + ' reused·0 new searches·' + privBadge + '
' + '' + '
Checked event wiki cache · sourceBundle fresh
Matched ' + sim + ' similar questions in semantic cache
Reused ' + reused + ' sources from event corpus
0 new searches · Linkup skipped
' + traceLastStep + '
' + - '
'; + '
'; ans.querySelector('.ans-q').textContent = intent.clean; feed.appendChild(ans); try { if (typeof ans.scrollIntoView === 'function') ans.scrollIntoView({ behavior: 'smooth', block: 'end' }); } catch (e) {} @@ -3984,11 +4019,13 @@

Keyboard shortcuts

// the clipboard write actually resolved; the native share sheet is its own feedback. function _snShareAnswer(answerId, explicitQuestion) { var url = (typeof EVENT_URL !== 'undefined' && EVENT_URL) ? EVENT_URL : location.href.split('#')[0]; + var shareUrl = answerId ? (url + '#' + answerAnchorId(answerId)) : url; + url = shareUrl; var title = (typeof EVENT_TITLE !== 'undefined' && EVENT_TITLE) ? EVENT_TITLE : 'ScratchNode'; var q = explicitQuestion || (window._sn_answer_q_by_id && answerId && window._sn_answer_q_by_id[answerId]) || ''; var text = (q ? '"' + q + '" — answered live in ' + title + ' on ScratchNode' - : 'Answered live in ' + title + ' on ScratchNode') + '\n' + url; + : 'Answered live in ' + title + ' on ScratchNode') + '\n' + shareUrl; if (navigator.share) { navigator.share({ title: title + ' · ScratchNode', text: text, url: url }).catch(function(){}); return; @@ -4485,6 +4522,9 @@

Keyboard shortcuts

share: function() { var url = EVENT_URL; + var wikiLink = window._sn_published_wiki_body + ? '

Public wiki is live

Share the post-event artifact: public Q&A, sources, and what changed. Private notes stay out.

' + : ''; return '

Share ' + escapeHtml(EVENT_TITLE) + '

' + '' + '' + + wikiLink + '

Or scan to join on mobile

' + '
QR code
' + '
Live room code ' + escapeHtml(EVENT_ROOM_CODE) + ' · realtime chat backed by Convex
'; @@ -4524,9 +4565,9 @@

Keyboard shortcuts

wiki: function() { if (window._sn_published_wiki_body) { return '

' + escapeHtml(EVENT_TITLE) + ' · Wiki

live Convex
' + - '
' + + '
' + '
' + window._sn_published_wiki_body + '
' + - '
'; + '
'; } if (window._sn_live && window._sn_live.eventId) { return '

' + escapeHtml(EVENT_TITLE) + ' · Wiki

not published
' + @@ -5244,9 +5285,7 @@

Keyboard shortcuts

// ── Share platform handlers ── function copyShareUrl() { refreshEventUrl(); - if (navigator.clipboard) { - navigator.clipboard.writeText(EVENT_URL).then(function() { toast('Copied', EVENT_URL); haptic(); }); - } + return copyTextOrToast(EVENT_URL, 'Copied join link', EVENT_URL); } function shareTo(p) { refreshEventUrl(); @@ -5523,7 +5562,7 @@

Keyboard shortcuts

'
Answered from event wiki·' + sim + ' similar·' + reused + ' reused·0 new searches·🔒 no private notes
' + '' + '
Checked event wiki cache · sourceBundle fresh
Matched ' + sim + ' similar questions in semantic cache
Reused ' + reused + ' sources from event corpus
0 new searches · Linkup skipped
No private notes used · public layer only
' + - '
'; + '
'; ans.querySelector('.ans-q').textContent = question; ans.querySelector('.ans-body').textContent = answerText; feed.appendChild(ans); @@ -7022,6 +7061,7 @@

Keyboard shortcuts

seenAnswerIds.add(answer._id); const article = document.createElement('article'); article.className = 'ans'; + article.id = answerAnchorId(answer._id); article.setAttribute('data-answer-id', answer._id); const sourceCount = (answer.sources || []).length || (answer.sourceIds || []).length || 0; const sourceChips = (answer.sources || []).slice(0, 5).map((source) => @@ -7684,11 +7724,15 @@

Keyboard shortcuts

const ownerKey = _snReadHostOwnerKey(); client.mutation('events:publishWiki', { eventId, ownerKey }) .then((res) => { - toast('Wiki published', 'Version ' + res.version + ' is now live.'); + window._sn_public_wiki_url = getPublicWikiUrl(); + toast('Wiki published', 'Version ' + res.version + ' is live at the public wiki URL.'); return client.query('events:getPublishedWiki', { eventId }); }) .then((wiki) => { - if (wiki) window._sn_published_wiki_body = wiki.bodyHtml; + if (wiki) { + window._sn_published_wiki_body = wiki.bodyHtml; + window._sn_public_wiki_url = getPublicWikiUrl(); + } }) .catch((e) => toast('Wiki publish blocked', e.message || String(e))); }; @@ -7715,7 +7759,10 @@

Keyboard shortcuts

client.query('events:getPublishedWiki', { eventId }) .then((wiki) => { - if (wiki) window._sn_published_wiki_body = wiki.bodyHtml; + if (wiki) { + window._sn_published_wiki_body = wiki.bodyHtml; + window._sn_public_wiki_url = getPublicWikiUrl(); + } }) .catch(() => {}); diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 476ac0c2..36183aed 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -98,6 +98,11 @@ async function fulfillScratchNodePage( localStorage.setItem('__snEndedEventArgs', JSON.stringify(args)); return Promise.resolve({ ok: true, eventId: args.eventId, status: 'ended' }); } + if (name === 'events:publishWiki') { + window.__snWikiPublished = true; + localStorage.setItem('__snWikiPublishArgs', JSON.stringify(args)); + return Promise.resolve({ ok: true, version: 1, wikiId: 'liveEventWikiVersions:1' }); + } if (name === 'events:requestJoinEvent') { window.__snRequestJoinArgs = args; localStorage.setItem('__snRequestJoinArgs', JSON.stringify(args)); @@ -115,7 +120,18 @@ async function fulfillScratchNodePage( } query(name) { if (name === 'events:getMyEvents') return Promise.resolve({ joined: [], hosted: [] }); - if (name === 'events:getPublishedWiki') return Promise.resolve(null); + if (name === 'events:getPublishedWiki') { + if (!window.__snWikiPublished) return Promise.resolve(null); + return Promise.resolve({ + version: 1, + title: 'AI Infra Summit Wiki', + bodyHtml: '

AI Infra Summit Wiki

Published public memory.

', + sourceAnswerIds: ['liveEventAnswers:share1'], + sourceIds: ['liveEventSources:1'], + createdAt: 1770000000000, + publishedAt: 1770000001000, + }); + } if (name === 'events:getHostStatus') { const token = localStorage.getItem('sn_host_owner_key_v2'); return Promise.resolve(token ? { isHost: true, role: 'owner', displayName: 'Mock Host' } : { isHost: false }); @@ -135,6 +151,10 @@ async function fulfillScratchNodePage( const rooms = window.__snPublicRooms || []; setTimeout(() => cb({ rooms, activeWindowMs: 1800000 }), 0); } + if (name === 'events:getAnswers') { + const answers = window.__snAnswers || []; + setTimeout(() => cb(answers), 0); + } if (name === 'events:getMyJoinRequest') { const tick = () => { const status = window.__snJoinRequestStatus || 'pending'; @@ -273,6 +293,72 @@ test.describe("ScratchNode live route honesty", () => { await expect(page.locator("#sheet-content")).not.toContainText("318 joined"); }); + test("published wiki exposes a real public wiki URL in the share sheet", async ({ page }) => { + await fulfillScratchNodePage(page); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page.addInitScript(() => { + localStorage.setItem( + "sn_host_owner_key_v2", + "hk1:liveEvents:1:nonce:1770000000000:abcdefabcdefabcdefabcdefabcdef12", + ); + }); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + await expect + .poll(() => page.evaluate(() => typeof (window as any).snPublishWiki), { timeout: 5_000 }) + .toBe("function"); + + await page.evaluate(() => (window as any).snPublishWiki()); + await expect + .poll(() => page.evaluate(() => (window as any)._sn_published_wiki_body || ""), { + timeout: 5_000, + }) + .toContain("Published public memory"); + + await page.evaluate(() => (window as any).openShare()); + await expect(page.locator("#sheet-content")).toContainText("Public wiki is live"); + await expect(page.locator("#sheet-content code").filter({ hasText: "/wiki" })).toContainText( + "/e/ai-infra-summit-2026/wiki", + ); + + await page.getByRole("button", { name: "Copy wiki" }).click(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toContain( + "/e/ai-infra-summit-2026/wiki", + ); + }); + + test("answer share copies an addressable answer URL instead of only showing a toast", async ({ + page, + }) => { + await fulfillScratchNodePage(page); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page.addInitScript(() => { + (window as any).__snAnswers = [ + { + _id: "liveEventAnswers:share1", + question: "What did we decide?", + body: "We agreed to ship the public wiki loop.", + sourceIds: ["liveEventSources:1"], + sources: [{ title: "Agenda", uri: "doc://agenda", excerpt: "Public agenda source." }], + createdAt: 1770000000000, + agentMode: "deterministic", + cacheHit: false, + }, + ]; + }); + + await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true"); + const answer = page.locator('[data-answer-id="liveEventAnswers:share1"]'); + await expect(answer).toContainText("We agreed to ship the public wiki loop."); + + await answer.getByRole("button", { name: "Share" }).click(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toContain( + "/e/ai-infra-summit-2026#answer-liveEventAnswers%3Ashare1", + ); + }); + test("host create event uses live mutation and navigates to the created room", async ({ page }) => { await fulfillScratchNodePage(page); diff --git a/vercel.json b/vercel.json index 22c8d82e..54038eaa 100644 --- a/vercel.json +++ b/vercel.json @@ -50,6 +50,9 @@ }, "api/voice.js": { "maxDuration": 60 + }, + "api/scratchnode-wiki.js": { + "maxDuration": 10 } }, "redirects": [ @@ -128,6 +131,16 @@ ], "destination": "/proto/home-v5.html" }, + { + "source": "/e/:slug/wiki", + "has": [ + { + "type": "host", + "value": "scratchnode.live" + } + ], + "destination": "/api/scratchnode-wiki?slug=:slug" + }, { "source": "/e/:slug*", "has": [