Spaces:
Sleeping
Sleeping
Download test/smoke.js from lvwerra/cowrite-dev: direct link, hf CLI and curl.
- Browser
- Download file 93.9 kB
-
https://huggingface.co/spaces/lvwerra/cowrite-dev/resolve/main/test/smoke.js
- Command line
-
hf download hf://spaces/lvwerra/cowrite-dev/test/smoke.js
-
curl -L -o smoke.js https://huggingface.co/spaces/lvwerra/cowrite-dev/resolve/main/test/smoke.js
93.9 kB
| // End-to-end smoke test: runs the real server, real Yjs clients, and a fake | |
| // agent talking over the HTTP API with an app-issued agent key. No browser needed. | |
| import { spawn } from 'node:child_process' | |
| import fs from 'node:fs' | |
| import zlib from 'node:zlib' | |
| import path from 'node:path' | |
| import assert from 'node:assert' | |
| import { fileURLToPath } from 'node:url' | |
| import * as Y from 'yjs' | |
| import { HocuspocusProvider } from '@hocuspocus/provider' | |
| import WebSocket from 'ws' | |
| import { getSchema } from '@tiptap/core' | |
| import StarterKit from '@tiptap/starter-kit' | |
| import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror' | |
| const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') | |
| const PORT = 3199 | |
| const BASE = `http://localhost:${PORT}` | |
| const DATA = path.join(root, '.smoke-data') | |
| const schema = getSchema([StarterKit]) | |
| let server | |
| function startServer() { | |
| server = spawn('node', ['server/index.js'], { | |
| cwd: root, | |
| env: { ...process.env, PORT: String(PORT), DATA_DIR: DATA, OAUTH_CLIENT_ID: '', OAUTH_CLIENT_SECRET: '', SPACE_AUTHOR_NAME: '', ADMIN_USER: 'root-admin' }, | |
| stdio: ['ignore', 'pipe', 'pipe'], | |
| }) | |
| server.stdout.on('data', d => process.env.SMOKE_VERBOSE && process.stdout.write('[server] ' + d)) | |
| server.stderr.on('data', d => process.stderr.write('[server:err] ' + d)) | |
| return waitFor(async () => (await fetch(`${BASE}/healthz`)).ok, 'server start') | |
| } | |
| async function stopServer() { | |
| server.kill('SIGTERM') | |
| await new Promise(r => server.on('exit', r)) | |
| } | |
| async function waitFor(fn, what, ms = 10000) { | |
| const t0 = Date.now() | |
| while (Date.now() - t0 < ms) { | |
| try { | |
| if (await fn()) return | |
| } catch {} | |
| await new Promise(r => setTimeout(r, 150)) | |
| } | |
| throw new Error(`timeout waiting for ${what}`) | |
| } | |
| function api(token, path_, { method = 'GET', body } = {}) { | |
| return fetch(BASE + path_, { | |
| method, | |
| headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) }, | |
| body: body ? JSON.stringify(body) : undefined, | |
| }).then(r => r.json()) | |
| } | |
| // Read a zip the server produced, so the test checks the bytes a user would get | |
| // rather than trusting the writer. No data descriptors, so the local headers | |
| // carry the sizes and a single forward pass is enough. | |
| function readZip(buf) { | |
| const files = {} | |
| let i = 0 | |
| while (i + 4 <= buf.length && buf.readUInt32LE(i) === 0x04034b50) { | |
| const method = buf.readUInt16LE(i + 8) | |
| const csize = buf.readUInt32LE(i + 18) | |
| const nameLen = buf.readUInt16LE(i + 26) | |
| const extraLen = buf.readUInt16LE(i + 28) | |
| const name = buf.subarray(i + 30, i + 30 + nameLen).toString('utf8') | |
| const start = i + 30 + nameLen + extraLen | |
| const body = buf.subarray(start, start + csize) | |
| if (!name.endsWith('/')) files[name] = method === 8 ? zlib.inflateRawSync(body) : Buffer.from(body) | |
| i = start + csize | |
| } | |
| return files | |
| } | |
| async function streamPoll(key, wait = 10) { | |
| const resp = await fetch(`${BASE}/api/mentions/stream?wait=${wait}`, { headers: { authorization: `Bearer ${key}` } }) | |
| let text = '' | |
| for await (const chunk of resp.body) text += Buffer.from(chunk).toString() | |
| const last = text.trim().split('\n').filter(l => !l.startsWith(':')).pop() | |
| return JSON.parse(last) | |
| } | |
| function connect(docId, token) { | |
| const doc = new Y.Doc() | |
| const provider = new HocuspocusProvider({ | |
| url: `ws://localhost:${PORT}/collab`, | |
| name: docId, | |
| document: doc, | |
| token, | |
| }) | |
| return { doc, provider } | |
| } | |
| // A visitor's browser sends the share cookie on the socket handshake; node's ws | |
| // needs it spelled out. | |
| function shareConnect(docId, shareToken, token = undefined) { | |
| const doc = new Y.Doc() | |
| class CookieWS extends WebSocket { | |
| constructor(url, protocols) { | |
| super(url, protocols, { headers: { cookie: `ie_share=${shareToken}` } }) | |
| } | |
| } | |
| const provider = new HocuspocusProvider({ | |
| url: `ws://localhost:${PORT}/collab`, | |
| name: docId, | |
| document: doc, | |
| token, // set to sign the visitor in as somebody with no access of their own | |
| WebSocketPolyfill: CookieWS, | |
| }) | |
| return { doc, provider } | |
| } | |
| function writeLegacyProjectDoc(file, nodes, suggestions = []) { | |
| const doc = new Y.Doc() | |
| doc.getXmlFragment('default').insert(0, nodes) | |
| for (const suggestion of suggestions) doc.getMap('suggestions').set(suggestion.id, suggestion) | |
| fs.writeFileSync(file, Buffer.from(Y.encodeStateAsUpdate(doc))) | |
| doc.destroy() | |
| } | |
| function legacyTextBlock(type, text, attrs = {}) { | |
| const block = new Y.XmlElement(type) | |
| for (const [key, value] of Object.entries(attrs)) block.setAttribute(key, value) | |
| const content = new Y.XmlText() | |
| content.insert(0, text) | |
| block.insert(0, [content]) | |
| return block | |
| } | |
| async function main() { | |
| // 0. inline markdown emphasis (pure parser) — underscore emphasis must work | |
| // AND intra-word underscores (filenames, snake_case) must stay literal | |
| { | |
| const { tokenizeInline } = await import('../server/md.js') | |
| const marksOf = s => tokenizeInline(s).map(x => Object.keys(x.attrs)[0] || null) | |
| const und = tokenizeInline('_Sources: see icm2026_schedule.html here._') | |
| assert.strictEqual(und.length, 1, 'underscore italic is one segment') | |
| assert.strictEqual(und[0].attrs.italic != null, true, 'underscore span is italic') | |
| assert.ok(und[0].text.includes('icm2026_schedule.html'), 'intra-word underscore preserved inside span') | |
| assert.deepStrictEqual(marksOf('plain snake_case_name stays literal'), [null], 'intra-word underscores are not emphasis') | |
| assert.deepStrictEqual(marksOf('mix _italic_ and __bold__ x'), [null, 'italic', null, 'bold', null], 'both underscore forms') | |
| assert.deepStrictEqual(marksOf('keep *star* and **bold**'), [null, 'italic', null, 'bold'], 'asterisk emphasis still works') | |
| console.log('✓ inline emphasis: underscore italic/bold parsed, intra-word underscores literal') | |
| // marks NEST: markdown inside a bold/italic/strike/link span must still be | |
| // parsed. A single-pass tokenizer emitted the body verbatim, so a code span, | |
| // link or formula inside **bold** rendered as literal backticks/brackets/$. | |
| const nest = str => tokenizeInline(str).map(x => (x.math != null ? 'math:' + x.math : Object.keys(x.attrs).sort().join('+') + ':' + x.text)) | |
| assert.deepStrictEqual(nest('**bold with `code` here**'), ['bold:bold with ', 'bold+code:code', 'bold: here'], 'code span inside bold') | |
| assert.deepStrictEqual(nest('**bold with *italic* here**'), ['bold:bold with ', 'bold+italic:italic', 'bold: here'], 'italic inside bold') | |
| assert.deepStrictEqual(nest('*italic with **bold** here*'), ['italic:italic with ', 'bold+italic:bold', 'italic: here'], 'bold inside italic') | |
| assert.deepStrictEqual(nest('**see [docs](http://x.y) now**'), ['bold:see ', 'bold+link:docs', 'bold: now'], 'link inside bold') | |
| assert.deepStrictEqual(nest('**math $x^2$ here**'), ['bold:math ', 'math:x^2', 'bold: here'], 'formula inside bold') | |
| assert.deepStrictEqual(nest('~~struck with **bold**~~'), ['strike:struck with ', 'bold+strike:bold'], 'bold inside strikethrough') | |
| assert.deepStrictEqual(nest('[a **bold** link](http://x.y)'), ['link:a ', 'bold+link:bold', 'link: link'], 'bold inside a link') | |
| assert.deepStrictEqual(nest('***both***'), ['bold+italic:both'], 'triple delimiter is bold+italic') | |
| assert.deepStrictEqual(nest('___both___'), ['bold+italic:both'], 'triple underscore is bold+italic') | |
| // a code span's body stays literal — that is what backticks mean | |
| assert.deepStrictEqual(nest('`code with **stars**`'), ['code:code with **stars**'], 'markdown inside a code span is literal') | |
| // and the serializer must nest in an order that survives a round trip: | |
| // `code` innermost (its body is literal), link outermost | |
| const { pmToMarkdown } = await import('../server/md.js') | |
| const ser = marks => pmToMarkdown({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 't', marks }] }] }).trim() | |
| assert.strictEqual(ser([{ type: 'bold' }, { type: 'code' }]), '**`t`**', 'bold+code serializes with code innermost') | |
| assert.strictEqual(ser([{ type: 'code' }, { type: 'bold' }]), '**`t`**', 'mark order in the doc does not change the markdown') | |
| assert.strictEqual(ser([{ type: 'bold' }, { type: 'italic' }]), '***t***', 'bold+italic serializes as ***') | |
| for (const marks of [[{ type: 'bold' }, { type: 'code' }], [{ type: 'bold' }, { type: 'italic' }], [{ type: 'italic' }, { type: 'strike' }], [{ type: 'bold' }, { type: 'italic' }, { type: 'link', attrs: { href: 'http://x.y' } }]]) { | |
| const segs = tokenizeInline(ser(marks)) | |
| assert.strictEqual(segs.length, 1, 'round trip stays one segment: ' + ser(marks)) | |
| const got = new Set(Object.keys(segs[0].attrs)) | |
| for (const m of marks) assert.ok(got.has(m.type), `round trip keeps ${m.type} in ${ser(marks)}`) | |
| } | |
| console.log('✓ inline nesting: markdown inside bold/italic/strike/link parses, marks round-trip') | |
| } | |
| fs.rmSync(DATA, { recursive: true, force: true }) | |
| await startServer() | |
| console.log('✓ server started') | |
| // 1. alice creates a doc | |
| const { id: docId } = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Smoke Test Doc' } }) | |
| assert.ok(docId, 'doc created') | |
| // 2. ACL: bob cannot see or join the doc before it is shared | |
| const denied = await api('dev:bob', `/api/docs/${docId}`) | |
| assert.ok(denied.error, 'unshared doc hidden from bob') | |
| const bobList = await api('dev:bob', '/api/docs') | |
| assert.equal(bobList.docs.length, 0, 'doc list filtered') | |
| const sneaky = connect(docId, 'dev:bob') | |
| await new Promise(r => setTimeout(r, 1500)) | |
| assert.equal(sneaky.doc.getXmlFragment('default').length, 0, 'ws sync denied for unshared doc') | |
| sneaky.provider.destroy() | |
| console.log('✓ ACL: unshared doc invisible to others (list, read, websocket)') | |
| // 3. alice shares with bob; bob cannot re-share | |
| const share = await api('dev:alice', `/api/docs/${docId}/share`, { method: 'POST', body: { username: 'bob' } }) | |
| assert.equal(share.ok, true, 'share: ' + JSON.stringify(share)) | |
| const reshare = await api('dev:bob', `/api/docs/${docId}/share`, { method: 'POST', body: { username: 'carol' } }) | |
| assert.ok(reshare.error, 'only creator can share') | |
| const bobRead = await api('dev:bob', `/api/docs/${docId}`) | |
| assert.ok(bobRead.markdown != null, 'bob can read after share') | |
| console.log('✓ sharing: creator adds bob, bob can read, bob cannot re-share') | |
| // 4. bob registers an agent handle -> gets an app-issued key | |
| const reg = await api('dev:bob', '/api/agents', { method: 'POST', body: { handle: 'test-agent' } }) | |
| assert.equal(reg.ok, true, 'agent registered: ' + JSON.stringify(reg)) | |
| assert.ok(reg.key?.startsWith('ak_'), 'agent key issued') | |
| const KEY = reg.key | |
| const steal = await api('dev:alice', '/api/agents', { method: 'POST', body: { handle: 'test-agent' } }) | |
| assert.ok(steal.error, 'cannot steal a handle') | |
| // a new handle is unshared, so bob must share it before alice's mentions can | |
| // reach it — the rest of this suite drives it as alice, a collaborator | |
| const shareAgent = await api('dev:bob', '/api/agents/test-agent/visibility', { method: 'POST', body: { shared: true } }) | |
| assert.equal(shareAgent.shared, true, 'fixture agent shared with collaborators: ' + JSON.stringify(shareAgent)) | |
| // agent keys are agents, not humans | |
| const agentDoc = await api(KEY, '/api/docs', { method: 'POST', body: { title: 'nope' } }) | |
| assert.ok(agentDoc.error, 'agent key cannot create docs') | |
| console.log('✓ agent key issued; human-only actions blocked for keys') | |
| // key rotation invalidates the old key | |
| const rotated = await api('dev:bob', '/api/agents/test-agent/rotate', { method: 'POST' }) | |
| assert.ok(rotated.key?.startsWith('ak_'), 'rotated') | |
| const oldKeyPoll = await fetch(`${BASE}/api/mentions`, { headers: { authorization: `Bearer ${KEY}` } }) | |
| assert.equal(oldKeyPoll.status, 401, 'old key dead after rotation') | |
| const AGENT = rotated.key | |
| console.log('✓ key rotation works, old key revoked') | |
| // 4b. the agent prompt is the whole interface an agent gets: if a line goes | |
| // missing, that capability effectively no longer exists. Pin the contract, and | |
| // a ceiling — the prompt is read by a model with other things to do. | |
| { | |
| const prompt = await fetch(`${BASE}/api/agent-prompt?doc=${docId}&handle=test-agent`, { headers: { authorization: 'Bearer dev:alice' } }).then(r => r.text()) | |
| const required = [ | |
| ['/api/mentions/stream', 'how to wait for work'], | |
| ['/api/mentions/<mention_id>/dismiss', 'how to drop a follow-up'], | |
| ['/threads/<thread_id>/reply', 'how to reply'], | |
| ['/threads', 'how to open its own comment'], | |
| ['/suggestions', 'how to propose an edit'], | |
| ['/page-suggestions', 'how to propose a page'], | |
| ['/upload', 'how to add an image'], | |
| ['/structure', 'how to read the page tree'], | |
| ['block_index', 'how to anchor'], | |
| ['supersedes', 'how to revise'], | |
| ['mention_id', 'how to close the request'], | |
| ['html-embed', 'how to embed html'], | |
| ['as_agent', 'how to sign its work'], | |
| // Both of these are live in the server and delivered on every mention, so | |
| // a prompt that never names them makes the capability unreachable: an | |
| // agent cannot link its comments back to the thread that asked, and reads | |
| // a position anchor as if it were the subject of the comment. | |
| ['origin_thread_id', 'how to keep what it writes linked to the source thread'], | |
| ['anchor_kind', 'how to tell a span anchor from a position anchor'], | |
| ] | |
| for (const [needle, why] of required) { | |
| assert.ok(prompt.includes(needle), `prompt still says ${why} (${needle})`) | |
| } | |
| // Presence checks alone missed two defects: every example carried "@handle" | |
| // (agentIdentity compares against the BARE handle, so each one would have | |
| // been rejected as belonging to someone else) and two examples both carried | |
| // mention_id while the text said to send it once. The examples are the | |
| // interface — assert they would actually run. | |
| assert.equal((prompt.match(/"as_agent": "@/g) || []).length, 0, 'examples pass the bare handle, not @handle') | |
| assert.ok(prompt.includes('"as_agent": "test-agent"'), 'and pass it as the handle the key belongs to') | |
| assert.equal( | |
| (prompt.match(/"mention_id": "<mention_id>"/g) || []).length, | |
| 1, | |
| 'exactly one example closes the request, matching what the text says' | |
| ) | |
| assert.ok(prompt.length < 9000, `prompt stays tight: ${prompt.length} chars`) | |
| console.log(`✓ agent prompt: complete and tight (${prompt.length} chars)`) | |
| } | |
| // 5. alice connects over websocket, edits, creates a thread mentioning the agent | |
| const alice = connect(docId, 'dev:alice') | |
| const frag = alice.doc.getXmlFragment('default') | |
| await waitFor(() => frag.length >= 2, 'initial sync') | |
| alice.doc.transact(() => { | |
| const p = frag.get(1) | |
| const t = new Y.XmlText() | |
| t.insert(0, 'This intro paragraph is quite bad and needs work.') | |
| p.insert(0, [t]) | |
| }) | |
| console.log('✓ collab sync + edit works') | |
| // 5b. public share links: one long URL, read-only, revocable | |
| { | |
| const anon = (path_, opts) => api('', path_, opts) // no bearer at all | |
| const bobLink = await api('dev:bob', `/api/docs/${docId}/public-link`, { method: 'POST' }) | |
| assert.ok(bobLink.error, 'a collaborator cannot publish a link: ' + JSON.stringify(bobLink)) | |
| const link = await api('dev:alice', `/api/docs/${docId}/public-link`, { method: 'POST' }) | |
| assert.ok(link.token && link.token.length >= 32, 'token is long: ' + JSON.stringify(link)) | |
| assert.ok(link.url.endsWith(`/p/${link.token}`), 'url points at /p/<token>: ' + link.url) | |
| const again = await api('dev:alice', `/api/docs/${docId}/public-link`, { method: 'POST' }) | |
| assert.equal(again.token, link.token, 'minting is idempotent — the same link comes back') | |
| // a visitor with no account reads it | |
| const view = await anon(`/api/docs/${docId}?share=${link.token}`) | |
| assert.ok(view.markdown?.includes('intro paragraph'), 'link holder reads the doc: ' + JSON.stringify(view).slice(0, 120)) | |
| assert.deepEqual(view.shared_with, [], 'the collaborator list is not published') | |
| // ...and nothing else | |
| const wrong = await anon(`/api/docs/${docId}?share=${'x'.repeat(link.token.length)}`) | |
| assert.ok(wrong.error, 'a wrong token reads nothing') | |
| const otherDoc = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Not Shared' } }) | |
| const crossDoc = await anon(`/api/docs/${otherDoc.id}?share=${link.token}`) | |
| assert.ok(crossDoc.error, 'a token opens only its own project: ' + JSON.stringify(crossDoc)) | |
| await api('dev:alice', `/api/docs/${otherDoc.id}`, { method: 'DELETE' }) | |
| for (const [what, call] of [ | |
| ['suggest', () => anon(`/api/docs/${docId}/suggestions?share=${link.token}`, { method: 'POST', body: { block_index: 0, replacement_markdown: 'nope' } })], | |
| ['add a page', () => anon(`/api/docs/${docId}/pages?share=${link.token}`, { method: 'POST', body: { title: 'nope' } })], | |
| ['share on', () => anon(`/api/docs/${docId}/share?share=${link.token}`, { method: 'POST', body: { username: 'mallory' } })], | |
| ['delete', () => anon(`/api/docs/${docId}?share=${link.token}`, { method: 'DELETE' })], | |
| ['re-link', () => anon(`/api/docs/${docId}/public-link?share=${link.token}`, { method: 'POST' })], | |
| ]) { | |
| const res = await call() | |
| assert.ok(res.error, `a link holder cannot ${what}: ` + JSON.stringify(res)) | |
| } | |
| // the socket is where read-only has to be real: connect as a visitor and edit | |
| const viewer = shareConnect(docId, link.token) | |
| await waitFor(() => viewer.doc.getXmlFragment('default').length >= 2, 'visitor syncs the document') | |
| const seen = viewer.doc.getXmlFragment('default').toString() | |
| assert.ok(seen.includes('intro paragraph'), 'visitor sees the content') | |
| viewer.doc.transact(() => { | |
| const t = new Y.XmlText() | |
| t.insert(0, 'VANDALISM') | |
| viewer.doc.getXmlFragment('default').get(1).insert(0, [t]) | |
| }) | |
| await new Promise(r => setTimeout(r, 1500)) | |
| assert.ok(!frag.toString().includes('VANDALISM'), 'the edit never reaches another client') | |
| const afterWrite = await api('dev:alice', `/api/docs/${docId}`) | |
| assert.ok(!afterWrite.markdown.includes('VANDALISM'), 'and it is not persisted: ' + afterWrite.markdown.slice(0, 80)) | |
| viewer.provider.destroy() | |
| // signed in as someone with no access to THIS project: the link is what | |
| // carries them, and the socket must agree with REST about that. Before the | |
| // fix, a session made `user` truthy, the share branch never ran, and the | |
| // page loaded and then never synced. | |
| const stranger = shareConnect(docId, link.token, 'dev:mallory') | |
| await waitFor(() => stranger.doc.getXmlFragment('default').length >= 2, 'a signed-in stranger syncs through the link') | |
| stranger.doc.transact(() => { | |
| const t = new Y.XmlText() | |
| t.insert(0, 'STRANGER EDIT') | |
| stranger.doc.getXmlFragment('default').get(1).insert(0, [t]) | |
| }) | |
| await new Promise(r => setTimeout(r, 1200)) | |
| assert.ok(!frag.toString().includes('STRANGER EDIT'), 'and is still read-only') | |
| stranger.provider.destroy() | |
| // a link publishes the project's TEXT, not who works on it. A signed-in | |
| // stranger has a req.user, so "is anyone signed in?" was the wrong test. | |
| const strangerSees = await fetch(`${BASE}/api/docs/${docId}`, { | |
| headers: { authorization: 'Bearer dev:mallory', cookie: `ie_share=${link.token}` }, | |
| }).then(r => r.json()) | |
| assert.ok(strangerSees.markdown, 'a signed-in stranger reads the project: ' + JSON.stringify(strangerSees).slice(0, 120)) | |
| assert.deepEqual(strangerSees.shared_with, [], 'but never the collaborator list') | |
| assert.equal(strangerSees.public_link, null, 'and never the link itself') | |
| const agentsViaShare = await fetch(`${BASE}/api/agents?doc=${docId}`, { | |
| headers: { authorization: 'Bearer dev:mallory', cookie: `ie_share=${link.token}` }, | |
| }) | |
| assert.equal(agentsViaShare.status, 404, 'and cannot enumerate the agents on a project they were only shown') | |
| // the token belongs in a cookie, not in the address bar: /p/<token> | |
| // redirects, so it never reaches history or a Referer header | |
| const hop = await fetch(`${BASE}/p/${link.token}`, { redirect: 'manual' }) | |
| assert.equal(hop.status, 302, 'the link redirects rather than rendering') | |
| assert.equal(hop.headers.get('location'), `/d/${docId}`, 'to the plain document URL: ' + hop.headers.get('location')) | |
| assert.match(hop.headers.get('set-cookie') || '', /ie_share=/, 'handing the grant to a cookie on the way') | |
| assert.equal(hop.headers.get('referrer-policy'), 'no-referrer', 'and no URL from this app travels off-origin') | |
| // uploads are global on disk: a link must not be a key to another project's | |
| const png = Buffer.from('89504e470d0a1a0a0000000d494844520000000100000001080600000' + '01f15c4890000000a49444154789c6360000002000100' + '05fe02fea7b5e2d40000000049454e44ae426082', 'hex') | |
| const up = await fetch(`${BASE}/api/docs/${docId}/upload`, { | |
| method: 'POST', | |
| headers: { authorization: 'Bearer dev:alice', 'content-type': 'image/png' }, | |
| body: png, | |
| }).then(r => r.json()) | |
| assert.ok(up.url, 'uploaded: ' + JSON.stringify(up)) | |
| const fileName = up.url.split('/').pop() | |
| const mine = await fetch(`${BASE}/files/${fileName}`, { headers: { cookie: `ie_share=${link.token}` } }) | |
| assert.equal(mine.status, 200, 'a visitor sees the images of the project they were given') | |
| const otherProj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Other Project' } }) | |
| const otherLink = await api('dev:alice', `/api/docs/${otherProj.id}/public-link`, { method: 'POST' }) | |
| const theirs = await fetch(`${BASE}/files/${fileName}`, { headers: { cookie: `ie_share=${otherLink.token}` } }) | |
| assert.equal(theirs.status, 404, 'and nobody else\'s') | |
| await api('dev:alice', `/api/docs/${otherProj.id}`, { method: 'DELETE' }) | |
| // rotate invalidates the link already handed out | |
| const rotated2 = await api('dev:alice', `/api/docs/${docId}/public-link`, { method: 'POST', body: { rotate: true } }) | |
| assert.notEqual(rotated2.token, link.token, 'rotate mints a new token') | |
| assert.ok((await anon(`/api/docs/${docId}?share=${link.token}`)).error, 'the old link is dead') | |
| assert.ok((await anon(`/api/docs/${docId}?share=${rotated2.token}`)).markdown, 'the new link works') | |
| // revoke closes it entirely — including a viewer already attached, who would | |
| // otherwise keep receiving every edit made after the link was turned off | |
| const attached = shareConnect(docId, rotated2.token) | |
| await waitFor(() => attached.doc.getXmlFragment('default').length >= 2, 'viewer attached before revoking') | |
| const revoke = await api('dev:alice', `/api/docs/${docId}/public-link`, { method: 'DELETE' }) | |
| assert.ok(revoke.closed >= 1, 'revoking closed the live viewer connection: ' + JSON.stringify(revoke)) | |
| await new Promise(r => setTimeout(r, 800)) | |
| alice.doc.transact(() => { | |
| const t = new Y.XmlText() | |
| t.insert(0, 'AFTER REVOCATION') | |
| frag.get(1).insert(0, [t]) | |
| }) | |
| await new Promise(r => setTimeout(r, 1500)) | |
| assert.ok(!attached.doc.getXmlFragment('default').toString().includes('AFTER REVOCATION'), 'and it receives nothing after that') | |
| attached.provider.destroy() | |
| assert.ok((await anon(`/api/docs/${docId}?share=${rotated2.token}`)).error, 'revoked link reads nothing') | |
| let wsRefused = false | |
| const dead = shareConnect(docId, rotated2.token) | |
| dead.provider.on('authenticationFailed', () => { wsRefused = true }) | |
| await new Promise(r => setTimeout(r, 1500)) | |
| assert.ok(wsRefused || dead.doc.getXmlFragment('default').length === 0, 'a revoked link cannot open the socket either') | |
| dead.provider.destroy() | |
| console.log('✓ public links: read-only, scoped to one project, rotatable, revocable') | |
| } | |
| const threads = alice.doc.getMap('threads') | |
| const anchorStart = Buffer.from(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(frag, 1))).toString('base64url') | |
| const anchorEnd = Buffer.from(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(frag, 2))).toString('base64url') | |
| const threadId = 'th-' + Math.random().toString(36).slice(2, 8) | |
| alice.doc.transact(() => { | |
| const messages = new Y.Array() | |
| messages.push([{ id: 'msg1', author: 'alice', authorType: 'user', text: '@test-agent please improve this paragraph', ts: Date.now() }]) | |
| const t = new Y.Map() | |
| t.set('id', threadId) | |
| t.set('anchorStart', anchorStart) | |
| t.set('anchorEnd', anchorEnd) | |
| t.set('excerpt', 'This intro paragraph is quite bad') | |
| t.set('resolved', false) | |
| t.set('createdBy', 'alice') | |
| t.set('createdAt', Date.now()) | |
| t.set('messages', messages) | |
| threads.set(threadId, t) | |
| }) | |
| await waitFor(() => { | |
| const msgs = threads.get(threadId)?.get('messages')?.toArray() || [] | |
| return msgs[0]?.mentions?.length === 1 | |
| }, 'mention chip') | |
| console.log('✓ mention detected, chip set') | |
| // 5b. REGRESSION: a browser that connects while the server is opening and | |
| // closing its own direct connections to the same document must end up on the | |
| // instance the server keeps writing to. Disconnecting a direct connection used | |
| // to unload the document immediately, which could drop the instance a client | |
| // had just been attached to: the tab still reported "connected" and "synced", | |
| // but every later server write (mention chips, agent replies, suggestions) | |
| // landed in a fresh instance and never arrived. | |
| { | |
| const { id: raceDoc } = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Churn Race Doc' } }) | |
| await api('dev:alice', `/api/docs/${raceDoc}/share`, { method: 'POST', body: { username: 'bob' } }) | |
| // keep opening and closing direct connections to this document for the whole | |
| // duration of the client's handshake — the collision window is between the | |
| // server picking up the document for the new socket and registering the | |
| // connection on it | |
| let churning = true | |
| const churn = (async () => { | |
| while (churning) await api('dev:alice', `/api/docs/${raceDoc}`) | |
| })() | |
| const client = connect(raceDoc, 'dev:alice') | |
| const rFrag = client.doc.getXmlFragment('default') | |
| await waitFor(() => rFrag.length >= 1, 'race doc initial sync') | |
| churning = false | |
| await churn | |
| const rThreads = client.doc.getMap('threads') | |
| const rid = 'race-th' | |
| const relPos = i => Buffer.from(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(rFrag, i))).toString('base64url') | |
| client.doc.transact(() => { | |
| const messages = new Y.Array() | |
| messages.push([{ id: 'rmsg1', author: 'alice', authorType: 'user', text: '@test-agent take a look', ts: Date.now() }]) | |
| const t = new Y.Map() | |
| t.set('id', rid) | |
| t.set('anchorStart', relPos(0)) | |
| t.set('anchorEnd', relPos(1)) | |
| t.set('excerpt', null) | |
| t.set('resolved', false) | |
| t.set('createdBy', 'alice') | |
| t.set('createdAt', Date.now()) | |
| t.set('messages', messages) | |
| rThreads.set(rid, t) | |
| }) | |
| // server -> client direction #1: the chip the server writes for the mention | |
| await waitFor(() => (rThreads.get(rid)?.get('messages')?.toArray() || [])[0]?.mentions?.length === 1, 'chip reaches a client that connected during churn') | |
| // server -> client direction #2: an agent reply posted over HTTP | |
| await api(AGENT, `/api/docs/${raceDoc}/threads/${rid}/reply`, { method: 'POST', body: { text: 'on it', as_agent: 'test-agent' } }) | |
| await waitFor(() => (rThreads.get(rid)?.get('messages')?.toArray() || []).some(m => m.text === 'on it'), 'agent reply reaches the same client') | |
| client.provider.destroy() | |
| // the collision is a race, so try it a few more times — a suggestion is the | |
| // cheapest server write to watch for and leaves the mention queue alone | |
| for (let round = 0; round < 3; round++) { | |
| const { id: doc2 } = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: `Churn Race ${round}` } }) | |
| await api('dev:alice', `/api/docs/${doc2}/share`, { method: 'POST', body: { username: 'bob' } }) | |
| let spinning = true | |
| const spin = (async () => { | |
| while (spinning) await api('dev:alice', `/api/docs/${doc2}`) | |
| })() | |
| const c2 = connect(doc2, 'dev:alice') | |
| await waitFor(() => c2.doc.getXmlFragment('default').length >= 1, `race doc ${round} synced`) | |
| spinning = false | |
| await spin | |
| const res = await api(AGENT, `/api/docs/${doc2}/suggestions`, { | |
| method: 'POST', | |
| body: { block_index: 0, replacement_markdown: `round ${round} rewrite`, as_agent: 'test-agent' }, | |
| }) | |
| assert.ok(res.suggestion_id, `suggestion posted (round ${round}): ` + JSON.stringify(res)) | |
| await waitFor(() => c2.doc.getMap('suggestions').size === 1, `agent suggestion reaches the client that connected during churn (round ${round})`) | |
| c2.provider.destroy() | |
| } | |
| console.log('✓ server writes reach a client that connected during direct-connection churn') | |
| } | |
| // 5c. REGRESSION: pages are fields of the project document, so a page-scoped | |
| // document name is not a document. Syncing one used to load a SECOND instance | |
| // of the project — which took over the project's mention watcher, and from | |
| // then on @mentions written by every real browser were never detected. The | |
| // comment landed and looked posted; no chip, no queue entry, no agent. | |
| { | |
| const { id: pDoc } = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Page-scoped Sync' } }) | |
| // test-agent is bob's, and an agent only gets work for docs its owner can see | |
| await api('dev:alice', `/api/docs/${pDoc}/share`, { method: 'POST', body: { username: 'bob' } }) | |
| await api('dev:alice', `/api/docs/${pDoc}/pages`, { method: 'POST', body: { title: 'Second Page' } }) | |
| const rogue = connect(`${pDoc}::second-page`, 'dev:alice') | |
| const refused = await new Promise(resolve => { | |
| rogue.provider.on('authenticationFailed', ({ reason }) => resolve(reason || 'refused')) | |
| setTimeout(() => resolve(null), 5000) | |
| }) | |
| // hocuspocus sends the client a bare "permission-denied"; the reason itself | |
| // is only in the server log, so assert the refusal, not the wording | |
| assert.ok(refused, 'page-scoped sync refused (no authenticationFailed within 5s)') | |
| rogue.provider.destroy() | |
| // and the project's own mention detection is untouched by the attempt | |
| const client = connect(pDoc, 'dev:alice') | |
| await waitFor(() => client.doc.getXmlFragment('default').length >= 1, 'project doc synced') | |
| const pThreads = client.doc.getMap('threads') | |
| client.doc.transact(() => { | |
| const messages = new Y.Array() | |
| messages.push([{ id: 'pmsg1', author: 'alice', authorType: 'user', text: '@test-agent still listening?', ts: Date.now() }]) | |
| const t = new Y.Map() | |
| t.set('id', 'page-th') | |
| t.set('excerpt', null) | |
| t.set('resolved', false) | |
| t.set('createdBy', 'alice') | |
| t.set('createdAt', Date.now()) | |
| t.set('messages', messages) | |
| pThreads.set('page-th', t) | |
| }) | |
| await waitFor( | |
| () => (pThreads.get('page-th')?.get('messages')?.toArray() || [])[0]?.mentions?.length === 1, | |
| 'mention still detected after a page-scoped sync attempt' | |
| ) | |
| client.provider.destroy() | |
| // deleting the project drops its queued mentions, so later counts stay exact | |
| await api('dev:alice', `/api/docs/${pDoc}`, { method: 'DELETE' }) | |
| console.log('✓ page-scoped sync refused, and it cannot silence the project mention watcher') | |
| } | |
| // 5d. an agent opens its own comments — several small ones instead of one long | |
| // reply — anchored to a block or about the page as a whole | |
| { | |
| const { id: cDoc } = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Agent Comments' } }) | |
| await api('dev:alice', `/api/docs/${cDoc}/share`, { method: 'POST', body: { username: 'bob' } }) | |
| await api('dev:alice', `/api/docs/${cDoc}/pages`, { method: 'POST', body: { title: 'Review Me' } }) | |
| const client = connect(cDoc, 'dev:alice') | |
| const cThreads = client.doc.getMap('threads:review-me') | |
| await waitFor(() => client.doc.getXmlFragment('page:review-me').length >= 1, 'subpage synced') | |
| const origin = await api('dev:alice', `/api/docs/${cDoc}/threads`, { | |
| method: 'POST', | |
| body: { text: 'Please review this page.', block_index: 0, page: 'review-me' }, | |
| }) | |
| assert.ok(origin.thread_id, 'origin thread created: ' + JSON.stringify(origin)) | |
| const anchored = await api(AGENT, `/api/docs/${cDoc}/threads`, { | |
| method: 'POST', | |
| body: { text: 'This heading promises more than the page delivers.', block_index: 0, page: 'review-me', origin_thread_id: origin.thread_id, as_agent: 'test-agent' }, | |
| }) | |
| assert.equal(anchored.anchored, true, 'anchored thread: ' + JSON.stringify(anchored)) | |
| const loose = await api(AGENT, `/api/docs/${cDoc}/threads`, { | |
| method: 'POST', | |
| body: { text: 'Read the whole page; structure is the main problem.', page: 'review-me', origin_thread_id: origin.thread_id, as_agent: 'test-agent' }, | |
| }) | |
| assert.equal(loose.anchored, false, 'unanchored thread: ' + JSON.stringify(loose)) | |
| await waitFor(() => cThreads.size === 3, 'origin and both standalone comments reach a client') | |
| const at = cThreads.get(anchored.thread_id) | |
| const lt = cThreads.get(loose.thread_id) | |
| assert.ok(at.get('anchorStart'), 'anchored thread carries anchors') | |
| assert.ok(at.get('excerpt'), 'anchored thread quotes the text it is about: ' + at.get('excerpt')) | |
| assert.equal(lt.get('anchorStart'), undefined, 'page-level thread carries no anchors') | |
| assert.equal(lt.get('excerpt'), null, 'page-level thread quotes nothing') | |
| assert.equal(at.get('originThreadId'), origin.thread_id, 'anchored comment remembers its origin') | |
| assert.equal(lt.get('originThreadId'), origin.thread_id, 'page-level comment remembers its origin') | |
| for (const t of [at, lt]) { | |
| const m = t.get('messages').toArray()[0] | |
| assert.equal(m.authorType, 'agent', 'authored as the agent') | |
| assert.equal(m.author, 'test-agent') | |
| // an agent's own comment must not queue work for itself | |
| assert.equal(m.mentions, undefined, 'no self-mention chip') | |
| } | |
| // a human replies into a thread the agent opened | |
| const reply = await api('dev:alice', `/api/docs/${cDoc}/threads/${anchored.thread_id}/reply`, { | |
| method: 'POST', | |
| body: { text: 'Fair — rewriting it.', page: 'review-me' }, | |
| }) | |
| assert.equal(reply.ok, true, 'human replies to an agent-opened thread: ' + JSON.stringify(reply)) | |
| await waitFor(() => at.get('messages').toArray().length === 2, 'reply lands in the agent thread') | |
| // an out-of-range block index clamps to the last block rather than failing | |
| const clamped = await api(AGENT, `/api/docs/${cDoc}/threads`, { | |
| method: 'POST', | |
| body: { text: 'clamped', block_index: 999, page: 'review-me', as_agent: 'test-agent' }, | |
| }) | |
| assert.equal(clamped.anchored, true, 'out-of-range block index clamps: ' + JSON.stringify(clamped)) | |
| // text is required | |
| const empty = await api(AGENT, `/api/docs/${cDoc}/threads`, { method: 'POST', body: { block_index: 0, page: 'review-me', as_agent: 'test-agent' } }) | |
| assert.match(empty.error || '', /text required/, 'empty comment refused: ' + JSON.stringify(empty)) | |
| const badOrigin = await api(AGENT, `/api/docs/${cDoc}/threads`, { | |
| method: 'POST', | |
| body: { text: 'bad origin', page: 'review-me', origin_thread_id: 'missing', as_agent: 'test-agent' }, | |
| }) | |
| assert.match(badOrigin.error || '', /origin_thread_id/, 'unknown origin refused: ' + JSON.stringify(badOrigin)) | |
| client.provider.destroy() | |
| await api('dev:alice', `/api/docs/${cDoc}`, { method: 'DELETE' }) | |
| console.log('✓ agents open their own comments: anchored to a block, or about the page') | |
| } | |
| // 6. snapshot shows the mention without claiming; the stream claims it | |
| const snap0 = await api(AGENT, '/api/mentions') | |
| assert.equal(snap0.snapshot, true, 'snapshot flagged') | |
| assert.equal(snap0.mentions?.length, 1, 'snapshot shows pending: ' + JSON.stringify(snap0)) | |
| assert.equal(snap0.mentions[0].status, 'pending', 'snapshot does not claim') | |
| const poll = await streamPoll(AGENT, 10) | |
| assert.equal(poll.mentions?.length, 1, 'mention delivered: ' + JSON.stringify(poll)) | |
| const mention = poll.mentions[0] | |
| assert.equal(mention.handle, 'test-agent') | |
| await waitFor(() => threads.get(threadId).get('messages').toArray()[0].mentions[0].status === 'claimed', 'claimed chip') | |
| const agents = await api('dev:alice', `/api/agents?doc=${docId}`) | |
| assert.equal(agents.agents.find(a => a.handle === 'test-agent')?.online, true, 'agent online') | |
| console.log('✓ mention long-poll with agent key + claim chip + presence') | |
| // agents list scoping: outsiders' agents don't show in docs they can't access, | |
| // the home list shows only your own handles, and mentions of outsiders go nowhere | |
| await api('dev:carol', '/api/agents', { method: 'POST', body: { handle: 'outsider-agent' } }) | |
| const docAgents = await api('dev:alice', `/api/agents?doc=${docId}`) | |
| assert.ok(docAgents.agents.some(a => a.handle === 'test-agent'), 'shared user agent listed in doc') | |
| assert.ok(!docAgents.agents.some(a => a.handle === 'outsider-agent'), 'outsider agent NOT listed in doc') | |
| const own = await api('dev:carol', '/api/agents') | |
| assert.deepEqual(own.agents.map(a => a.handle), ['outsider-agent'], 'home list = own agents only') | |
| alice.doc.transact(() => { | |
| threads.get(threadId).get('messages').push([ | |
| { id: 'msg-outsider', author: 'alice', authorType: 'user', text: '@outsider-agent do something', ts: Date.now() }, | |
| ]) | |
| }) | |
| await new Promise(r => setTimeout(r, 1200)) | |
| const outsiderMsg = threads.get(threadId).get('messages').toArray().find(m => m.id === 'msg-outsider') | |
| assert.ok(!outsiderMsg.mentions?.length, 'mentioning an outsider agent creates no task') | |
| await api('dev:carol', '/api/agents/outsider-agent', { method: 'DELETE' }) | |
| console.log('✓ agent visibility scoped to doc access; outsider mentions inert') | |
| // 7. the agent reads the doc with its key | |
| const snapshot = await api(AGENT, `/api/docs/${mention.doc_id}`) | |
| assert.ok(snapshot.markdown.includes('Smoke Test Doc'), 'doc markdown') | |
| assert.ok(snapshot.blocks[1].markdown.includes('quite bad'), 'target block found') | |
| // 8. suggestion + reply — identity comes from the key, no as_agent needed | |
| const sugg = await api(AGENT, `/api/docs/${docId}/suggestions`, { | |
| method: 'POST', | |
| body: { | |
| anchor_start: snapshot.blocks[1].anchor_start, | |
| anchor_end: snapshot.blocks[1].anchor_end, | |
| replacement_markdown: 'This **improved** intro cites [Hugging Face](https://huggingface.co) properly.\n\n- one\n- two', | |
| rationale: 'Clearer wording, added a source.', | |
| mention_id: mention.mention_id, | |
| origin_thread_id: mention.origin_thread_id, | |
| }, | |
| }) | |
| assert.equal(sugg.ok, true, 'suggestion created: ' + JSON.stringify(sugg)) | |
| await waitFor(() => alice.doc.getMap('suggestions').get(sugg.suggestion_id), 'standalone suggestion reaches client') | |
| assert.equal(alice.doc.getMap('suggestions').get(sugg.suggestion_id).originThreadId, mention.thread_id, 'suggestion remembers origin') | |
| assert.equal(alice.doc.getMap('suggestions').get(sugg.suggestion_id).threadId, null, 'suggestion is not grouped inside origin') | |
| const reply = await api(AGENT, `/api/docs/${docId}/threads/${mention.thread_id}/reply`, { | |
| method: 'POST', | |
| body: { text: 'Proposed a rewrite with a source — see suggestions.', mention_id: mention.mention_id }, | |
| }) | |
| assert.equal(reply.ok, true, 'reply posted') | |
| const forged = await api('dev:alice', `/api/docs/${docId}/threads/${mention.thread_id}/reply`, { | |
| method: 'POST', | |
| body: { text: 'fake', as_agent: 'test-agent' }, | |
| }) | |
| assert.ok(forged.error, 'as_agent forgery blocked') | |
| console.log('✓ suggestion + reply via agent key, forgery blocked') | |
| await waitFor(() => threads.get(threadId).get('messages').toArray()[0].mentions[0].status === 'done', 'done chip') | |
| const msgs = threads.get(threadId).get('messages').toArray() | |
| assert.ok(msgs.some(m => m.authorType === 'agent' && m.author === 'test-agent'), 'agent message in thread') | |
| console.log('✓ mention marked done, agent reply attributed to the handle') | |
| // 9. implicit follow-up + dismiss (still via key) | |
| alice.doc.transact(() => { | |
| threads.get(threadId).get('messages').push([ | |
| { id: 'msg-followup', author: 'alice', authorType: 'user', text: 'hmm, can you double check the link?', ts: Date.now() }, | |
| ]) | |
| }) | |
| const followPoll = await streamPoll(AGENT, 10) | |
| assert.equal(followPoll.mentions?.length, 1, 'follow-up delivered') | |
| assert.equal(followPoll.mentions[0].implicit_follow_up, true, 'marked implicit') | |
| const dismissed = await api(AGENT, `/api/mentions/${followPoll.mentions[0].mention_id}/dismiss`, { method: 'POST' }) | |
| assert.equal(dismissed.ok, true, 'dismiss works') | |
| console.log('✓ implicit follow-up delivered + dismissable via key') | |
| // 9c. streaming long-poll: connect first, mention arrives mid-wait | |
| const streamPromise = (async () => { | |
| const resp = await fetch(`${BASE}/api/mentions/stream?wait=30`, { headers: { authorization: `Bearer ${AGENT}` } }) | |
| let text = '' | |
| for await (const chunk of resp.body) text += Buffer.from(chunk).toString() | |
| return text | |
| })() | |
| await new Promise(r => setTimeout(r, 1500)) | |
| alice.doc.transact(() => { | |
| threads.get(threadId).get('messages').push([ | |
| { id: 'msg-stream', author: 'alice', authorType: 'user', text: 'one more thing: fix the typo too', ts: Date.now() }, | |
| ]) | |
| }) | |
| const streamText = await streamPromise | |
| assert.ok(streamText.includes(':connected'), 'stream prologue present') | |
| const lastLine = streamText.trim().split('\n').filter(l => !l.startsWith(':')).pop() | |
| const streamResult = JSON.parse(lastLine) | |
| assert.equal(streamResult.mentions?.length, 1, 'stream delivered mid-wait: ' + lastLine) | |
| await api(AGENT, `/api/mentions/${streamResult.mentions[0].mention_id}/dismiss`, { method: 'POST' }) | |
| console.log('✓ streaming long-poll delivers mentions mid-wait') | |
| // 10. agents cannot accept; alice accepts server-side | |
| const suggMap = alice.doc.getMap('suggestions') | |
| await waitFor(() => suggMap.size === 1, 'suggestion synced') | |
| const suggestion = [...suggMap.values()][0] | |
| const agentAccept = await api(AGENT, `/api/docs/${docId}/suggestions/${suggestion.id}/accept`, { method: 'POST' }) | |
| assert.ok(agentAccept.error, 'agent key cannot accept suggestions') | |
| const accepted = await api('dev:alice', `/api/docs/${docId}/suggestions/${suggestion.id}/accept`, { method: 'POST' }) | |
| assert.equal(accepted.ok, true, 'accept: ' + JSON.stringify(accepted)) | |
| await waitFor(() => [...suggMap.values()][0].status === 'accepted', 'suggestion accepted state') | |
| // 11. content applied with exact marks, schema-valid | |
| await waitFor(() => { | |
| try { | |
| return yXmlFragmentToProseMirrorRootNode(frag, schema).textContent.includes('improved') | |
| } catch { | |
| return false | |
| } | |
| }, 'replacement applied') | |
| const pmRoot = yXmlFragmentToProseMirrorRootNode(frag, schema) | |
| pmRoot.check() | |
| const boldTexts = [] | |
| const linkTexts = [] | |
| pmRoot.descendants(node => { | |
| for (const mark of node.marks || []) { | |
| if (mark.type.name === 'link' && mark.attrs.href === 'https://huggingface.co') linkTexts.push(node.text) | |
| if (mark.type.name === 'bold') boldTexts.push(node.text) | |
| } | |
| }) | |
| assert.deepEqual(boldTexts, ['improved'], 'bold covers exactly the bold span') | |
| assert.deepEqual(linkTexts, ['Hugging Face'], 'link covers exactly the link span') | |
| console.log('✓ accept applied replacement; schema + exact marks ok') | |
| const again = await api('dev:alice', `/api/docs/${docId}/suggestions/${suggestion.id}/accept`, { method: 'POST' }) | |
| assert.ok(again.error, 'double accept rejected') | |
| // 11a. mark_only from a client that is NOT connected must still apply the | |
| // content. A tab whose websocket is dead (rejected as outdated, offline) can | |
| // still reach this endpoint over HTTP: trusting its "I applied it locally" | |
| // recorded accepted suggestions whose text never reached the document — six | |
| // of them were lost that way in a real doc before this check existed. | |
| const ghostSugg = await api(AGENT, `/api/docs/${docId}/suggestions`, { | |
| method: 'POST', | |
| body: { block_index: 0, replacement_markdown: 'Applied by the server on behalf of a dead tab.' }, | |
| }) | |
| assert.ok(ghostSugg.suggestion_id, 'ghost suggestion created: ' + JSON.stringify(ghostSugg)) | |
| // bob has access but no editor open, so his mark_only claim cannot be true | |
| const ghostAccept = await api('dev:bob', `/api/docs/${docId}/suggestions/${ghostSugg.suggestion_id}/accept`, { | |
| method: 'POST', | |
| body: { mark_only: true }, | |
| }) | |
| assert.equal(ghostAccept.ok, true, 'ghost accept: ' + JSON.stringify(ghostAccept)) | |
| await waitFor(() => { | |
| try { | |
| return yXmlFragmentToProseMirrorRootNode(frag, schema).textContent.includes('Applied by the server on behalf of a dead tab') | |
| } catch { | |
| return false | |
| } | |
| }, 'server applied the content the disconnected client could not send') | |
| await waitFor(() => suggMap.get(ghostSugg.suggestion_id)?.status === 'accepted', 'ghost suggestion marked accepted') | |
| // ...while a genuinely connected client's mark_only is still honoured (no | |
| // double application): alice IS connected here, and applies it herself. | |
| const liveSugg = await api(AGENT, `/api/docs/${docId}/suggestions`, { | |
| method: 'POST', | |
| body: { block_index: 0, replacement_markdown: 'Applied once by the live client.' }, | |
| }) | |
| assert.ok(liveSugg.suggestion_id, 'live suggestion created') | |
| const liveRange = [...suggMap.values()].find(x => x.id === liveSugg.suggestion_id) | |
| assert.ok(liveRange, 'live suggestion synced') | |
| // emulate the client-side apply through the same ydoc the editor uses | |
| alice.doc.transact(() => { | |
| const replacement = new Y.XmlElement('paragraph') | |
| replacement.insert(0, [new Y.XmlText('Applied once by the live client.')]) | |
| frag.delete(0, 1) | |
| frag.insert(0, [replacement]) | |
| }) | |
| const liveAccept = await api('dev:alice', `/api/docs/${docId}/suggestions/${liveSugg.suggestion_id}/accept`, { | |
| method: 'POST', | |
| body: { mark_only: true }, | |
| }) | |
| assert.equal(liveAccept.ok, true, 'live accept: ' + JSON.stringify(liveAccept)) | |
| await waitFor(() => suggMap.get(liveSugg.suggestion_id)?.status === 'accepted', 'live suggestion accepted') | |
| const liveText = yXmlFragmentToProseMirrorRootNode(frag, schema).textContent | |
| const occurrences = liveText.split('Applied once by the live client.').length - 1 | |
| assert.equal(occurrences, 1, `client-applied replacement must not be applied twice (found ${occurrences})`) | |
| console.log('✓ accepts are durable: server applies for a disconnected client, never double-applies for a live one') | |
| // 11b. revisions co-exist: supersedes only links, never hides | |
| const sugA = await api(AGENT, `/api/docs/${docId}/suggestions`, { | |
| method: 'POST', | |
| body: { block_index: 0, replacement_markdown: '# Better Title' }, | |
| }) | |
| const sugB = await api(AGENT, `/api/docs/${docId}/suggestions`, { | |
| method: 'POST', | |
| body: { supersedes: sugA.suggestion_id, replacement_markdown: '# Even Better Title' }, | |
| }) | |
| assert.ok(sugA.ok && sugB.ok, 'both suggestions created') | |
| await waitFor(() => suggMap.get(sugB.suggestion_id), 'revision synced') | |
| assert.equal(suggMap.get(sugA.suggestion_id).status, 'open', 'original stays open alongside its revision') | |
| assert.equal(suggMap.get(sugB.suggestion_id).revises, sugA.suggestion_id, 'revision links to the original') | |
| for (const sid of [sugA.suggestion_id, sugB.suggestion_id]) { | |
| const rej = await api('dev:alice', `/api/docs/${docId}/suggestions/${sid}/reject`, { method: 'POST' }) | |
| assert.equal(rej.ok, true, 'both independently actionable') | |
| } | |
| console.log('✓ revisions co-exist (supersedes links, human decides)') | |
| // A thread represents one requested edit. A fuller draft for the same target | |
| // updates its open proposal instead of creating another acceptable copy. | |
| const threadedA = await api(AGENT, `/api/docs/${docId}/suggestions`, { | |
| method: 'POST', | |
| body: { block_index: 0, replacement_markdown: '# Thread draft', thread_id: 'revision-thread' }, | |
| }) | |
| const threadedB = await api(AGENT, `/api/docs/${docId}/suggestions`, { | |
| method: 'POST', | |
| body: { block_index: 0, replacement_markdown: '# Thread final', thread_id: 'revision-thread' }, | |
| }) | |
| assert.equal(threadedB.suggestion_id, threadedA.suggestion_id, 'same thread and target update one suggestion') | |
| assert.equal(threadedB.updated, true, 'response identifies the in-place update') | |
| await waitFor(() => suggMap.get(threadedA.suggestion_id)?.replacementMarkdown === '# Thread final', 'updated draft synced') | |
| const threadedReject = await api('dev:alice', `/api/docs/${docId}/suggestions/${threadedA.suggestion_id}/reject`, { method: 'POST' }) | |
| assert.equal(threadedReject.ok, true, 'updated suggestion remains actionable') | |
| console.log('✓ repeated proposals in one thread update in place') | |
| // 11c. content typed AFTER a suggestion's last block must not be swallowed | |
| const snapTail = await api(AGENT, `/api/docs/${docId}`) | |
| const tailSugg = await api(AGENT, `/api/docs/${docId}/suggestions`, { | |
| method: 'POST', | |
| body: { block_index: snapTail.blocks.length - 1, replacement_markdown: 'Tail block rewritten.' }, | |
| }) | |
| assert.ok(tailSugg.ok, 'tail suggestion: ' + JSON.stringify(tailSugg)) | |
| alice.doc.transact(() => { | |
| const p = new Y.XmlElement('paragraph') | |
| frag.push([p]) | |
| const t = new Y.XmlText() | |
| t.insert(0, 'appended alpha survives') | |
| p.insert(0, [t]) | |
| }) | |
| await new Promise(r => setTimeout(r, 400)) | |
| const tailAcc = await api('dev:alice', `/api/docs/${docId}/suggestions/${tailSugg.suggestion_id}/accept`, { method: 'POST' }) | |
| assert.equal(tailAcc.ok, true, 'tail accept: ' + JSON.stringify(tailAcc)) | |
| await waitFor(async () => (await api('dev:alice', `/api/docs/${docId}`)).markdown.includes('Tail block rewritten'), 'tail applied') | |
| const mdAfter = (await api('dev:alice', `/api/docs/${docId}`)).markdown | |
| assert.ok(mdAfter.includes('appended alpha survives'), 'newly appended block NOT swallowed by the accept: ' + mdAfter.slice(-200)) | |
| console.log('✓ inclusive anchors: content appended after a suggestion survives its accept') | |
| // An anchor can remain live after another edit expands the old target into | |
| // several blocks. Refuse that stale proposal rather than duplicating the | |
| // newly inserted neighbours. | |
| const staleSugg = await api(AGENT, `/api/docs/${docId}/suggestions`, { | |
| method: 'POST', | |
| body: { block_index: 0, replacement_markdown: '# Stale rewrite', thread_id: 'stale-thread' }, | |
| }) | |
| alice.doc.transact(() => { | |
| const inserted = new Y.XmlElement('paragraph') | |
| inserted.insert(0, [new Y.XmlText('Inserted between target and its old neighbour.')]) | |
| frag.insert(1, [inserted]) | |
| }) | |
| const staleAccept = await api('dev:alice', `/api/docs/${docId}/suggestions/${staleSugg.suggestion_id}/accept`, { method: 'POST' }) | |
| assert.ok(staleAccept.error?.includes('changed'), 'stale structural target is rejected: ' + JSON.stringify(staleAccept)) | |
| assert.equal(suggMap.get(staleSugg.suggestion_id)?.status, 'open', 'stale suggestion stays open for revision') | |
| const staleReject = await api('dev:alice', `/api/docs/${docId}/suggestions/${staleSugg.suggestion_id}/reject`, { method: 'POST' }) | |
| assert.equal(staleReject.ok, true, 'stale suggestion can still be rejected') | |
| console.log('✓ structurally stale suggestions cannot be accepted') | |
| // 11c. a comment anchored to a POSITION rather than a span — what the hover | |
| // affordance in the right margin writes. Built here the way the client builds | |
| // it: ONE Yjs relative position, taken INSIDE the paragraph (not at the | |
| // fragment index of the block, which is what suggestion anchors use), written | |
| // into both anchor fields. Nothing is quoted, so `excerpt` stays null and | |
| // anchor_kind is what tells a reader which of the two shapes this is. | |
| const posText = new Y.XmlText() | |
| posText.insert(0, 'Polarised light guides bees home, which is the part nobody expects.') | |
| alice.doc.transact(() => { | |
| const posPara = new Y.XmlElement('paragraph') | |
| posPara.insert(0, [posText]) | |
| frag.push([posPara]) | |
| }) | |
| const posAnchor = Buffer.from(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(posText, 0))).toString('base64url') | |
| const posThreadId = 'th-pos-' + Math.random().toString(36).slice(2, 8) | |
| alice.doc.transact(() => { | |
| const messages = new Y.Array() | |
| messages.push([{ id: 'pos-msg1', author: 'alice', authorType: 'user', text: '@test-agent worth a footnote here?', ts: Date.now() }]) | |
| const t = new Y.Map() | |
| t.set('id', posThreadId) | |
| t.set('anchorStart', posAnchor) | |
| t.set('anchorEnd', posAnchor) | |
| t.set('excerpt', null) | |
| t.set('anchorKind', 'position') | |
| t.set('anchorContext', 'Polarised light guides bees home, which is the part nobody expects.') | |
| t.set('resolved', false) | |
| t.set('createdBy', 'alice') | |
| t.set('createdAt', Date.now()) | |
| t.set('messages', messages) | |
| threads.set(posThreadId, t) | |
| }) | |
| const posSnap = await api(AGENT, `/api/docs/${docId}`) | |
| const posThread = (posSnap.threads || []).find(t => t.thread_id === posThreadId) | |
| assert.ok(posThread, 'the position thread reaches the snapshot: ' + JSON.stringify(posSnap.threads)) | |
| assert.equal(posThread.anchor_kind, 'position', 'reported as position-anchored: ' + JSON.stringify(posThread)) | |
| assert.equal(posThread.excerpt, null, 'and quotes nothing: ' + JSON.stringify(posThread)) | |
| assert.ok(posThread.anchor_context.startsWith('Polarised light'), 'the place travels instead: ' + JSON.stringify(posThread)) | |
| // Threads written before any of this existed carry no anchorKind at all, and | |
| // must keep reading as the span-anchored comments they are. | |
| const spanThread = (posSnap.threads || []).find(t => t.thread_id === threadId) | |
| assert.equal(spanThread.anchor_kind, 'span', 'a thread with no anchorKind is a span comment: ' + JSON.stringify(spanThread)) | |
| assert.ok(spanThread.excerpt, 'and still carries its quote: ' + JSON.stringify(spanThread)) | |
| // the mention has to say which kind it is, or a null excerpt is | |
| // indistinguishable from a comment whose text was deleted | |
| const posPoll = await streamPoll(AGENT, 10) | |
| const posMention = (posPoll.mentions || []).find(m => m.thread_id === posThreadId) | |
| assert.ok(posMention, 'a position comment still delivers its mention: ' + JSON.stringify(posPoll)) | |
| assert.equal(posMention.anchor_kind, 'position', 'mention says which anchor it is: ' + JSON.stringify(posMention)) | |
| assert.ok(posMention.anchored_text.startsWith('Polarised light'), 'anchored_text falls back to the place: ' + JSON.stringify(posMention)) | |
| const posReply = await api(AGENT, `/api/docs/${docId}/threads/${posThreadId}/reply`, { | |
| method: 'POST', | |
| body: { text: 'Added a footnote there.', mention_id: posMention.mention_id }, | |
| }) | |
| assert.equal(posReply.ok, true, 'and replies like any other thread: ' + JSON.stringify(posReply)) | |
| console.log('✓ position-anchored comments: no excerpt, anchor_kind + anchor_context reach agents') | |
| // 11d. multi-page projects: pages, structure yaml, page-scoped suggestions | |
| const proj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Wiki Smoke' } }) | |
| await api('dev:alice', `/api/docs/${proj.id}/share`, { method: 'POST', body: { username: 'bob' } }) // the smoke agent's owner | |
| const pg1 = await api('dev:alice', `/api/docs/${proj.id}/pages`, { method: 'POST', body: { title: 'Design Notes' } }) | |
| assert.equal(pg1.slug, 'design-notes', 'slugified page: ' + JSON.stringify(pg1)) | |
| const st1 = await api('dev:alice', `/api/docs/${proj.id}/structure`) | |
| assert.deepEqual(st1.tree.map(n => n.slug), ['home', 'design-notes'], 'new page lands in the tree') | |
| // structure changes are ordinary suggestions on the _structure page | |
| const stDoc = await api('dev:alice', `/api/docs/${proj.id}?page=_structure`) | |
| const ci = stDoc.blocks.findIndex(b => b.markdown.includes('```')) | |
| const ssug = await api(AGENT, `/api/docs/${proj.id}/suggestions`, { | |
| method: 'POST', | |
| body: { page: '_structure', block_index: ci, replacement_markdown: '```yaml\n- home\n - design-notes\n```' }, | |
| }) | |
| assert.ok(ssug.ok, 'structure suggestion: ' + JSON.stringify(ssug)) | |
| const sacc = await api('dev:alice', `/api/docs/${proj.id}/suggestions/${ssug.suggestion_id}/accept`, { method: 'POST', body: { page: '_structure' } }) | |
| assert.equal(sacc.ok, true, 'structure accept: ' + JSON.stringify(sacc)) | |
| const st2 = await api('dev:alice', `/api/docs/${proj.id}/structure`) | |
| assert.equal(st2.tree[0]?.children?.[0]?.slug, 'design-notes', 'accepted structure suggestion re-nests the tree: ' + JSON.stringify(st2.tree)) | |
| // page-scoped agent read + suggestion + accept | |
| const pgRead = await api(AGENT, `/api/docs/${proj.id}?page=design-notes`) | |
| assert.ok(pgRead.markdown.includes('Design Notes'), 'agent reads the page') | |
| assert.ok(pgRead.pages.some(p => p.slug === 'design-notes'), 'pages listed') | |
| const psug = await api(AGENT, `/api/docs/${proj.id}/suggestions`, { | |
| method: 'POST', | |
| body: { page: 'design-notes', block_index: 0, replacement_markdown: '# Design Notes v2' }, | |
| }) | |
| await api('dev:alice', `/api/docs/${proj.id}/suggestions/${psug.suggestion_id}/accept`, { method: 'POST', body: { page: 'design-notes' } }) | |
| const pgRead2 = await api('dev:alice', `/api/docs/${proj.id}?page=design-notes`) | |
| assert.ok(pgRead2.markdown.includes('Design Notes v2'), 'page-scoped accept applied') | |
| // Both pages arrive over one websocket as named fields of one Y.Doc. | |
| const unified = connect(proj.id, 'dev:alice') | |
| await waitFor(() => unified.doc.getXmlFragment('default').length && unified.doc.getXmlFragment('page:design-notes').length, 'unified project sync') | |
| assert.ok(unified.doc.getXmlFragment('default') !== unified.doc.getXmlFragment('page:design-notes'), 'pages remain isolated named fragments') | |
| assert.ok(unified.doc.getMap('suggestions:design-notes').has(psug.suggestion_id), 'page-scoped metadata shares the project document') | |
| unified.provider.destroy() | |
| // page isolation: home untouched | |
| const homeRead = await api('dev:alice', `/api/docs/${proj.id}`) | |
| assert.ok(!homeRead.markdown.includes('v2'), 'home page untouched by page suggestion') | |
| // ACL applies to pages; deletion is creator-only and forbidden for home/_structure | |
| const carolPage = await api('dev:carol', `/api/docs/${proj.id}?page=design-notes`) | |
| assert.ok(carolPage.error, 'stranger blocked from page reads') | |
| const delHome = await api('dev:alice', `/api/docs/${proj.id}/pages/home`, { method: 'DELETE' }) | |
| assert.ok(delHome.error, 'home page cannot be deleted') | |
| const delPage = await api('dev:bob', `/api/docs/${proj.id}/pages/design-notes`, { method: 'DELETE' }) | |
| assert.equal(delPage.ok, true, 'any collaborator can delete a page: ' + JSON.stringify(delPage)) | |
| const carolDel = await api('dev:carol', `/api/docs/${proj.id}/pages/home`, { method: 'DELETE' }) | |
| assert.ok(carolDel.error, 'strangers still blocked entirely') | |
| const st3 = await api('dev:alice', `/api/docs/${proj.id}/structure`) | |
| assert.ok(!JSON.stringify(st3.tree).includes('design-notes'), 'deleted page leaves the tree') | |
| await api('dev:alice', `/api/docs/${proj.id}`, { method: 'DELETE' }) | |
| console.log('✓ multi-page projects: pages CRUD, structure-as-suggestion, isolation, ACL') | |
| // 11d2. sidebar structure ops: groups + drag-and-drop move endpoints | |
| const sproj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Structure Smoke' } }) | |
| await api('dev:alice', `/api/docs/${sproj.id}/pages`, { method: 'POST', body: { title: 'Intro' } }) | |
| await api('dev:alice', `/api/docs/${sproj.id}/pages`, { method: 'POST', body: { title: 'Setup' } }) | |
| const grp = await api('dev:alice', `/api/docs/${sproj.id}/structure/groups`, { method: 'POST', body: { label: 'Getting started', index: 1 } }) | |
| assert.equal(grp.ok, true, 'group created: ' + JSON.stringify(grp)) | |
| const mv1 = await api('dev:alice', `/api/docs/${sproj.id}/structure/move`, { method: 'POST', body: { node: 'intro', parent: ['"Getting started"'], index: 0 } }) | |
| assert.equal(mv1.ok, true, 'move into group: ' + JSON.stringify(mv1)) | |
| const mv2 = await api('dev:alice', `/api/docs/${sproj.id}/structure/move`, { method: 'POST', body: { node: 'setup', parent: ['"Getting started"'], index: 1 } }) | |
| assert.equal(mv2.ok, true, 'second move: ' + JSON.stringify(mv2)) | |
| const gst = await api('dev:alice', `/api/docs/${sproj.id}/structure`) | |
| const gnode = gst.tree.find(n => n.group === 'Getting started') | |
| assert.deepEqual(gnode?.children?.map(c => c.slug), ['intro', 'setup'], 'pages filed under the group: ' + JSON.stringify(gst.tree)) | |
| const ren = await api('dev:alice', `/api/docs/${sproj.id}/structure/groups/rename`, { method: 'POST', body: { path: ['"Getting started"'], label: 'Start here' } }) | |
| assert.equal(ren.ok, true, 'group renamed') | |
| const badMove = await api('dev:alice', `/api/docs/${sproj.id}/structure/move`, { method: 'POST', body: { node: 'no-such-page', parent: null, index: 0 } }) | |
| assert.ok(badMove.error, 'unknown page rejected') | |
| const agentMove = await api(AGENT, `/api/docs/${sproj.id}/structure/move`, { method: 'POST', body: { node: 'setup', parent: null, index: 0 } }) | |
| assert.ok(agentMove.error, 'agents cannot call structure ops: ' + JSON.stringify(agentMove)) | |
| const dis = await api('dev:alice', `/api/docs/${sproj.id}/structure/groups/dissolve`, { method: 'POST', body: { path: ['"Start here"'] } }) | |
| assert.equal(dis.ok, true, 'group dissolved') | |
| const gst2 = await api('dev:alice', `/api/docs/${sproj.id}/structure`) | |
| assert.deepEqual(gst2.tree.map(n => n.slug), ['home', 'intro', 'setup'], 'children promoted on dissolve: ' + JSON.stringify(gst2.tree)) | |
| // multi-select move: both pages under a fresh group in one call | |
| await api('dev:alice', `/api/docs/${sproj.id}/structure/groups`, { method: 'POST', body: { label: 'Docs' } }) | |
| const mvN = await api('dev:alice', `/api/docs/${sproj.id}/structure/move`, { method: 'POST', body: { nodes: ['intro', 'setup'], parent: ['"Docs"'], index: 0 } }) | |
| assert.equal(mvN.ok, true, 'multi move: ' + JSON.stringify(mvN)) | |
| const gst3 = await api('dev:alice', `/api/docs/${sproj.id}/structure`) | |
| assert.deepEqual(gst3.tree.find(n => n.group === 'Docs')?.children?.map(c => c.slug), ['intro', 'setup'], 'block landed: ' + JSON.stringify(gst3.tree)) | |
| // untitled page: empty title allowed, H1 empty, sidebar says Untitled | |
| const upg = await api('dev:alice', `/api/docs/${sproj.id}/pages`, { method: 'POST', body: { title: '' } }) | |
| assert.equal(upg.slug, 'untitled', 'untitled slug: ' + JSON.stringify(upg)) | |
| const uread = await api('dev:alice', `/api/docs/${sproj.id}?page=untitled`) | |
| assert.ok(uread.pages.find(p => p.slug === 'untitled')?.title === 'Untitled', 'untitled page listed as Untitled') | |
| // rename rewrites the H1 and the sidebar label in one move | |
| const ren2 = await api('dev:alice', `/api/docs/${sproj.id}/pages/untitled/rename`, { method: 'POST', body: { title: 'Field Notes' } }) | |
| assert.equal(ren2.ok, true, 'rename: ' + JSON.stringify(ren2)) | |
| const rread = await api('dev:alice', `/api/docs/${sproj.id}?page=untitled`) | |
| assert.ok(rread.markdown.startsWith('# Field Notes'), 'H1 rewritten: ' + JSON.stringify(rread.markdown.slice(0, 40))) | |
| assert.equal(rread.pages.find(p => p.slug === 'untitled')?.title, 'Field Notes', 'sidebar title follows') | |
| // renaming home renames the project | |
| await api('dev:alice', `/api/docs/${sproj.id}/pages/home/rename`, { method: 'POST', body: { title: 'Structure Smoke 2' } }) | |
| const hread = await api('dev:alice', `/api/docs/${sproj.id}`) | |
| assert.equal(hread.title, 'Structure Smoke 2', 'project title follows home rename: ' + hread.title) | |
| const renAgent = await api(AGENT, `/api/docs/${sproj.id}/pages/untitled/rename`, { method: 'POST', body: { title: 'nope' } }) | |
| assert.ok(renAgent.error, 'agents cannot rename') | |
| await api('dev:alice', `/api/docs/${sproj.id}`, { method: 'DELETE' }) | |
| console.log('✓ sidebar structure ops: group CRUD, single+multi move, untitled create, H1 rename, agent-blocked') | |
| // 11e. playground: seed, flag, reset restores, creator-only | |
| const pgw = await api('dev:alice', '/api/playground', { method: 'POST' }) | |
| const pgDoc = await api('dev:alice', `/api/docs/${pgw.id}`) | |
| assert.ok(pgDoc.playground, 'playground flag set') | |
| const seededCount = pgDoc.suggestions.length | |
| assert.ok(seededCount >= 10, 'playground richly seeded: ' + seededCount) | |
| assert.ok(pgDoc.suggestions.some(s => s.status === 'open'), 'open suggestions present') | |
| const anyOpen = pgDoc.suggestions.find(s => s.status === 'open') | |
| await api('dev:alice', `/api/docs/${pgw.id}/suggestions/${anyOpen.id}/accept`, { method: 'POST' }) | |
| const rst = await api('dev:alice', `/api/docs/${pgw.id}/reset`, { method: 'POST' }) | |
| assert.equal(rst.ok, true, 'reset ok') | |
| const pgDoc2 = await api('dev:alice', `/api/docs/${pgw.id}`) | |
| assert.equal(pgDoc2.suggestions.length, seededCount, 'reset restores the exact seed (no duplication)') | |
| assert.ok(pgDoc2.markdown.includes('happy testing'), 'reset restores content') | |
| const bobReset = await api('dev:bob', `/api/docs/${pgw.id}/reset`, { method: 'POST' }) | |
| assert.ok(bobReset.error, 'reset is creator-only') | |
| const again2 = await api('dev:alice', '/api/playground', { method: 'POST' }) | |
| assert.equal(again2.id, pgw.id, 'playground create is idempotent') | |
| await api('dev:alice', `/api/docs/${pgw.id}`, { method: 'DELETE' }) | |
| console.log('✓ playground: seed, reset restores state, creator-only') | |
| // 11e-bis. a BRAND NEW agent is unshared: the old code stored no flag and every | |
| // read treated "no flag" as shared, so registering an agent quietly exposed it | |
| // to every collaborator on every doc you share | |
| { | |
| const fresh = await api('dev:bob', '/api/agents', { method: 'POST', body: { handle: 'fresh-agent' } }) | |
| assert.ok(fresh.key, 'registered: ' + JSON.stringify(fresh)) | |
| const seenByOwner = await api('dev:bob', `/api/agents?doc=${docId}`) | |
| const seenByOther = await api('dev:alice', `/api/agents?doc=${docId}`) | |
| assert.ok(seenByOwner.agents.some(a => a.handle === 'fresh-agent' && a.shared === false), 'owner sees it, marked unshared') | |
| assert.ok(!seenByOther.agents.some(a => a.handle === 'fresh-agent'), 'a collaborator cannot see a newly registered agent') | |
| // and a collaborator's mention must not reach it | |
| const freshThread = 'thf-' + Math.random().toString(36).slice(2, 8) | |
| alice.doc.transact(() => { | |
| const messages = new Y.Array() | |
| messages.push([{ id: 'freshmsg1', author: 'alice', authorType: 'user', text: '@fresh-agent default-privacy check', ts: Date.now() }]) | |
| const t = new Y.Map() | |
| t.set('anchorStart', anchorStart) | |
| t.set('anchorEnd', anchorEnd) | |
| t.set('resolved', false) | |
| t.set('messages', messages) | |
| alice.doc.getMap('threads').set(freshThread, t) | |
| }) | |
| await new Promise(r => setTimeout(r, 1200)) | |
| const snapFresh = await api(fresh.key, '/api/mentions') | |
| assert.ok(!(snapFresh.mentions || []).some(m => m.instruction?.includes('default-privacy check')), 'no work queued from a collaborator: ' + JSON.stringify(snapFresh.mentions)) | |
| // sharing is the deliberate act, and it works | |
| const shareIt = await api('dev:bob', '/api/agents/fresh-agent/visibility', { method: 'POST', body: { shared: true } }) | |
| assert.equal(shareIt.shared, true, 'owner shared it') | |
| const nowSeen = await api('dev:alice', `/api/agents?doc=${docId}`) | |
| assert.ok(nowSeen.agents.some(a => a.handle === 'fresh-agent'), 'shared agent becomes visible') | |
| alice.doc.transact(() => { | |
| alice.doc.getMap('threads').get(freshThread).get('messages').push([{ id: 'freshmsg2', author: 'alice', authorType: 'user', text: '@fresh-agent now that it is shared', ts: Date.now() }]) | |
| }) | |
| await waitFor(async () => ((await api(fresh.key, '/api/mentions')).mentions || []).some(m => m.instruction?.includes('now that it is shared')), 'a shared agent does receive collaborator mentions') | |
| await api('dev:bob', '/api/agents/fresh-agent', { method: 'DELETE' }) | |
| console.log('✓ a new agent is unshared until its owner shares it') | |
| } | |
| // 11f. private agents: only the owner's mentions reach them | |
| const visPriv = await api('dev:bob', '/api/agents/test-agent/visibility', { method: 'POST', body: { shared: false } }) | |
| assert.equal(visPriv.shared, false, 'owner made agent private') | |
| const aliceAgents = await api('dev:alice', `/api/agents?doc=${docId}`) | |
| assert.ok(!aliceAgents.agents.some(a => a.handle === 'test-agent'), 'private agent hidden from collaborators') | |
| const bobAgents = await api('dev:bob', `/api/agents?doc=${docId}`) | |
| assert.ok(bobAgents.agents.some(a => a.handle === 'test-agent'), 'owner still sees it') | |
| // alice mentions it: no task; bob mentions it: task created | |
| const privThread = 'thp-' + Math.random().toString(36).slice(2, 8) | |
| alice.doc.transact(() => { | |
| const messages = new Y.Array() | |
| messages.push([{ id: 'privmsg1', author: 'alice', authorType: 'user', text: '@test-agent private check from alice', ts: Date.now() }]) | |
| const t = new Y.Map() | |
| t.set('anchorStart', anchorStart) | |
| t.set('anchorEnd', anchorEnd) | |
| t.set('resolved', false) | |
| t.set('messages', messages) | |
| alice.doc.getMap('threads').set(privThread, t) | |
| }) | |
| await new Promise(r => setTimeout(r, 1200)) | |
| const snapPriv = await api(AGENT, '/api/mentions') | |
| assert.ok(!snapPriv.mentions.some(m => m.instruction?.includes('private check from alice')), 'collaborator mention does NOT reach a private agent') | |
| alice.doc.transact(() => { | |
| alice.doc.getMap('threads').get(privThread).get('messages').push([{ id: 'privmsg2', author: 'bob', authorType: 'user', text: '@test-agent private check from bob', ts: Date.now() }]) | |
| }) | |
| await waitFor(async () => (await api(AGENT, '/api/mentions')).mentions.some(m => m.instruction?.includes('private check from bob')), 'owner mention reaches private agent') | |
| await api('dev:bob', '/api/agents/test-agent/visibility', { method: 'POST', body: { shared: true } }) | |
| console.log('✓ private agents: hidden from collaborators, owner-only mentions') | |
| // 11g. task lists + GFM tables round-trip through an agent suggestion + accept | |
| const ttProj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'TT Smoke' } }) | |
| await api('dev:alice', `/api/docs/${ttProj.id}/share`, { method: 'POST', body: { username: 'bob' } }) | |
| const ttSnap = await api(AGENT, `/api/docs/${ttProj.id}`) | |
| const ttSug = await api(AGENT, `/api/docs/${ttProj.id}/suggestions`, { | |
| method: 'POST', | |
| body: { | |
| block_index: ttSnap.blocks.length - 1, | |
| replacement_markdown: '- [ ] open task\n- [x] done task\n\n| Name | Role |\n| --- | --- |\n| Ada | Engineer |\n| Bob | Writer |', | |
| }, | |
| }) | |
| assert.ok(ttSug.ok, 'todo+table suggestion: ' + JSON.stringify(ttSug)) | |
| const ttAcc = await api('dev:alice', `/api/docs/${ttProj.id}/suggestions/${ttSug.suggestion_id}/accept`, { method: 'POST' }) | |
| assert.equal(ttAcc.ok, true, 'accept: ' + JSON.stringify(ttAcc)) | |
| await waitFor(async () => (await api('dev:alice', `/api/docs/${ttProj.id}`)).markdown.includes('| --- |'), 'table applied') | |
| const ttMd = (await api('dev:alice', `/api/docs/${ttProj.id}`)).markdown | |
| assert.ok(ttMd.includes('- [ ] open task') && ttMd.includes('- [x] done task'), 'task list round-trips: ' + ttMd) | |
| assert.ok(ttMd.includes('| Name | Role |') && ttMd.includes('| Ada | Engineer |'), 'GFM table round-trips: ' + ttMd) | |
| await api('dev:alice', `/api/docs/${ttProj.id}`, { method: 'DELETE' }) | |
| console.log('✓ task lists + GFM tables: agent suggestion, accept, markdown round-trip') | |
| // 11g-math. LaTeX survives the whole agent path: markdown -> Yjs -> markdown | |
| const mProj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Math Smoke' } }) | |
| await api('dev:alice', `/api/docs/${mProj.id}/share`, { method: 'POST', body: { username: 'bob' } }) | |
| const mSnap = await api(AGENT, `/api/docs/${mProj.id}`) | |
| assert.ok(mSnap.blocks, 'agent can read the math project: ' + JSON.stringify(mSnap)) | |
| const mathMd = [ | |
| 'The bound $\\|Ax - b\\|_2^2$ is tight when $a * b$ and $c * d$ agree.', | |
| '', | |
| '$$', | |
| '\\int_0^\\infty e^{-x} dx = 1', | |
| '$$', | |
| '', | |
| '| term | value |', | |
| '| --- | --- |', | |
| '| $e^{i\\pi}$ | $-1$ |', | |
| '', | |
| 'It costs $5 and $10 more.', | |
| ].join('\n') | |
| const mSug = await api(AGENT, `/api/docs/${mProj.id}/suggestions`, { | |
| method: 'POST', | |
| body: { block_index: mSnap.blocks.length - 1, replacement_markdown: mathMd }, | |
| }) | |
| assert.ok(mSug.ok, 'math suggestion accepted by the API: ' + JSON.stringify(mSug)) | |
| const mAcc = await api('dev:alice', `/api/docs/${mProj.id}/suggestions/${mSug.suggestion_id}/accept`, { method: 'POST' }) | |
| assert.equal(mAcc.ok, true, 'accept math: ' + JSON.stringify(mAcc)) | |
| await waitFor(async () => (await api('dev:alice', `/api/docs/${mProj.id}`)).markdown.includes('e^{i\\pi}'), 'math applied') | |
| const mOut = (await api('dev:alice', `/api/docs/${mProj.id}`)).markdown | |
| assert.ok(mOut.includes('$\\|Ax - b\\|_2^2$'), 'inline math round-trips: ' + mOut) | |
| // the two formulas that markdown emphasis used to swallow | |
| assert.ok(mOut.includes('$a * b$') && mOut.includes('$c * d$'), 'stars inside math survive: ' + mOut) | |
| assert.ok(/\$\$\n\\int_0\^\\infty e\^\{-x\} dx = 1\n\$\$/.test(mOut), 'display math round-trips fenced: ' + mOut) | |
| assert.ok(mOut.includes('| $e^{i\\pi}$ | $-1$ |'), 'math inside a table cell round-trips: ' + mOut) | |
| assert.ok(mOut.includes('costs $5 and $10 more'), 'currency is not treated as math: ' + mOut) | |
| await api('dev:alice', `/api/docs/${mProj.id}`, { method: 'DELETE' }) | |
| console.log('✓ LaTeX math: agent suggestion, accept, markdown round-trip (inline, display, table, currency)') | |
| // 11h. new-page suggestions: agent npProposes a whole page, human accepts/rejects | |
| const npjProj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'NP Wiki' } }) | |
| await api('dev:alice', `/api/docs/${npjProj.id}/share`, { method: 'POST', body: { username: 'bob' } }) | |
| const npProp = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, { | |
| method: 'POST', | |
| body: { title: 'Roadmap', content_markdown: '# Roadmap\n\n- [ ] q1 goal\n\n| Item | State |\n| --- | --- |\n| A | todo |', rationale: 'draft the roadmap' }, | |
| }) | |
| assert.ok(npProp.ok && npProp.slug === 'roadmap', 'npProposal created: ' + JSON.stringify(npProp)) | |
| const npSt = await api('dev:alice', `/api/docs/${npjProj.id}/structure`) | |
| assert.ok(npSt.pending?.some(p => p.slug === 'roadmap' && p.author === 'test-agent'), 'pending appears in structure: ' + JSON.stringify(npSt.pending)) | |
| // page must not exist yet | |
| assert.ok(!(await api('dev:alice', `/api/docs/${npjProj.id}`)).pages.some(p => p.slug === 'roadmap'), 'page not created before accept') | |
| // agent key cannot accept (requireHuman) | |
| const npAgentAccept = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions/${npProp.page_suggestion_id}/accept`, { method: 'POST' }) | |
| assert.ok(npAgentAccept.error, 'agent key cannot accept a page npProposal') | |
| // human accepts -> page created with content + added to structure | |
| const npAcc = await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${npProp.page_suggestion_id}/accept`, { method: 'POST' }) | |
| assert.equal(npAcc.ok, true, 'accept: ' + JSON.stringify(npAcc)) | |
| const npPageMd = (await api('dev:alice', `/api/docs/${npjProj.id}?page=roadmap`)).markdown | |
| assert.ok(npPageMd.includes('- [ ] q1 goal') && npPageMd.includes('| Item | State |'), 'accepted page has the npProposed content: ' + npPageMd) | |
| const npSt2 = await api('dev:alice', `/api/docs/${npjProj.id}/structure`) | |
| assert.ok(npSt2.tree.some(n => n.slug === 'roadmap'), 'accepted page joins the tree') | |
| assert.equal(npSt2.pending?.length || 0, 0, 'no longer pending') | |
| // reject flow | |
| const npProp2 = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, { method: 'POST', body: { title: 'Scratch', content_markdown: '# Scratch' } }) | |
| await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${npProp2.page_suggestion_id}/reject`, { method: 'POST' }) | |
| const npSt3 = await api('dev:alice', `/api/docs/${npjProj.id}/structure`) | |
| assert.equal(npSt3.pending?.length || 0, 0, 'rejected npProposal cleared') | |
| assert.ok(!(await api('dev:alice', `/api/docs/${npjProj.id}`)).pages.some(p => p.slug === 'scratch'), 'rejected page never created') | |
| // 11h-2. the page body must never be dropped in silence. Reading only the | |
| // exact field name meant an agent that sent "markdown" got a 200 and a | |
| // title-only page — no error, nothing to retry against. (Reported from the | |
| // field: "accepts a markdown field but ignores it, so both pages arrived | |
| // title-only".) | |
| for (const field of ['markdown', 'content', 'content_markdown']) { | |
| const prop = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, { | |
| method: 'POST', | |
| body: { title: `Via ${field}`, [field]: `# Via ${field}\n\nBody sent as ${field}.` }, | |
| }) | |
| assert.ok(prop.ok, `a body under "${field}" is accepted: ${JSON.stringify(prop)}`) | |
| const stored = await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${prop.page_suggestion_id}`) | |
| assert.ok( | |
| stored.content_markdown.includes(`Body sent as ${field}`), | |
| `a body under "${field}" survives instead of vanishing: ${JSON.stringify(stored.content_markdown)}` | |
| ) | |
| await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${prop.page_suggestion_id}/reject`, { method: 'POST' }) | |
| } | |
| // and with no body at all it is an error, not an empty page | |
| const noBody = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, { method: 'POST', body: { title: 'Bodyless', rationale: 'oops' } }) | |
| assert.ok(noBody.error?.includes('content_markdown'), 'a proposal with no body is refused: ' + JSON.stringify(noBody)) | |
| assert.ok(!noBody.page_suggestion_id, 'and no title-only page proposal is left behind') | |
| assert.equal( | |
| (await api('dev:alice', `/api/docs/${npjProj.id}/structure`)).pending?.length || 0, | |
| 0, | |
| 'nothing pending after the refusals' | |
| ) | |
| // the same tolerance on ordinary suggestions, where '' still means "delete this" | |
| const sugTol = await api(AGENT, `/api/docs/${npjProj.id}/suggestions`, { method: 'POST', body: { block_index: 0, markdown: 'replaced via alias' } }) | |
| assert.ok(sugTol.ok, 'a suggestion body under "markdown" is accepted too: ' + JSON.stringify(sugTol)) | |
| const sugNone = await api(AGENT, `/api/docs/${npjProj.id}/suggestions`, { method: 'POST', body: { block_index: 0 } }) | |
| assert.ok(sugNone.error?.includes('replacement_markdown'), 'a suggestion with no body is still refused: ' + JSON.stringify(sugNone)) | |
| console.log('✓ page/suggestion bodies: field-name aliases accepted, a missing body is an error not an empty page') | |
| // 11h-3. a proposal can say WHERE in the hierarchy the page belongs, in the | |
| // same {parent, index} vocabulary /structure/move uses — an agent proposing a | |
| // page had no way to place it, so every accepted page landed at the top level. | |
| await api('dev:alice', `/api/docs/${npjProj.id}/pages`, { method: 'POST', body: { title: 'Research' } }) | |
| await api('dev:alice', `/api/docs/${npjProj.id}/structure/groups`, { method: 'POST', body: { label: 'Getting started' } }) | |
| const inGroup = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, { | |
| method: 'POST', | |
| body: { title: 'Setup', content_markdown: '# Setup\n\nInstall it.', parent: ['"Getting started"'], index: 0 }, | |
| }) | |
| assert.ok(inGroup.ok, 'a proposal into a group is accepted: ' + JSON.stringify(inGroup)) | |
| const underPage = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, { | |
| method: 'POST', | |
| body: { title: 'Notes on X', content_markdown: '# Notes\n\n...', parent: 'research' }, | |
| }) | |
| assert.ok(underPage.ok, 'a proposal under a page is accepted: ' + JSON.stringify(underPage)) | |
| // a parent nobody can find is refused, not silently flattened to the root | |
| const badParent = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, { | |
| method: 'POST', | |
| body: { title: 'Nope', content_markdown: '# Nope', parent: 'no-such-page' }, | |
| }) | |
| assert.ok(badParent.error?.includes('unknown parent'), 'an unknown parent is refused: ' + JSON.stringify(badParent)) | |
| // the placement reaches the sidebar payload, so a pending page can be drawn in place | |
| const placedPending = (await api('dev:alice', `/api/docs/${npjProj.id}/structure`)).pending | |
| const setupPending = placedPending.find(p => p.title === 'Setup') | |
| assert.deepEqual(setupPending.parent, ['"Getting started"'], 'pending carries its parent: ' + JSON.stringify(setupPending)) | |
| assert.equal(setupPending.index, 0, 'and its index') | |
| assert.equal(placedPending.find(p => p.title === 'Notes on X').parent, 'research', 'a page parent survives as a slug') | |
| // and accepting files the page there, instead of appending at the top level | |
| await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${inGroup.page_suggestion_id}/accept`, { method: 'POST' }) | |
| await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${underPage.page_suggestion_id}/accept`, { method: 'POST' }) | |
| const placedTree = (await api('dev:alice', `/api/docs/${npjProj.id}/structure`)).tree | |
| const groupNode = placedTree.find(n => n.group === 'Getting started') | |
| assert.deepEqual( | |
| groupNode?.children?.map(c => c.slug), | |
| ['setup'], | |
| 'accepted page sits inside the group it was proposed for: ' + JSON.stringify(placedTree) | |
| ) | |
| assert.deepEqual( | |
| placedTree.find(n => n.slug === 'research')?.children?.map(c => c.slug), | |
| ['notes-on-x'], | |
| 'and a page-parented proposal nests under that page: ' + JSON.stringify(placedTree) | |
| ) | |
| // An agent works from what it can see, and what it sees is a TITLE. Sending | |
| // "Research Notes" for the page `research-notes`, or a group's label without | |
| // its quotes, used to be a 400 — which is how a requested subpage ended up at | |
| // the top level instead. | |
| await api('dev:alice', `/api/docs/${npjProj.id}/pages`, { method: 'POST', body: { title: 'Research Notes' } }) | |
| const guesses = [ | |
| ['the exact slug', 'research-notes', 'research-notes'], | |
| ['the page title', 'Research Notes', 'research-notes'], | |
| ['the title in another case', 'research notes', 'research-notes'], | |
| ['a group label without quotes', 'Getting started', ['"Getting started"']], | |
| ['a one-element array holding a title', ['Research Notes'], 'research-notes'], | |
| ] | |
| for (const [what, parent, expected] of guesses) { | |
| const r = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, { | |
| method: 'POST', | |
| body: { title: `Placed by ${what}`, content_markdown: '# X', parent }, | |
| }) | |
| assert.ok(r.ok, `${what} is accepted as a parent: ${JSON.stringify(r)}`) | |
| assert.deepEqual(r.parent, expected, `${what} resolves to the canonical ref, and the response says so: ${JSON.stringify(r)}`) | |
| await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${r.page_suggestion_id}/reject`, { method: 'POST' }) | |
| } | |
| // the aliases an agent is likely to reach for | |
| for (const field of ['parent_slug', 'parent_page', 'under']) { | |
| const r = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, { | |
| method: 'POST', | |
| body: { title: `Via ${field}`, content_markdown: '# X', [field]: 'Research Notes' }, | |
| }) | |
| assert.equal(r.parent, 'research-notes', `"${field}" works as a parent field: ${JSON.stringify(r)}`) | |
| await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${r.page_suggestion_id}/reject`, { method: 'POST' }) | |
| } | |
| // a name nothing matches is still refused, with the pages listed to choose from | |
| const noSuch = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, { | |
| method: 'POST', | |
| body: { title: 'Nowhere', content_markdown: '# X', parent: 'Marketing Plan' }, | |
| }) | |
| assert.ok(noSuch.error?.includes('unknown parent'), 'an unknown parent is refused: ' + JSON.stringify(noSuch)) | |
| assert.ok(noSuch.pages?.includes('research-notes'), 'and the reply lists real pages to choose from: ' + JSON.stringify(noSuch)) | |
| // reading a proposal back shows where it is headed | |
| const titleParented = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, { | |
| method: 'POST', | |
| body: { title: 'Child By Title', content_markdown: '# Child', parent: 'Research Notes' }, | |
| }) | |
| const readBack = await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${titleParented.page_suggestion_id}`) | |
| assert.equal(readBack.parent, 'research-notes', 'GET shows the resolved parent: ' + JSON.stringify(readBack)) | |
| await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${titleParented.page_suggestion_id}/accept`, { method: 'POST' }) | |
| const guessTree = (await api('dev:alice', `/api/docs/${npjProj.id}/structure`)).tree | |
| assert.deepEqual( | |
| guessTree.find(n => n.slug === 'research-notes')?.children?.map(c => c.slug), | |
| ['child-by-title'], | |
| 'and a title-parented proposal lands under that page: ' + JSON.stringify(guessTree) | |
| ) | |
| console.log('✓ a parent can be named by slug, title or group label — an agent\'s guess is not silently flattened') | |
| console.log('✓ page proposals carry a place in the hierarchy, and accepting files them there') | |
| // 11i. markdown export: a zip that renders outside cowrite. The images matter | |
| // most — a /files/... link only resolves for someone signed in here, so the | |
| // bytes have to travel with the markdown and the links have to be rewritten. | |
| const expProj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Export Me' } }) | |
| const png = Buffer.from( | |
| '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63f8ffff3f0005fe02fea735c9080000000049454e44ae426082', | |
| 'hex' | |
| ) | |
| const upRes = await fetch(`${BASE}/api/docs/${expProj.id}/upload`, { | |
| method: 'POST', | |
| headers: { authorization: 'Bearer dev:alice', 'content-type': 'image/png' }, | |
| body: png, | |
| }).then(r => r.json()) | |
| assert.ok(upRes.url?.startsWith('/files/'), 'image uploaded: ' + JSON.stringify(upRes)) | |
| await api('dev:alice', `/api/docs/${expProj.id}/pages`, { | |
| method: 'POST', | |
| body: { title: 'Figures', content_markdown: `# Figures\n\nSee it:\n\n` }, | |
| }) | |
| await api('dev:alice', `/api/docs/${expProj.id}/pages`, { method: 'POST', body: { title: 'Prose', content_markdown: '# Prose\n\nNo pictures.' } }) | |
| const getZip = async query => { | |
| const r = await fetch(`${BASE}/api/docs/${expProj.id}/export${query}`, { headers: { authorization: 'Bearer dev:alice' } }) | |
| return { status: r.status, headers: r.headers, files: readZip(Buffer.from(await r.arrayBuffer())) } | |
| } | |
| const whole = await getZip('?scope=project') | |
| assert.equal(whole.status, 200) | |
| assert.equal(whole.headers.get('content-type'), 'application/zip') | |
| assert.match(whole.headers.get('content-disposition'), /attachment; filename="export-me-project\.zip"/) | |
| const names = Object.keys(whole.files).sort() | |
| assert.deepEqual( | |
| names.filter(n => n.endsWith('.md')).sort(), | |
| ['figures.md', 'home.md', 'prose.md'], | |
| 'one .md per page: ' + JSON.stringify(names) | |
| ) | |
| assert.ok(names.includes('structure.yaml'), 'the project shape travels too: ' + JSON.stringify(names)) | |
| const assetName = names.find(n => n.startsWith('assets/') && n.endsWith('.png')) | |
| assert.ok(assetName, 'the referenced image is in the zip: ' + JSON.stringify(names)) | |
| assert.ok(whole.files[assetName].equals(png), 'and it is the real bytes, not a placeholder') | |
| const figuresMd = whole.files['figures.md'].toString() | |
| assert.ok(figuresMd.includes(`](${assetName})`), 'the image link is rewritten to the bundled copy: ' + figuresMd) | |
| assert.ok(!figuresMd.includes('/files/'), 'no authenticated /files link is left behind: ' + figuresMd) | |
| const onePage = await getZip('?scope=page&page=prose') | |
| assert.deepEqual(Object.keys(onePage.files), ['prose.md'], 'page scope carries just that page (and no assets it does not use)') | |
| assert.match(onePage.headers.get('content-disposition'), /filename="prose\.zip"/) | |
| const pageWithImage = await getZip('?scope=page&page=figures') | |
| assert.ok(Object.keys(pageWithImage.files).some(n => n.endsWith('.png')), 'a single page still brings its own images') | |
| // a bad page slug is a 404, not an empty archive; and no access means no export | |
| assert.equal((await fetch(`${BASE}/api/docs/${expProj.id}/export?scope=page&page=nope`, { headers: { authorization: 'Bearer dev:alice' } })).status, 404) | |
| assert.equal((await fetch(`${BASE}/api/docs/${expProj.id}/export`, { headers: { authorization: 'Bearer dev:carol' } })).status, 404) | |
| await api('dev:alice', `/api/docs/${expProj.id}`, { method: 'DELETE' }) | |
| console.log('✓ markdown export: one .md per page, images bundled and relinked, scoped to page or project') | |
| await api('dev:alice', `/api/docs/${npjProj.id}`, { method: 'DELETE' }) | |
| console.log('✓ new-page suggestions: propose, pending, human accept creates page, reject discards') | |
| // 12. persistence across restart (registry keeps ACL) | |
| alice.provider.destroy() | |
| await stopServer() | |
| // Upgrade fixture: old releases persisted each subpage in its own Yjs file. | |
| // The first project open must copy those fields into the base doc and leave | |
| // the source files untouched so rolling back remains possible. | |
| const legacyId = 'legacy-unified-fixture' | |
| const registryPath = path.join(DATA, 'registry.json') | |
| const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) | |
| registry[legacyId] = { | |
| title: 'Legacy Project', createdBy: 'alice', createdAt: Date.now(), updatedAt: Date.now(), | |
| pages: { notes: { title: 'Legacy Notes' }, _structure: { title: 'Structure' } }, | |
| } | |
| fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2)) | |
| const docsDir = path.join(DATA, 'docs') | |
| writeLegacyProjectDoc(path.join(docsDir, `${legacyId}.yjs`), [legacyTextBlock('heading', 'Legacy Project', { level: 1 })]) | |
| writeLegacyProjectDoc( | |
| path.join(docsDir, `${legacyId}__notes.yjs`), | |
| [legacyTextBlock('heading', 'Legacy Notes', { level: 1 }), legacyTextBlock('paragraph', 'Imported page body.')], | |
| [{ id: 'legacy-suggestion', author: 'old-agent', status: 'open', rationale: 'preserve me' }] | |
| ) | |
| writeLegacyProjectDoc( | |
| path.join(docsDir, `${legacyId}___structure.yjs`), | |
| [legacyTextBlock('codeBlock', '- home\n - notes', { language: 'yaml' })] | |
| ) | |
| await startServer() | |
| const after = await api('dev:bob', `/api/docs/${docId}`) | |
| assert.ok(after.markdown.includes('improved'), 'content + share survived restart') | |
| assert.deepEqual(after.shared_with, ['bob'], 'ACL persisted') | |
| const afterKey = await api(AGENT, `/api/docs/${docId}`) | |
| assert.ok(afterKey.markdown, 'agent key survives restart') | |
| console.log('✓ persistence across restart (content, ACL, agent key)') | |
| const imported = await api('dev:alice', `/api/docs/${legacyId}?page=notes`) | |
| assert.ok(imported.markdown.includes('Imported page body.'), 'legacy subpage content imported') | |
| assert.ok(imported.suggestions.some(s => s.id === 'legacy-suggestion'), 'legacy page metadata imported') | |
| const importedStructure = await api('dev:alice', `/api/docs/${legacyId}/structure`) | |
| assert.equal(importedStructure.tree[0]?.children?.[0]?.slug, 'notes', 'legacy structure hierarchy imported') | |
| assert.ok(fs.existsSync(path.join(docsDir, `${legacyId}__notes.yjs`)), 'legacy source page retained for rollback') | |
| await api('dev:alice', `/api/docs/${legacyId}`, { method: 'DELETE' }) | |
| console.log('✓ legacy page files migrate into one project document and remain rollback-safe') | |
| // 12b. agent removal: strangers can't, owner and admin can; keys die with it | |
| const strangerDel = await api('dev:alice', '/api/agents/test-agent', { method: 'DELETE' }) | |
| assert.ok(strangerDel.error, 'non-owner non-admin cannot remove an agent') | |
| const ownerDel = await api('dev:bob', '/api/agents/test-agent', { method: 'DELETE' }) | |
| assert.equal(ownerDel.ok, true, 'owner removes own agent') | |
| const deadKey = await fetch(`${BASE}/api/mentions`, { headers: { authorization: `Bearer ${AGENT}` } }) | |
| assert.equal(deadKey.status, 401, 'removed agent key is dead') | |
| const reg2 = await api('dev:bob', '/api/agents', { method: 'POST', body: { handle: 'admin-target' } }) | |
| assert.equal(reg2.ok, true) | |
| const adminDel = await api('dev:root-admin', '/api/agents/admin-target', { method: 'DELETE' }) | |
| assert.equal(adminDel.ok, true, 'space admin can remove any agent: ' + JSON.stringify(adminDel)) | |
| console.log('✓ agent removal: owner + admin allowed, strangers blocked, key revoked') | |
| // 13. deletion + auth | |
| const deniedDel = await api('dev:bob', `/api/docs/${docId}`, { method: 'DELETE' }) | |
| assert.ok(deniedDel.error, 'non-creator cannot delete') | |
| const deleted = await api('dev:alice', `/api/docs/${docId}`, { method: 'DELETE' }) | |
| assert.equal(deleted.ok, true, 'creator deletes doc') | |
| const anon = await fetch(`${BASE}/api/docs`).then(r => r.status) | |
| assert.equal(anon, 401, 'unauthenticated rejected') | |
| console.log('✓ deletion (creator-only) + auth required') | |
| await stopServer() | |
| fs.rmSync(DATA, { recursive: true, force: true }) | |
| console.log('\nALL SMOKE TESTS PASSED') | |
| } | |
| main().catch(err => { | |
| console.error('\nSMOKE TEST FAILED:', err) | |
| try { server?.kill('SIGKILL') } catch {} | |
| process.exit(1) | |
| }) | |