// Real-browser UI test (chromium via playwright-core). Drives the actual editor: // login, typing, selection -> comment/suggest, margin cards, images, discussion. // node test/browser.js import { spawn } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import assert from 'node:assert' import { fileURLToPath } from 'node:url' import { chromium } from 'playwright-core' const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') const PORT = 3299 const BASE = `http://localhost:${PORT}` const DATA = path.join(root, '.browser-data') const SHOT = process.env.SHOT_DIR || '/tmp/claude-1000/ui-shots' fs.rmSync(DATA, { recursive: true, force: true }) fs.mkdirSync(SHOT, { recursive: true }) const server = spawn('node', ['server/index.js'], { cwd: root, env: { ...process.env, PORT: String(PORT), DATA_DIR: DATA, OAUTH_CLIENT_ID: '', OAUTH_CLIENT_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'], }) server.stderr.on('data', d => process.stderr.write('[server] ' + d)) // Put the caret at the end of the document without depending on where things // happen to sit: Playwright clicks an element's centre, and if an atom node (a // formula, an image) is there, the click opens THAT instead of placing a caret. async function focusDocEnd(page) { // Put the caret at the end of the document, reliably. Two traps here: // - typing before the document has synced loses those keystrokes, because // the remote state then replaces them (the skeleton lifting is the signal); // - editor.commands.focus() sets the selection but does NOT take DOM focus // in headless Chromium, so the keystrokes go to the body instead. // Hence a real click, at a point that is always text: the title line. Clicking // the element's centre would be a gamble — an atom (a formula, an image) // sitting there swallows the click and every keystroke after it. await page.waitForFunction(() => document.getElementById('doc-skeleton')?.classList.contains('hidden'), null, { timeout: 20000 }) await page.waitForFunction(() => window.__editor && !window.__editor.isDestroyed) await page.click('.tiptap', { position: { x: 24, y: 10 } }) await page.waitForFunction(() => window.__editor?.isFocused, null, { timeout: 5000 }) await page.keyboard.press('Control+End') } const consoleErrors = [] async function main() { await waitFor(async () => (await fetch(`${BASE}/healthz`)).ok, 'server') const browser = await chromium.launch({ headless: true, executablePath: process.env.PW_EXECUTABLE || undefined, args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--single-process'], }) const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) page.on('console', msg => { if (msg.type() === 'error' && !msg.text().includes('fonts.googleapis') && !msg.text().includes('fonts.gstatic')) { consoleErrors.push(msg.text()) } }) page.on('pageerror', err => consoleErrors.push('PAGEERROR: ' + err.message)) // --- login + create doc, from the switcher rather than an index page --- await page.goto(`${BASE}/auth/dev?u=alice&next=/`) // With no documents yet, / has nowhere to send us and serves the bare shell. // The switcher's loading state has to be in the markup the server sends, not // built by JS — that is what makes it visible on the first frame. const shellHtml = await (await page.request.get(`${BASE}/`)).text() assert.ok((shellHtml.match(/sk-card/g) || []).length >= 3, 'shell ships skeleton cards for the switcher') assert.ok(shellHtml.includes('id="docs-list" class="loading"'), 'switcher list starts in its loading state') assert.ok(shellHtml.includes('window.__BOOT'), 'shell inlines the session') await page.waitForSelector('#docs-pop:not(.hidden)', { timeout: 10000 }) page.once('dialog', d => d.accept('Browser Test Doc')) await page.click('#new-doc') await page.waitForURL('**/d/**') await page.waitForSelector('.tiptap', { timeout: 10000 }) console.log('✓ login + doc creation from the switcher + editor mounted') // --- the shell paints a loading state, and carries the session inline --- const bootInline = await page.evaluate(() => !!window.__BOOT?.user) assert.ok(bootInline, 'the session is inlined in the shell (no /api/me round trip)') const meCalls = await page.evaluate(() => performance.getEntriesByType('resource').filter(r => r.name.endsWith('/api/me')).length ) assert.equal(meCalls, 0, 'no /api/me request was needed') const assets = await page.evaluate(() => performance.getEntriesByType('resource') .filter(r => /\.(js|css)\?v=/.test(r.name)) .map(r => ({ name: r.name.split('/').pop(), enc: r.encodedBodySize, dec: r.decodedBodySize })) ) assert.ok(assets.length >= 2, 'versioned assets were fetched: ' + JSON.stringify(assets)) for (const a of assets) { assert.ok(a.enc > 0 && a.enc < a.dec * 0.6, `${a.name} arrived compressed (${a.enc} of ${a.dec} bytes)`) } console.log('✓ shell: session inlined, bundles served compressed') // --- theme: light by default, toggle sticks across a reload --- const themeState = () => page.evaluate(() => { const sum = c => (c.match(/\d+/g) || []).slice(0, 3).reduce((t, n) => t + +n, 0) return { theme: document.documentElement.dataset.theme, stored: localStorage.getItem('cw-theme'), bg: sum(getComputedStyle(document.body).backgroundColor), ink: sum(getComputedStyle(document.querySelector('.tiptap')).color), page: sum(getComputedStyle(document.getElementById('editor')).backgroundColor), } }) const lightState = await themeState() assert.equal(lightState.theme, 'light', 'starts light when the OS is light: ' + JSON.stringify(lightState)) assert.ok(lightState.bg > 700 && lightState.ink < 60, 'light: pale canvas, black ink: ' + JSON.stringify(lightState)) await page.click('#theme-btn') await waitFor(async () => (await themeState()).theme === 'dark', 'toggle switches to dark') const darkState = await themeState() assert.ok(darkState.bg < 160 && darkState.ink > 600, 'dark: dark canvas, light ink: ' + JSON.stringify(darkState)) assert.ok(darkState.page < 200 && darkState.page > darkState.bg, 'dark: the sheet sits above the canvas: ' + JSON.stringify(darkState)) assert.equal(darkState.stored, 'dark', 'choice is stored') await page.reload() await page.waitForSelector('.tiptap') const afterReload = await themeState() assert.equal(afterReload.theme, 'dark', 'dark survives a reload (no light flash path)') await page.click('#theme-btn') await waitFor(async () => (await themeState()).theme === 'light', 'toggle switches back to light') console.log('✓ theme: OS default, toggle to dark, persists, toggles back') // register an agent handle (agents live in a header popover now) await page.click('#agents-btn') await page.fill('#agent-handle', 'ui-agent') await page.click('#agent-form button') await page.waitForSelector('.agent-row') // registration issues a one-time agent key await page.waitForSelector('.key-box code', { timeout: 5000 }) const shownKey = await page.textContent('.key-box code') assert.ok(shownKey.startsWith('ak_'), 'agent key shown once: ' + shownKey.slice(0, 6)) // a second handle, so the @ menu has a list to arrow through await page.fill('#agent-handle', 'ui-agent-two') await page.click('#agent-form button') await waitFor(async () => (await page.textContent('#agents-list')).includes('ui-agent-two'), 'second agent registered') await waitFor(async () => (await page.$$('.key-box code')).length > 1, 'second agent key issued') const shownKeyTwo = (await page.$$eval('.key-box code', els => els.map(e => e.textContent)))[1] assert.ok(shownKeyTwo.startsWith('ak_') && shownKeyTwo !== shownKey, 'second agent has its own key') await page.click('#agents-btn') // close console.log('✓ agents registered via header panel, key issued') // --- sharing UI --- await page.click('#share-btn') await page.waitForSelector('#share-pop:not(.hidden)') await waitFor(async () => (await page.textContent('#share-list')).includes('owner'), 'share list shows owner') await page.fill('#share-user', 'bob') await page.click('#share-form button') await waitFor(async () => (await page.textContent('#share-list')).includes('bob'), 'bob added to share list') await page.click('#share-btn') // close console.log('✓ share panel: add collaborator') // --- LaTeX math: typed, typeset by KaTeX, source editable --- await focusDocEnd(page) await page.keyboard.press('Enter') await page.keyboard.type('Einstein said $E = mc^2$ and meant it.') await page.waitForSelector('.tiptap .math-inline', { timeout: 5000 }) // KaTeX is lazy: it must NOT be in the main bundle, and must arrive on demand await waitFor(async () => await page.evaluate(() => !!window.katex), 'katex loads on demand') const katexFetches = await page.evaluate(() => performance.getEntriesByType('resource').filter(r => /katex\.(js|css)/.test(r.name)).map(r => r.name.split('/').pop()) ) assert.equal(katexFetches.length, 2, 'katex js + css fetched separately from the main bundle: ' + katexFetches) await page.waitForSelector('.tiptap .math-inline .katex', { timeout: 5000 }) const rendered = await page.textContent('.tiptap .math-inline .katex') assert.ok(rendered.includes('E') && rendered.includes('mc'), 'formula is typeset, not raw source: ' + rendered) assert.ok(!(await page.textContent('.tiptap')).includes('$E = mc^2$'), 'the $ delimiters are gone from the text') // double-clicking a formula opens its LaTeX source; Enter commits the edit await page.dblclick('.tiptap .math-inline .math-view') await page.waitForSelector('.tiptap .math-inline.editing .math-src', { timeout: 5000 }) assert.equal(await page.inputValue('.math-inline.editing .math-src'), 'E = mc^2', 'the source is what you edit') await page.fill('.math-inline.editing .math-src', 'E = mc^3') await page.keyboard.press('Enter') await waitFor(async () => (await page.textContent('.tiptap .math-inline .katex')).includes('mc'), 'edited formula re-renders') assert.ok(!(await page.$('.math-inline.editing')), 'Enter closes the source editor') // a display formula from $$ on its own line await focusDocEnd(page) // the caret was in the previous formula's source input await page.keyboard.press('Enter') await page.keyboard.type('$$') await page.waitForSelector('.tiptap .math-block', { timeout: 5000 }) await page.waitForSelector('.tiptap .math-block.editing .math-src', { timeout: 5000 }) await page.fill('.math-block.editing .math-src', '\\int_0^1 x^2 dx') await page.keyboard.press('Enter') await page.waitForSelector('.tiptap .math-block .katex-display', { timeout: 5000 }) console.log('✓ math: $x$ typesets via lazily-loaded KaTeX, source click-to-edit, $$ display block') // --- a formula can be selected and commented on --- // Single click selects it (that is how you comment on one) and must NOT open // the source editor; focusing the comment box must not dismiss the selection. await page.click('.tiptap .math-inline .math-view') const picked = await page.evaluate(() => ({ editing: document.querySelectorAll('.math-inline.editing').length, selNode: window.__editor.state.selection.node?.type?.name || null, })) assert.equal(picked.editing, 0, 'a single click does not open the source editor') assert.equal(picked.selNode, 'mathInline', 'a single click selects the formula: ' + JSON.stringify(picked)) await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 }) await page.click('#composer-text') await page.waitForTimeout(300) assert.ok(await page.isVisible('#composer'), 'focusing the comment box does not dismiss the formula selection') // the quote shows the formula source, not an empty string const mathQuote = await page.textContent('#composer-quote') assert.ok(mathQuote.includes('mc'), 'the comment quotes the formula source: ' + JSON.stringify(mathQuote)) await page.fill('#composer-text', '@ui-agent is this the right constant?') await page.click('#composer-send') await waitFor(async () => (await page.textContent('#margin-items')).includes('right constant'), 'comment on a formula posts') // resolve it: later assertions count the open threads on this document await page.click('.card.thread.expanded .resolve-btn') await waitFor(async () => !(await page.$('.card.thread')), 'this test cleans up its thread') console.log('✓ math: single click selects a formula, comment box keeps the selection and quotes the source') // dragging a text selection through a formula must work, not open the editor const dragBox = await page.evaluate(() => { const p = [...document.querySelectorAll('.tiptap p')].find(n => n.querySelector('.math-inline')) const r = p.getBoundingClientRect() return { x1: r.left + 4, x2: r.right - 4, y: r.top + r.height / 2 } }) await page.mouse.move(dragBox.x1, dragBox.y) await page.mouse.down() await page.mouse.move(dragBox.x2, dragBox.y, { steps: 20 }) await page.mouse.up() const dragged = await page.evaluate(() => { const sel = window.__editor.state.selection let hasMath = false window.__editor.state.doc.nodesBetween(sel.from, sel.to, n => { if (n.type.name === 'mathInline') hasMath = true }) return { empty: sel.empty, hasMath, editing: document.querySelectorAll('.editing').length } }) assert.ok(!dragged.empty && dragged.hasMath, 'a drag selects through the formula: ' + JSON.stringify(dragged)) assert.equal(dragged.editing, 0, 'dragging over a formula does not open its editor') console.log('✓ math: text selection drags straight through a rendered formula') // double click is what opens the source await page.dblclick('.tiptap .math-inline .math-view') await page.waitForSelector('.math-inline.editing .math-src', { timeout: 5000 }) await page.keyboard.press('Escape') await waitFor(async () => !(await page.$('.math-inline.editing')), 'Escape closes the source editor') console.log('✓ math: double click opens the source, Escape closes it') // it must survive a reload — i.e. it is really in the shared document await page.reload() await page.waitForSelector('.tiptap .math-inline .katex', { timeout: 15000 }) await page.waitForSelector('.tiptap .math-block .katex-display', { timeout: 15000 }) console.log('✓ math persists across a reload (stored in the collaborative doc)') // --- loading skeleton --- const docHtml = await (await page.request.get(page.url())).text() assert.ok(docHtml.includes('id="doc-skeleton"') && (docHtml.match(/sk-line/g) || []).length > 3, 'the doc shell ships the skeleton sheet in its markup') assert.ok(docHtml.includes('Browser Test Doc'), 'the shell carries the real title, not a placeholder') // plain fetch: no browser cookie jar, so this is a stranger asking const anonHtml = await (await fetch(page.url())).text() assert.ok(!anonHtml.includes('Browser Test Doc'), 'a shell for someone without access carries no title') const sk = await page.evaluate(() => { const el = document.getElementById('doc-skeleton') return el ? { exists: true, hidden: el.classList.contains('hidden'), lines: el.querySelectorAll('.sk-line').length } : { exists: false } }) assert.ok(sk.exists && sk.lines > 3, 'the doc shell ships a skeleton sheet: ' + JSON.stringify(sk)) assert.ok(sk.hidden, 'the skeleton is lifted once the document has synced') console.log('✓ loading: skeleton sheet ships in the shell and lifts after sync') // --- type into the editor --- await focusDocEnd(page) await page.keyboard.type('The quick brown fox jumps over the lazy dog.') await waitFor(async () => (await page.textContent('.tiptap')).includes('quick brown fox'), 'typed text') console.log('✓ typing works') // --- select text: the comment box opens itself in the margin, no widget --- await selectText(page, 'quick brown fox') await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 }) assert.ok(await page.isHidden('#selection-menu'), 'no floating selection widget on desktop') await page.waitForTimeout(300) const composerTop = parseFloat(await page.evaluate(() => document.getElementById('composer').style.top)) assert.ok(composerTop > 30, `composer aligned to anchored text, not at column top (top=${composerTop})`) // it comes pre-mentioning my first agent, and must NOT have taken the keyboard const prefilled = await page.inputValue('#composer-text') assert.equal(prefilled, '@ui-agent ', 'comment box pre-mentions my first agent: ' + JSON.stringify(prefilled)) const focusStillInDoc = await page.evaluate(() => !!document.activeElement?.closest('.tiptap')) assert.ok(focusStillInDoc, 'auto-opened box does not steal focus from the document') await page.fill('#composer-text', '@ui-agent please review this phrase') await page.click('#composer-send') await page.waitForSelector('.card.thread.expanded', { timeout: 5000 }) await waitFor(async () => (await page.textContent('#margin-items')).includes('please review'), 'thread text') await page.waitForSelector('.card.thread .chip', { timeout: 15000 }) await page.waitForSelector('.tiptap .comment-hl', { timeout: 5000 }) console.log('✓ comment card created: chip + text highlight visible') // --- Enter sends a comment, Shift+Enter adds a line --- await selectText(page, 'brown fox jumps') await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 }) await page.click('#composer-text') await page.fill('#composer-text', '') await page.keyboard.type('first line') await page.keyboard.down('Shift') await page.keyboard.press('Enter') await page.keyboard.up('Shift') await page.keyboard.type('second line') const twoLines = await page.inputValue('#composer-text') assert.ok(twoLines.includes('\n'), 'Shift+Enter adds a newline: ' + JSON.stringify(twoLines)) assert.ok(await page.isVisible('#composer'), 'Shift+Enter does not send') await page.keyboard.press('Enter') await waitFor(async () => await page.isHidden('#composer'), 'Enter sends the comment') await waitFor(async () => (await page.textContent('#margin-items')).includes('second line'), 'both lines posted') console.log('✓ comment composer: Enter sends, Shift+Enter adds a line') // the reply box behaves the same way await page.click('.card.thread') await page.waitForSelector('.card.thread.expanded .reply-row textarea', { timeout: 5000 }) const replyBox = '.card.thread.expanded .reply-row textarea' await page.click(replyBox) await page.keyboard.type('reply one') await page.keyboard.down('Shift') await page.keyboard.press('Enter') await page.keyboard.up('Shift') await page.keyboard.type('reply two') assert.ok((await page.inputValue(replyBox)).includes('\n'), 'Shift+Enter adds a line in a reply') await page.keyboard.press('Enter') await waitFor(async () => (await page.textContent('.card.thread')).includes('reply two'), 'Enter sends the reply') assert.equal(await page.inputValue(replyBox), '', 'the reply box clears after sending') // resolve it again: later assertions count the open threads, and this test's // own thread would otherwise look like a second discussion await page.click('.card.thread.expanded .resolve-btn') await waitFor(async () => (await page.$$('.card.thread')).length === 1, 'this test cleans up its thread') console.log('✓ reply box: Enter sends, Shift+Enter adds a line') // --- @ menu: arrows pick an agent, no mouse needed --- await selectText(page, 'over the lazy') await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 }) await page.click('#composer-text') await page.fill('#composer-text', '') await page.keyboard.type('@ui') await page.waitForSelector('#composer-mentions:not(.hidden)', { timeout: 5000 }) const menuOrder = await page.evaluate(() => [...document.querySelectorAll('#composer-mentions .mention-item')].map(d => d.textContent.trim()) ) assert.deepEqual(menuOrder, ['@ui-agent', '@ui-agent-two'], 'both handles offered: ' + menuOrder.join(',')) const firstSelected = await page.evaluate( () => document.querySelector('#composer-mentions .mention-item')?.classList.contains('selected') ) assert.ok(firstSelected, 'first agent is preselected') await page.keyboard.press('ArrowDown') const secondSelected = await page.evaluate( () => [...document.querySelectorAll('#composer-mentions .mention-item')][1]?.classList.contains('selected') ) assert.ok(secondSelected, 'ArrowDown moves the selection') await page.keyboard.press('Enter') const afterEnter = await page.inputValue('#composer-text') assert.equal(afterEnter, '@ui-agent-two ', 'Enter inserts the arrowed-to handle: ' + JSON.stringify(afterEnter)) assert.ok(await page.isHidden('#composer-mentions'), '@ menu closes after picking') await page.click('#composer-cancel') console.log('✓ @ menu: own agents first, preselected, arrow keys + Enter pick') // --- a connected agent is the one the box hands work to --- // ui-agent-two starts polling; presence is derived from that long-poll, so it // is now the only agent that would pick a mention up right away. const pollAbort = new AbortController() const polling = fetch(`${BASE}/api/mentions/stream?wait=20`, { headers: { authorization: `Bearer ${shownKeyTwo}` }, signal: pollAbort.signal, }).catch(() => {}) await waitFor(async () => { const agents = await page.evaluate(async () => { const r = await fetch(`/api/agents?doc=${location.pathname.split('/').pop()}`) return (await r.json()).agents }) return agents.find(a => a.handle === 'ui-agent-two')?.online === true }, 'ui-agent-two shows as online') await selectText(page, 'quick brown fox') await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 }) // the box refreshes presence when it opens, so the mention corrects itself await waitFor( async () => (await page.inputValue('#composer-text')) === '@ui-agent-two ', 'comment box pre-mentions the CONNECTED agent, not merely the first one' ) // and the @ menu puts it at the top, where Enter lands await page.fill('#composer-text', '') await page.type('#composer-text', '@ui-') await page.waitForSelector('#composer-mentions:not(.hidden)', { timeout: 5000 }) const onlineFirst = await page.evaluate(() => [...document.querySelectorAll('#composer-mentions .mention-item')].map(d => d.textContent.trim()) ) assert.deepEqual(onlineFirst, ['@ui-agent-two', '@ui-agent'], 'connected agent ranks first: ' + onlineFirst.join(',')) await page.keyboard.press('Escape') await page.click('#composer-cancel') pollAbort.abort() await polling console.log('✓ connected agent is preferred: pre-mention + top of the @ menu') // collapse on outside click await page.mouse.click(40, 500) await waitFor(async () => !(await page.$('.card.thread.expanded')), 'card collapses') console.log('✓ cards collapse when clicking away') // --- the human suggest UI is hidden until proposing means editing inline --- assert.ok(await page.isHidden('#mode-switch'), 'Editing/Suggesting switch is hidden') assert.ok(await page.isHidden('#suggest-btn'), 'Suggest button is hidden') assert.ok(await page.evaluate(() => window.__editor.isEditable), 'the document stays directly editable') page.on('dialog', d => { console.log('[dialog]', d.message()) d.dismiss().catch(() => {}) }) // suggestions themselves are unchanged — post one the way an agent does, then // check the whole reviewing flow on top of it const suggested = await page.evaluate(async () => { const id = location.pathname.split('/d/')[1].split('/')[0] const snap = await (await fetch(`/api/docs/${id}`)).json() const idx = (snap.blocks || []).findIndex(b => (b.markdown || '').includes('lazy dog')) const res = await fetch(`/api/docs/${id}/suggestions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ block_index: idx, replacement_markdown: 'The quick brown fox jumps over the **energetic** dog.', rationale: 'more positive energy', }), }) return { idx, ...(await res.json()) } }) assert.ok(suggested.idx >= 0 && suggested.suggestion_id, 'suggestion posted on the typed block: ' + JSON.stringify(suggested)) await page.waitForSelector('.card.suggestion', { timeout: 5000 }) await page.click('.card.suggestion') await page.waitForSelector('.card.suggestion.expanded', { timeout: 5000 }) console.log('✓ suggest UI hidden; document editable; suggestion arrives from the API') // inline track-changes rendering in the document await page.waitForSelector('.tiptap .sugg-ins', { timeout: 5000 }) // wait for the decoration set to settle: the widgets appear as it is rebuilt, // so reading the first paint was always a race await waitFor( async () => (await page.evaluate(() => [...document.querySelectorAll('.tiptap .sugg-ins')].map(n => n.textContent).join(' '))).includes('energetic'), 'inserted words shown inline' ) await waitFor( async () => (await page.evaluate(() => [...document.querySelectorAll('.tiptap .sugg-del')].map(n => n.textContent).join(' '))).includes('lazy'), 'deleted words struck through inline' ) // slim card: accept/reject in the header, no diff repetition assert.ok(await page.$('.card.suggestion .accept-btn'), 'accept button in card header') assert.ok(!(await page.$('.card.suggestion .udiff')), 'card does not repeat the diff') console.log('✓ suggestion rendered inline (strikethrough + insertion); card is slim') // --- discuss the suggestion (guide the agent) --- await page.fill('.card.suggestion .reply-row textarea', '@ui-agent could you keep the dog lazy though?') await page.click('.card.suggestion .reply-row button') await waitFor(async () => (await page.textContent('.card.suggestion')).includes('keep the dog lazy'), 'discussion message') // the discussion thread must NOT appear as a separate card const threadCards = await page.$$('.card.thread') assert.equal(threadCards.length, 1, 'suggestion discussion is embedded, not a separate card') console.log('✓ discussion on suggestion (embedded thread)') // --- collapse, then accept straight from the collapsed card --- await page.mouse.click(40, 500) await waitFor(async () => !(await page.$('.card.suggestion.expanded')), 'suggestion card collapsed') assert.ok(await page.locator('.card.suggestion .accept-btn').isVisible(), 'accept visible while collapsed') // active-highlight: click the card, inline marks gain .active await page.click('.card.suggestion .head .who') await waitFor(async () => (await page.$('.tiptap .sugg-ins.active')) !== null, 'active suggestion emphasized inline') await page.click('.card.suggestion .accept-btn') // (the inline insertion widget also contains the word — wait for the real applied mark) await waitFor(async () => (await page.innerHTML('.tiptap')).includes('energetic'), 'suggestion applied with marks') console.log('✓ suggestion accepted -> text replaced with marks') // resolved items are hidden by default, shown with the toggle await waitFor(async () => !(await page.$('.card.suggestion')), 'accepted suggestion hidden') await page.check('#show-resolved') await page.waitForSelector('.card.suggestion', { timeout: 5000 }) assert.ok((await page.textContent('.card.suggestion')).includes('accepted'), 'status visible when shown') await page.uncheck('#show-resolved') console.log('✓ resolved hidden by default, toggle shows them') // --- undo the accept: text reverts AND the suggestion reopens --- await page.click('#undo-btn') await waitFor(async () => !(await page.innerHTML('.tiptap')).includes('energetic'), 'undo reverts accepted text') await page.waitForSelector('.card.suggestion', { timeout: 5000 }) // visible again => open await waitFor(async () => (await page.$('.tiptap .sugg-ins')) !== null, 'inline diff back after reopen') console.log('✓ undo reverts the accept and reopens the suggestion') // accept again to continue (straight from the collapsed card) await page.click('.card.suggestion .accept-btn') await waitFor(async () => (await page.innerHTML('.tiptap')).includes('energetic'), 're-accept applies') console.log('✓ re-accept after undo works') // --- accepting while the collaboration socket is down --- // The accept endpoint is plain HTTP: it still answers when the websocket is // gone. Trusting a dead tab's "I applied it locally" marked suggestions // accepted while their content was silently dropped, which is how six // accepted entries vanished from a real document. const offlineSugg = await page.evaluate(async () => { const id = location.pathname.split('/').filter(Boolean)[1] const snap = await (await fetch(`/api/docs/${id}`)).json() const bi = snap.blocks.findIndex(b => b.markdown.includes('paragraph')) return await (await fetch(`/api/docs/${id}/suggestions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ block_index: bi < 0 ? 1 : bi, replacement_markdown: 'Written while the socket was down.' }), })).json() }) assert.ok(offlineSugg.suggestion_id, 'offline-case suggestion posted') await page.waitForSelector('.card.suggestion .accept-btn', { timeout: 8000 }) const dialogs = [] // an earlier handler in this suite already dismisses dialogs — only record here const onDialog = d => dialogs.push(d.message()) page.on('dialog', onDialog) await page.evaluate(() => window.__provider.disconnect()) await waitFor(async () => !(await page.evaluate(() => document.getElementById('conn').classList.contains('on'))), 'connection indicator goes off') await page.click('.card.suggestion .accept-btn') await page.waitForTimeout(800) assert.ok(dialogs.some(m => /not connected/i.test(m)), 'accepting offline warns instead of silently failing: ' + JSON.stringify(dialogs)) const stillOpen = await page.evaluate(async sid => { const id = location.pathname.split('/').filter(Boolean)[1] const snap = await (await fetch(`/api/docs/${id}`)).json() return snap.suggestions.find(x => x.id === sid)?.status }, offlineSugg.suggestion_id) assert.equal(stillOpen, 'open', 'a suggestion is not marked accepted while the tab cannot save') assert.ok(await page.$('#offline-notice'), 'a disconnected tab says so') // reconnect: the same accept now works end to end await page.evaluate(() => window.__provider.connect()) await waitFor(async () => !!(await page.evaluate(() => document.getElementById('conn').classList.contains('on'))), 'reconnected') await waitFor(async () => !(await page.$('#offline-notice')), 'offline notice clears on reconnect') await page.click('.card.suggestion .accept-btn') await waitFor(async () => (await page.textContent('.tiptap')).includes('Written while the socket was down.'), 'accept applies after reconnect') page.off('dialog', onDialog) console.log('✓ accepts are refused (not silently lost) while the socket is down, and work after reconnect') // --- image upload --- const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAF0lEQVR4nGP8z8Dwn4EIwESMolGFtFEIAK5+AxGmizXcAAAAAElFTkSuQmCC', 'base64' ) fs.writeFileSync(`${SHOT}/test.png`, png) const [chooser] = await Promise.all([page.waitForEvent('filechooser'), page.click('#image-btn')]) await chooser.setFiles(`${SHOT}/test.png`) await page.waitForSelector('.tiptap img:not(.ProseMirror-separator)', { timeout: 10000 }) const src = await page.getAttribute('.tiptap img:not(.ProseMirror-separator)', 'src') assert.ok(src.startsWith('/files/'), 'image served from /files: ' + src) const imgRes = await page.evaluate(async u => (await fetch(u)).status, src) assert.equal(imgRes, 200, 'image fetchable') console.log('✓ image upload + render') // --- reply + resolve on the standalone thread --- await page.click('.card.thread') await page.waitForSelector('.card.thread.expanded', { timeout: 5000 }) await page.fill('.card.thread .reply-row textarea', 'looks good, thanks!') await page.click('.card.thread .reply-row button') await waitFor(async () => (await page.textContent('.card.thread')).includes('looks good'), 'reply visible') console.log('✓ reply works') // --- list scenario: agent adds one item, only that item shows as inserted --- await focusDocEnd(page) await page.keyboard.press('Enter') await page.keyboard.type('- alpha') await page.keyboard.press('Enter') await page.keyboard.type('beta') await page.keyboard.press('Enter') await page.keyboard.type('gamma') await page.waitForTimeout(400) const listSugg = await page.evaluate(async () => { const id = location.pathname.split('/').pop() const snap = await (await fetch(`/api/docs/${id}`)).json() const li = snap.blocks.findIndex(b => b.markdown.includes('alpha')) return (await (await fetch(`/api/docs/${id}/suggestions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ block_index: li, replacement_markdown: '- alpha\n- beta\n- gamma\n- delta' }), })).json()) }) assert.ok(listSugg.ok, 'list suggestion: ' + JSON.stringify(listSugg)) await waitFor(async () => { const spans = await page.$$eval('.tiptap .sugg-ins', els => els.map(e => e.textContent)) return spans.some(t => t.includes('delta')) }, 'list insertion inline') const delSpans = await page.$$eval('.tiptap .sugg-del', els => els.map(e => e.textContent).filter(t => ['alpha', 'beta', 'gamma'].some(w => t.includes(w)))) assert.equal(delSpans.length, 0, 'unchanged list items are NOT struck through: ' + JSON.stringify(delSpans)) // typing a new block right after the suggested range must not get highlighted await focusDocEnd(page) await page.keyboard.press('Enter') await page.keyboard.press('Enter') // second Enter exits the list into a fresh paragraph await page.keyboard.type('untouched trailing line') await page.waitForTimeout(400) const trailingCls = await page.evaluate(() => { const blocks = [...document.querySelectorAll('.tiptap > *')] const t = blocks.find(b => b.textContent.includes('untouched trailing line')) return t ? t.className || '' : 'MISSING' }) assert.ok(!trailingCls.includes('suggestion-hl') && !trailingCls.includes('sugg-del') && trailingCls !== 'MISSING', 'new trailing block not highlighted: ' + trailingCls) await page.evaluate(async sid => { const id = location.pathname.split('/').pop() await fetch(`/api/docs/${id}/suggestions/${sid}/reject`, { method: 'POST' }) }, listSugg.suggestion_id) console.log('✓ list suggestion: only the added item renders as inserted') // --- new blocks (heading + list) render FORMATTED in the insertion panel --- const blockSugg = await page.evaluate(async () => { const id = location.pathname.split('/').pop() const snap = await (await fetch(`/api/docs/${id}`)).json() const li = snap.blocks.findIndex(b => b.markdown.includes('alpha')) return (await (await fetch(`/api/docs/${id}/suggestions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ block_index: li, replacement_markdown: snap.blocks[li].markdown + '\n\n## Sources\n\n- one **bold** source', }), })).json()) }) assert.ok(blockSugg.ok, 'block suggestion: ' + JSON.stringify(blockSugg)) await page.waitForSelector('.tiptap .sugg-ins-block', { timeout: 5000 }) assert.ok(await page.$('.tiptap .sugg-ins-block h2'), 'inserted heading renders as a heading') assert.ok(await page.$('.tiptap .sugg-ins-block ul li strong'), 'inserted list renders as bullets with marks') await page.evaluate(async sid => { const id = location.pathname.split('/').pop() await fetch(`/api/docs/${id}/suggestions/${sid}/reject`, { method: 'POST' }) }, blockSugg.suggestion_id) console.log('✓ new blocks render formatted (heading, bullets, marks) in the insertion panel') // --- formatting-only changes are visible, and inserted runs keep their marks --- // The inline diff used to compare mark-stripped text: a suggestion that only // linked (or bolded) an existing word looked completely identical, so the // document showed no diff at all and the card seemed to propose nothing. const markCases = [ { label: 'link', md: 'A paragraph to [link up](https://example.com/ref) here.', sel: '.tiptap .sugg-ins a[href="https://example.com/ref"]' }, { label: 'bold', md: 'A paragraph to **link up** here.', sel: '.tiptap .sugg-ins strong' }, { label: 'code', md: 'A paragraph to `link up` here.', sel: '.tiptap .sugg-ins code' }, ] // its own paragraph, verified to be a standalone block: the point of these // cases is that ONLY the formatting differs from the replacement markdown await focusDocEnd(page) await page.keyboard.press('Enter') await page.keyboard.type('A paragraph to link up here.') await waitFor(async () => (await page.textContent('.tiptap')).includes('A paragraph to link up here.'), 'mark-case paragraph typed') await waitFor(async () => { const md = await page.evaluate(async () => { const id = location.pathname.split('/').filter(Boolean)[1] const snap = await (await fetch(`/api/docs/${id}`)).json() const b = snap.blocks.find(x => x.markdown.includes('A paragraph to link up here.')) return b?.markdown }) return md === 'A paragraph to link up here.' }, 'the paragraph is its own block (formatting-only cases need an exact match)') for (const c of markCases) { const sugg = await page.evaluate(async md => { const id = location.pathname.split('/').filter(Boolean)[1] const snap = await (await fetch(`/api/docs/${id}`)).json() const bi = snap.blocks.findIndex(b => b.markdown.includes('A paragraph to link up here.')) return await (await fetch(`/api/docs/${id}/suggestions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ block_index: bi, replacement_markdown: md, rationale: 'formatting only' }), })).json() }, c.md) assert.ok(sugg.suggestion_id, `${c.label} suggestion posted: ` + JSON.stringify(sugg)) // the old run is struck AND the new run is inserted with its formatting await page.waitForSelector(c.sel, { timeout: 8000 }) const marked = await page.evaluate(sel => { const ins = document.querySelector(sel) return { text: ins.textContent, deleted: [...document.querySelectorAll('.tiptap .sugg-del')].map(n => n.textContent) } }, c.sel) assert.equal(marked.text, 'link up', `${c.label}: inserted run carries the text`) assert.ok(marked.deleted.some(t => t.includes('link up')), `${c.label}: the unformatted run is struck through: ${JSON.stringify(marked.deleted)}`) await page.evaluate(async sid => { const id = location.pathname.split('/').filter(Boolean)[1] await fetch(`/api/docs/${id}/suggestions/${sid}/reject`, { method: 'POST' }) }, sugg.suggestion_id) await waitFor(async () => !(await page.$(c.sel)), `${c.label} suggestion cleared`) } console.log('✓ formatting-only suggestions (link / bold / code) render as visible diffs') // a link inside a *proposed* change must not navigate away on a plain click const linkSugg = await page.evaluate(async () => { const id = location.pathname.split('/').filter(Boolean)[1] const snap = await (await fetch(`/api/docs/${id}`)).json() const bi = snap.blocks.findIndex(b => b.markdown.includes('A paragraph to link up here.')) return await (await fetch(`/api/docs/${id}/suggestions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ block_index: bi, replacement_markdown: 'A paragraph to [link up](https://example.com/ref) here.' }), })).json() }) assert.ok(linkSugg.suggestion_id, 'preview-link suggestion posted') await page.waitForSelector('.tiptap .sugg-ins a[href]', { timeout: 8000 }) const urlBefore = page.url() await page.click('.tiptap .sugg-ins a[href]') await page.waitForTimeout(500) assert.equal(page.url(), urlBefore, 'clicking a suggested link stays on the page') assert.ok(await page.$('.card.suggestion.expanded'), 'clicking a suggested link opens its card instead') // accepting turns it into a real link in the document await page.click('.card.suggestion.expanded .accept-btn') await waitFor(async () => !!(await page.$('.tiptap a[href="https://example.com/ref"]')), 'accepted link is a real link') await waitFor(async () => { const md = await page.evaluate(async () => { const id = location.pathname.split('/').filter(Boolean)[1] return (await (await fetch(`/api/docs/${id}`)).json()).markdown }) return md.includes('[link up](https://example.com/ref)') }, 'accepted link round-trips to markdown') console.log('✓ suggested links preview safely, then accept into real links') // --- dissimilar rewrite: struck old block + formatted panel, never a flat text blob --- const dis = await page.evaluate(async () => { const id = location.pathname.split('/').pop() const snap = await (await fetch(`/api/docs/${id}`)).json() const li = snap.blocks.findIndex(b => b.markdown.includes('untouched trailing line')) return (await (await fetch(`/api/docs/${id}/suggestions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ block_index: li, replacement_markdown: '## Appendix\n\nCompletely different content here.' }), })).json()) }) assert.ok(dis.ok, 'dissimilar suggestion: ' + JSON.stringify(dis)) await waitFor(async () => (await page.$$('.tiptap .sugg-ins-block')).length >= 1, 'panel for dissimilar rewrite') assert.ok(await page.$('.tiptap .sugg-del-block'), 'unrelated old block struck wholesale') const flatIns = await page.$$eval('.tiptap .sugg-ins', els => els.map(e => e.textContent).filter(t => t.includes('Appendix'))) assert.equal(flatIns.length, 0, 'heading is not flat inline text') assert.ok(await page.$$eval('.tiptap .sugg-ins-block', els => els.some(e => e.querySelector('h2'))), 'panel contains a real heading') await page.evaluate(async sid => { const id = location.pathname.split('/').pop() await fetch(`/api/docs/${id}/suggestions/${sid}/reject`, { method: 'POST' }) }, dis.suggestion_id) console.log('✓ dissimilar rewrites: whole-block strike + formatted panel (no raw blob)') // --- typing at a suggestion boundary pushes the insertion panel down --- await focusDocEnd(page) await page.keyboard.press('Enter') await page.keyboard.type('ordering base line') await page.waitForTimeout(400) const push = await page.evaluate(async () => { const id = location.pathname.split('/').pop() const snap = await (await fetch(`/api/docs/${id}`)).json() const li = snap.blocks.findIndex(b => b.markdown.includes('ordering base line')) return (await (await fetch(`/api/docs/${id}/suggestions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ block_index: li, replacement_markdown: 'ordering base line\n\n## Pushed Panel' }), })).json()) }) assert.ok(push.ok, JSON.stringify(push)) await waitFor(async () => (await page.textContent('.tiptap')).includes('Pushed Panel'), 'panel appears') await page.evaluate(() => { const e = window.__editor let pos = null e.state.doc.descendants((node, p) => { if (node.isText && node.text.includes('ordering base line')) pos = p + node.text.length }) e.chain().focus().setTextSelection(pos).run() }) await page.keyboard.press('Enter') await page.keyboard.type('pushes the panel') await page.waitForTimeout(500) const domOrder = await page.evaluate(() => { const kids = [...document.querySelector('.tiptap').children].map(el => el.className.includes('sugg-ins-block') ? 'PANEL' : el.textContent.slice(0, 25) ) return kids.slice(kids.findIndex(t => t.includes('ordering base'))) }) assert.ok( domOrder.indexOf('PANEL') > domOrder.findIndex(t => t.includes('pushes the panel')), 'typed line lands ABOVE the panel (pushes it down): ' + JSON.stringify(domOrder) ) await page.evaluate(async sid => { const id = location.pathname.split('/').pop() await fetch(`/api/docs/${id}/suggestions/${sid}/reject`, { method: 'POST' }) }, push.suggestion_id) console.log('✓ typing at the boundary pushes the insertion panel down') // --- zoom --- const baseSize = await page.evaluate(() => parseFloat(getComputedStyle(document.querySelector('.tiptap')).fontSize)) await page.click('#zoom-in') const zoomedSize = await page.evaluate(() => parseFloat(getComputedStyle(document.querySelector('.tiptap')).fontSize)) assert.ok(zoomedSize > baseSize, `zoom-in grows text (${baseSize} -> ${zoomedSize})`) await page.click('#zoom-out') console.log('✓ zoom controls') // --- document style: Docs (Arial 11pt/1.15) <-> Reading (serif 22px/1.45) --- const typeset = () => page.evaluate(() => { const t = getComputedStyle(document.querySelector('.tiptap')) const p = [...document.querySelectorAll('.tiptap p')].find(e => e.textContent.trim().length > 40) return { style: document.documentElement.dataset.docStyle, family: t.fontFamily.split(',')[0].replace(/"/g, ''), size: parseFloat(t.fontSize), line: parseFloat(t.lineHeight), paraGap: p ? parseFloat(getComputedStyle(p).marginBottom) : null, measure: Math.round(document.querySelector('.tiptap').getBoundingClientRect().width), } }) const docsSet = await typeset() assert.strictEqual(docsSet.style, 'docs', 'default document style is docs') assert.strictEqual(docsSet.family, 'Arial', 'docs style sets the document in Arial') assert.strictEqual(docsSet.paraGap, 0, 'docs style has no space between paragraphs') await page.click('#docstyle-btn') await waitFor(async () => (await typeset()).family === 'Libre Baskerville', 'reading style swaps in the serif') const readingSet = await typeset() // The settled numbers, pinned: 16px on 28.8px with 17.6px between paragraphs. // Chosen by eye against real text rather than derived, which is exactly why // they are worth pinning — nothing else records why these and not others. assert.strictEqual(readingSet.size, 16, `body is set at 16px (got ${readingSet.size})`) assert.strictEqual(Math.round(readingSet.line * 10) / 10, 28.8, `leading is 28.8px (got ${readingSet.line})`) assert.strictEqual(Math.round(readingSet.paraGap * 10) / 10, 17.6, `paragraph gap is 17.6px (got ${readingSet.paraGap})`) const head = await page.evaluate(() => { const h = document.querySelector('.tiptap h2') if (!h) return null const s = getComputedStyle(h) return { size: parseFloat(s.fontSize), line: parseFloat(s.lineHeight), caps: s.fontVariantCaps, ls: parseFloat(s.letterSpacing), mt: parseFloat(s.marginTop) } }) if (head) { assert.strictEqual(Math.round(head.size * 10) / 10, 25.6, `section heads are 25.6px (got ${head.size})`) assert.strictEqual(head.caps, 'all-small-caps', 'section heads are small caps') assert.ok(Math.abs(head.ls / head.size - 0.075) < 0.005, `section heads keep the article's 0.075em tracking (got ${(head.ls / head.size).toFixed(4)}em)`) assert.ok(Math.abs(head.mt / head.size - 2) < 0.02, `section heads take 2em of air above (got ${(head.mt / head.size).toFixed(2)}em)`) } // the switch is typography only — the sheet keeps Docs' letter geometry assert.strictEqual(readingSet.measure, docsSet.measure, 'the text column is untouched by the document style') // (no words-per-line guard against Docs: this face at this size fits 10.69 // to Docs' 13.54, so the two deliberately differ and switching reflows) // and it survives a reload, like the theme await page.reload({ waitUntil: 'domcontentloaded' }) await waitFor(async () => !!(await page.$('.tiptap p')), 'doc reloads') assert.strictEqual((await typeset()).family, 'Libre Baskerville', 'document style persists across a reload') await page.click('#docstyle-btn') await waitFor(async () => (await typeset()).family === 'Arial', 'switches back to docs') console.log('✓ document style switch (Docs <-> Reading), persisted, page width untouched') // --- undo / redo --- await focusDocEnd(page) await page.waitForTimeout(600) await page.keyboard.type(' UNDOME') await waitFor(async () => (await page.textContent('.tiptap')).includes('UNDOME'), 'typed marker') await page.click('#undo-btn') await waitFor(async () => !(await page.textContent('.tiptap')).includes('UNDOME'), 'undo removes marker') await page.click('#redo-btn') await waitFor(async () => (await page.textContent('.tiptap')).includes('UNDOME'), 'redo restores marker') await page.click('#undo-btn') console.log('✓ undo / redo buttons') await page.screenshot({ path: `${SHOT}/redesign-doc.png`, fullPage: false }) // "- [ ] " markdown shortcut converts to a real task list (done last so it // doesn't perturb the earlier suggestion tests on this doc) await focusDocEnd(page) await page.keyboard.press('Enter') await page.keyboard.type('- [ ] shortcut task') await waitFor(async () => !!(await page.$('.tiptap ul[data-type="taskList"] input')), '"- [ ] " creates a task list') assert.ok(!(await page.textContent('.tiptap')).includes('[ ] shortcut'), 'no literal bracket text left') console.log('✓ "- [ ] " shortcut makes a task list') // live HTML embed: insert via toolbar, render in a sandboxed iframe, persist await page.click('button[title="Embed live HTML (sandboxed)"]') await waitFor(async () => !!(await page.$('.embed-overlay-ta')), 'embed overlay opens') await page.fill('.embed-overlay-ta', '