cowrite / test /browser.js
lvwerra's picture
lvwerra HF Staff
Table columns resize by dragging a cell border (#1)
7e842fd
Raw
History Blame Contribute Delete
78.3 kB
// 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('<span id="doc-title">Browser Test Doc</span>'), '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('<strong>energetic</strong>'), '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('<strong>energetic</strong>'), '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('<strong>energetic</strong>'), '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', '<div id="embed-probe" style="height:220px">hello <b>embed</b></div>')
await page.click('.embed-overlay .btn.primary')
await waitFor(async () => !!(await page.$('.tiptap iframe.html-embed')), 'embed iframe renders')
const sandbox = await page.getAttribute('.tiptap iframe.html-embed', 'sandbox')
assert.ok(/\ballow-scripts\b/.test(sandbox), 'iframe allows scripts')
assert.ok(!/allow-same-origin/.test(sandbox), 'iframe is NOT same-origin (sandbox intact)')
// content actually rendered inside the frame
await waitFor(async () => {
const fr = page.frames().find(f => f.parentFrame())
try { return !!(fr && (await fr.$('#embed-probe'))) } catch { return false }
}, 'embed content mounted inside frame')
// iframe auto-sizes to content — no stray inner scrollbar (frame not scrollable)
await waitFor(async () => {
const fr = page.frames().find(f => f.parentFrame())
try {
const m = await fr.evaluate(() => ({ s: document.documentElement.scrollHeight, c: document.documentElement.clientHeight }))
return m.c >= 200 && m.s <= m.c + 1
} catch { return false }
}, 'embed frame fits its content (no vertical scrollbar)')
// survives a reload (round-trips through Yjs + markdown)
await page.reload({ waitUntil: 'networkidle' })
await waitFor(async () => !!(await page.$('.tiptap iframe.html-embed')), 'embed persists after reload')
console.log('✓ sandboxed HTML embed renders + persists')
// --- playground: sidebar, strips, chains, image bubble, reset ---
await page.click('#docs-btn')
await page.waitForSelector('#docs-pop:not(.hidden)')
await page.click('#playground-btn')
await page.waitForURL('**/d/**')
await page.waitForSelector('.tiptap')
await waitFor(async () => (await page.$$('.card')).length >= 8, 'playground cards render')
await page.waitForTimeout(800)
const treeLinks = await page.$$eval('#page-tree a', as => as.map(a => a.textContent))
assert.ok(treeLinks.length >= 2, 'sidebar tree has pages: ' + JSON.stringify(treeLinks))
console.log('✓ playground: sidebar tree')
// task lists + tables render natively in the seeded home page
await waitFor(async () => !!(await page.$('.tiptap ul[data-type="taskList"] input')), 'task list renders with checkboxes')
await waitFor(async () => !!(await page.$('.tiptap table th')), 'table renders with a header row')
await waitFor(async () => !!(await page.$('.tiptap iframe.html-embed')), 'seeded HTML embed renders in the editor')
console.log('✓ task lists + tables + HTML embed render in the editor')
// --- table columns resize by dragging a cell border, and the width sticks ---
const colWidths = () => page.evaluate(() =>
[...document.querySelectorAll('.tiptap table:first-of-type tr:first-child > *')]
.map(c => ({ px: Math.round(c.getBoundingClientRect().width), attr: c.getAttribute('colwidth') })))
const tableWidth = () => page.evaluate(() =>
Math.round(document.querySelector('.tiptap table:first-of-type').getBoundingClientRect().width))
const startCols = await colWidths()
const startTable = await tableWidth()
assert.ok(startCols.length >= 2, 'seeded table has columns to resize')
const border = await page.evaluate(() => {
const c = document.querySelector('.tiptap table:first-of-type tr:first-child > *')
const r = c.getBoundingClientRect()
return { x: r.right, y: r.top + r.height / 2 }
})
await page.mouse.move(border.x - 1, border.y)
await waitFor(async () => !!(await page.$('.tiptap .column-resize-handle')), 'resize handle appears on the border')
assert.ok(await page.evaluate(() => document.querySelector('.tiptap').classList.contains('resize-cursor')),
'the editor shows a col-resize cursor while the border is under the pointer')
await page.mouse.down()
await page.mouse.move(border.x - 90, border.y, { steps: 10 })
await page.mouse.up()
await waitFor(async () => (await colWidths())[0].attr !== null, 'drag writes a colwidth onto the column')
const narrowedCols = await colWidths()
assert.ok(narrowedCols[0].px < startCols[0].px - 40, `first column narrowed (${startCols[0].px} -> ${narrowedCols[0].px})`)
// the last column is deliberately elastic, so the table itself must not grow
assert.ok(Math.abs(await tableWidth() - startTable) <= 2,
`table stays pinned to the page width (${startTable} -> ${await tableWidth()})`)
// and the width is an edit like any other: it survives a reload through the doc
await page.reload({ waitUntil: 'domcontentloaded' })
await waitFor(async () => !!(await page.$('.tiptap table')), 'doc reloads with the table')
await waitFor(async () => (await colWidths())[0].attr !== null, 'column width persisted across the reload')
const colsAfterReload = await colWidths()
assert.strictEqual(colsAfterReload[0].attr, narrowedCols[0].attr,
`same colwidth after reload (${narrowedCols[0].attr} vs ${colsAfterReload[0].attr})`)
console.log('✓ table columns resize by drag, table width pinned, widths persist')
// --- the switcher is the whole of navigation now: list, switch, and / resolves ---
const firstDocUrl = page.url()
await page.click('#docs-btn')
await page.waitForSelector('#docs-pop:not(.hidden)')
await waitFor(async () => (await page.$$('#docs-pop .doc-row')).length >= 2, 'switcher lists the documents')
const rows = await page.$$eval('#docs-pop .doc-row', rs => rs.map(r => ({
title: r.querySelector('.title')?.textContent, current: r.classList.contains('current') })))
assert.strictEqual(rows.filter(r => r.current).length, 1, 'exactly one row is marked as the one you are in: ' + JSON.stringify(rows))
// switching is a navigation, so the URL is the document — a link worth sharing
const other = rows.find(r => !r.current)
await page.click(`#docs-pop .doc-row:not(.current) .doc-row-main`)
await page.waitForURL(u => u.toString() !== firstDocUrl, { timeout: 15000 })
await page.waitForSelector('.tiptap', { timeout: 20000 })
await waitFor(async () => (await page.textContent('#doc-title')).trim() === other.title,
`header shows the document switched to (${other.title})`)
assert.ok(await page.isHidden('#docs-pop'), 'the switcher closes behind the navigation')
console.log('✓ switcher lists documents, marks the current one, switches by navigation')
// / is no longer a page of its own: it resolves to the most recent document
const landed = await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' })
assert.ok(/\/d\//.test(page.url()), 'a signed-in visitor at / lands in a document: ' + page.url())
assert.strictEqual(landed.request().redirectedFrom() ? 302 : landed.status(), landed.request().redirectedFrom() ? 302 : 200)
await page.waitForSelector('.tiptap', { timeout: 20000 })
console.log('✓ / resolves to the most recent document, no index page')
// new-page suggestion: the seeded proposal shows as pending; preview + accept creates it
await waitFor(async () => !!(await page.$('#page-tree a.pending-page')), 'proposed page shows in sidebar')
await page.click('#page-tree a.pending-page')
await page.waitForSelector('#proposal-view:not(.hidden)')
await waitFor(async () => !!(await page.$('#proposal-view table')), 'proposal preview renders content')
await page.click('#proposal-view .proposal-actions .primary')
await waitFor(async () => (await page.$$eval('#page-tree a', as => as.map(a => a.textContent))).some(t => t.includes('Proposed Page') && !t.includes('proposed')), 'accepted page joins the tree')
await waitFor(async () => !(await page.$('#page-tree a.pending-page')), 'no longer pending after accept')
console.log('✓ new-page suggestion: pending → preview → accept creates the page')
// return to the home page for the remaining playground sub-tests
await page.evaluate(() => [...document.querySelectorAll('#page-tree a')].find(a => a.textContent.trim() === 'Playground')?.click())
await waitFor(async () => (await page.$$('.card.suggestion')).length >= 1, 'back on home with suggestions')
// revision chain: ‹ 2/3 shows generation two inline
assert.ok(await page.$('.chain-nav'), 'chain nav present')
assert.equal(await page.textContent('.chain-pos'), '3/3', 'chain defaults to latest')
await page.click('.chain-nav .iconbtn:first-child')
await page.waitForTimeout(400)
assert.equal(await page.textContent('.chain-pos'), '2/3', 'nav moves to generation 2')
const gen2Text = await page.textContent('.tiptap')
assert.ok(gen2Text.includes('generation') && gen2Text.includes('sharper'), 'inline diff follows the displayed generation')
await page.click('.chain-nav .iconbtn:last-child')
await page.waitForTimeout(300)
console.log('✓ revision chain navigation ‹ n/N ›')
// suggestion attached to a comment thread: strip with working accept
assert.ok(await page.$('.card.thread .sugg-strip'), 'suggestion renders as a strip inside its comment card')
await page.click('.sugg-strip .accept-btn')
await waitFor(async () => (await page.textContent('.tiptap')).includes('go break things'), 'strip accept applies')
console.log('✓ comment-attached suggestion strip (accept works collapsed)')
// comment resolvable without opening the card
assert.ok(await page.$('.card.thread .resolve-btn'), 'collapsed thread has a resolve button')
await page.click('.card.thread .resolve-btn')
await waitFor(async () => !(await page.$('.card.thread:not(.expanded) .resolve-btn')), 'thread resolved from collapsed head')
console.log('✓ comment resolve without opening')
// image bubble: width + alignment
const img = await page.$('.tiptap img:not(.ProseMirror-separator)')
await img.scrollIntoViewIfNeeded()
await img.click()
await page.waitForTimeout(400)
assert.ok(await page.evaluate(() => !document.getElementById('image-bubble').classList.contains('hidden')), 'bubble appears on image selection')
for (const title of ['Width 50%', 'Align center']) {
await page.evaluate(t => {
;[...document.querySelectorAll('#image-bubble .iconbtn')].find(b => b.title === t).dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
}, title)
await page.waitForTimeout(300)
}
const imgAttrs = await page.evaluate(() => {
const i = document.querySelector('.tiptap img:not(.ProseMirror-separator)')
return { style: i.getAttribute('style') || '', align: i.getAttribute('data-align') }
})
assert.ok(imgAttrs.style.includes('50%') && imgAttrs.align === 'center', 'image resized + centered: ' + JSON.stringify(imgAttrs))
console.log('✓ image layout bubble (resize + align)')
// structure link: icon + capitalized
const stLink = await page.evaluate(() => ({
text: document.getElementById('structure-link').textContent.trim(),
hasIcon: !!document.querySelector('#structure-link svg'),
}))
assert.deepEqual(stLink, { text: 'Structure', hasIcon: true }, 'structure link polished: ' + JSON.stringify(stLink))
// [[ cross-reference: autocomplete inserts a page link
await focusDocEnd(page)
await page.keyboard.press('Enter')
await page.keyboard.type('See also [[not')
await waitFor(async () => !(await page.evaluate(() => document.getElementById('pagelink-menu').classList.contains('hidden'))), 'pagelink menu appears')
await page.keyboard.press('Enter')
await waitFor(async () => page.evaluate(() => [...document.querySelectorAll('.tiptap a')].some(a => a.textContent === 'Notes' && a.getAttribute('href').endsWith('/notes'))), 'cross-reference link inserted')
console.log('✓ [[ page cross-references')
// ghost page: visiting a slug that does not exist creates it, titled by slug
const projId = page.url().split('/d/')[1].split('/')[0]
await page.goto(`${BASE}/d/${projId}/road-map`)
await page.waitForSelector('.tiptap')
await waitFor(async () => (await page.textContent('.tiptap')).includes('Road map'), 'ghost page seeded with slug title')
await waitFor(async () => (await page.$$eval('#page-tree a', as => as.map(a => a.textContent))).some(t => t.includes('Road map')), 'ghost page in sidebar')
console.log('✓ ghost pages auto-created from the structure')
await page.goto(`${BASE}/d/${projId}`)
await page.waitForSelector('.tiptap')
await page.waitForTimeout(1000)
// reset restores the seeded state
await page.evaluate(() => (window.confirm = () => true))
await page.click('#reset-btn')
await page.waitForSelector('.tiptap')
await waitFor(async () => (await page.textContent('.tiptap')).includes('happy testing'), 'reset restores content')
console.log('✓ playground reset')
// --- phone layout (same doc, iPhone-sized touch viewport) ---
// its own browser: chromium runs --single-process here and tears down when
// its last page closes, so contexts are not swapped mid-run
await mobileChecks(page.url())
assert.deepEqual(consoleErrors, [], 'no console errors: ' + JSON.stringify(consoleErrors))
console.log('✓ zero console errors')
await browser.close()
server.kill()
fs.rmSync(DATA, { recursive: true, force: true })
console.log('\nBROWSER TEST PASSED — screenshots in ' + SHOT)
process.exit(0)
}
// The phone shell is a different layout, not just narrower: compact header with
// a ⋯ menu and a toggleable formatting row, the page tree as a drawer, the
// comments as a bottom sheet. Everything below runs at 390x844 with touch.
async function mobileChecks(docUrl) {
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 ctx = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
deviceScaleFactor: 2,
})
const page = await ctx.newPage()
page.on('console', msg => {
if (msg.type() === 'error' && !msg.text().includes('fonts.g')) consoleErrors.push('mobile: ' + msg.text())
})
page.on('pageerror', err => consoleErrors.push('mobile PAGEERROR: ' + err.message))
// fresh browser => fresh cookie jar: sign in, landing on the same document
const docPath = new URL(docUrl).pathname
await page.goto(`${BASE}/auth/dev?u=alice&next=${encodeURIComponent(docPath)}`)
await page.waitForSelector('.tiptap')
await waitFor(async () => (await page.$$('.card')).length >= 1, 'mobile cards render')
await page.waitForTimeout(800)
const shot = name => page.screenshot({ path: path.join(SHOT, `mobile-${name}.png`) })
// nothing may widen the page: a phone browser would zoom out to fit
const width = await page.evaluate(() => ({ inner: window.innerWidth, scroll: document.documentElement.scrollWidth }))
assert.equal(width.scroll, width.inner, `no horizontal overflow (${JSON.stringify(width)})`)
// the header must stay put at any scroll depth (regression: body{height:100%}
// made the sticky containing block one viewport tall)
for (const y of [900, 2500]) {
await page.evaluate(scrollY => window.scrollTo(0, scrollY), y)
await page.waitForTimeout(250)
const top = await page.evaluate(() => Math.round(document.getElementById('topbar').getBoundingClientRect().top))
assert.equal(top, 0, `header pinned at scrollY=${y} (top=${top})`)
}
await page.evaluate(() => window.scrollTo(0, 0))
await page.waitForTimeout(200)
await shot('01-doc')
console.log('✓ mobile: no horizontal overflow, header stays pinned while scrolling')
// desktop-only chrome is gone; phone controls are real 40px tap targets
const targets = await page.evaluate(() => {
const out = {}
for (const sel of ['#m-pages', '#m-comments', '#m-fmt', '#m-more']) {
const r = document.querySelector(sel).getBoundingClientRect()
out[sel] = [Math.round(r.width), Math.round(r.height)]
}
out.shareVisible = document.getElementById('share-btn').getBoundingClientRect().height > 0
out.edgeTabs = document.getElementById('sidebar-toggle').getBoundingClientRect().height > 0
return out
})
assert.ok(!targets.shareVisible, 'desktop Share button hidden on phones (moved into ⋯)')
assert.ok(!targets.edgeTabs, 'desktop edge tabs hidden on phones')
for (const sel of ['#m-pages', '#m-comments', '#m-fmt', '#m-more']) {
const [w, h] = targets[sel]
assert.ok(w >= 40 && h >= 40, `${sel} is a 40px+ tap target (got ${w}x${h})`)
}
// ⋯ menu holds Share / Agents / identity and still works from there
await page.click('#m-more')
await page.waitForSelector('#more-pop:not(.hidden)')
const inMenu = await page.evaluate(() => [...document.getElementById('more-pop').children].map(c => c.id))
assert.ok(inMenu.includes('share-btn') && inMenu.includes('agents-btn') && inMenu.includes('whoami'), 'more menu: ' + inMenu)
assert.ok(inMenu.includes('theme-btn'), 'the theme toggle rides the ⋯ sheet on a phone: ' + inMenu)
await shot('02-more-menu')
await page.click('#more-pop #share-btn')
await page.waitForSelector('#share-pop:not(.hidden)')
const pop = await page.evaluate(() => {
const r = document.getElementById('share-pop').getBoundingClientRect()
return { left: Math.round(r.left), right: Math.round(r.right), width: Math.round(r.width) }
})
assert.ok(pop.left >= 0 && pop.right <= 390 && pop.width > 300, 'share sheet fits the screen: ' + JSON.stringify(pop))
await page.click('#m-more') // dismiss
await page.waitForTimeout(200)
console.log('✓ mobile: ⋯ menu carries Share/Agents/identity, popovers fit the screen')
// formatting toolbar: hidden by default, revealed as a scrollable second row
assert.ok(await page.evaluate(() => document.getElementById('header-tools').getBoundingClientRect().height === 0), 'toolbar collapsed by default')
await page.click('#m-fmt')
await page.waitForTimeout(300)
const tools = await page.evaluate(() => {
const t = document.getElementById('header-tools')
const r = t.getBoundingClientRect()
return { h: Math.round(r.height), top: Math.round(r.top), scrollable: t.scrollWidth > t.clientWidth + 10, hdr: document.getElementById('topbar').offsetHeight }
})
assert.ok(tools.h > 25 && tools.top < tools.hdr, 'formatting row shows inside the header: ' + JSON.stringify(tools))
assert.ok(tools.scrollable, 'formatting row scrolls horizontally instead of squeezing')
await shot('03-fmt-row')
// and it actually formats
await selectText(page, 'quick brown fox')
await page.evaluate(() => [...document.querySelectorAll('#header-tools button')].find(b => b.textContent === 'B')?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
await waitFor(async () => await page.evaluate(() => window.__editor.isActive('bold')), 'bold applied from the phone toolbar')
await page.evaluate(() => [...document.querySelectorAll('#header-tools button')].find(b => b.textContent === 'B')?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
await page.click('#m-fmt')
await page.waitForTimeout(200)
console.log('✓ mobile: formatting row toggles, scrolls and applies marks')
// pages drawer: scrim, dismiss, and closing itself when a page is chosen
await page.click('#m-pages')
await page.waitForTimeout(350)
const drawer = await page.evaluate(() => {
const s = document.getElementById('sidebar').getBoundingClientRect()
return {
onScreen: s.left >= 0 && s.width > 200,
belowHeader: Math.round(s.top) >= document.getElementById('topbar').offsetHeight,
scrim: !document.getElementById('scrim').classList.contains('hidden'),
}
})
assert.ok(drawer.onScreen && drawer.belowHeader && drawer.scrim, 'drawer slides over with a scrim: ' + JSON.stringify(drawer))
await shot('04-drawer')
// tap the exposed part of the scrim, to the right of the 300px drawer
await page.mouse.click(360, 620)
await waitFor(async () => await page.evaluate(() => document.getElementById('sidebar').classList.contains('hidden')), 'scrim dismisses the drawer')
await page.click('#m-pages')
await page.waitForTimeout(300)
await page.click('#page-tree a:not(.current)')
await waitFor(async () => await page.evaluate(() => document.getElementById('sidebar').classList.contains('hidden')), 'drawer closes after picking a page')
await page.waitForSelector('.tiptap')
await page.waitForTimeout(600)
console.log('✓ mobile: pages drawer — scrim dismiss + closes on navigation')
// comments bottom sheet: closed by default, badge shows the count, cards land
// on screen (they used to stack below the whole document, out of sight)
await page.goto(`${BASE}${docPath}`)
await page.waitForSelector('.tiptap')
await waitFor(async () => (await page.$$('.card')).length >= 1, 'cards back on the main page')
await page.waitForTimeout(700)
assert.ok(await page.evaluate(() => document.getElementById('margin-col').classList.contains('hidden')), 'sheet starts closed')
const badge = await page.evaluate(() => {
const b = document.querySelector('#m-comments .count')
return { text: b.textContent, shown: !b.classList.contains('hidden') }
})
assert.ok(badge.shown && Number(badge.text) > 0, 'comment count badge: ' + JSON.stringify(badge))
await page.click('#m-comments')
await page.waitForTimeout(500)
const sheet = await page.evaluate(() => {
const r = document.getElementById('margin-col').getBoundingClientRect()
const cards = [...document.querySelectorAll('.card')]
return {
box: [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.bottom)],
vh: window.innerHeight,
visibleCards: cards.filter(c => { const b = c.getBoundingClientRect(); return b.top < window.innerHeight && b.bottom > 0 }).length,
minHeightGap: document.getElementById('margin-items').style.minHeight,
}
})
assert.ok(sheet.box[3] >= sheet.vh - 1 && sheet.box[1] > 0, 'sheet is anchored to the bottom edge: ' + JSON.stringify(sheet))
assert.ok(sheet.box[2] === 390, 'sheet spans the width')
assert.ok(sheet.visibleCards >= 1, 'cards are visible inside the sheet')
assert.ok(!sheet.minHeightGap, 'no leftover desktop min-height gap')
await shot('05-comments-sheet')
console.log('✓ mobile: comments open as a bottom sheet with the cards on screen')
// tapping a comment highlight in the text opens the sheet on that card
await page.click('#margin-close')
await waitFor(async () => await page.evaluate(() => document.getElementById('margin-col').classList.contains('hidden')), 'sheet closes')
// a real tap: the reveal runs through ProseMirror's click handling
await page.evaluate(() => document.querySelector('.tiptap .comment-hl, .tiptap .sugg-ins')?.scrollIntoView({ block: 'center' }))
await page.waitForTimeout(400)
const hl = await page.locator('.tiptap .comment-hl, .tiptap .sugg-ins').first().boundingBox()
await page.mouse.click(hl.x + hl.width / 2, hl.y + hl.height / 2)
await page.waitForTimeout(600)
const revealed = await page.evaluate(() => ({
open: !document.getElementById('margin-col').classList.contains('hidden'),
expanded: !!document.querySelector('.card.expanded'),
}))
assert.ok(revealed.open, 'tapping highlighted text opens the sheet')
console.log('✓ mobile: tapping highlighted text opens the sheet' + (revealed.expanded ? ' on that card' : ''))
// selection -> Comment must open the composer inside the sheet
await page.click('#margin-close').catch(() => {})
await page.waitForTimeout(200)
await selectText(page, 'lazy dog')
await page.waitForSelector('#selection-menu:not(.hidden)')
const menuBtn = await page.evaluate(() => {
const r = document.getElementById('comment-btn').getBoundingClientRect()
return [Math.round(r.width), Math.round(r.height)]
})
assert.ok(menuBtn[1] >= 40, `selection menu buttons are touch-sized (${menuBtn})`)
await page.click('#comment-btn')
await page.waitForSelector('#composer:not(.hidden)')
await page.waitForTimeout(500) // the sheet slides up
const composer = await page.evaluate(() => {
const r = document.getElementById('composer').getBoundingClientRect()
return {
sheetOpen: !document.getElementById('margin-col').classList.contains('hidden'),
rect: [Math.round(r.top), Math.round(r.bottom), Math.round(r.width)],
vh: window.innerHeight,
scrollTop: document.getElementById('margin-items').scrollTop,
onScreen: r.top < window.innerHeight && r.bottom > 0 && r.width > 100,
fontSize: getComputedStyle(document.getElementById('composer-text')).fontSize,
}
})
assert.ok(composer.sheetOpen, 'commenting opens the sheet holding the composer')
assert.ok(composer.onScreen, 'composer is on screen: ' + JSON.stringify(composer))
assert.equal(composer.fontSize, '16px', 'inputs are 16px so iOS does not zoom on focus')
await shot('06-composer')
await page.fill('#composer-text', 'from a phone')
await page.click('#composer-send')
await waitFor(async () => (await page.textContent('#margin-items')).includes('from a phone'), 'phone comment posted')
console.log('✓ mobile: selection → comment posts from the sheet, inputs are zoom-safe')
// long agent replies are clamped with a Show more toggle
await page.evaluate(() => {
const long = 'A very long agent reply that should not be allowed to run on forever. '.repeat(40)
const thread = [...window.__ydoc.getMap('threads').values()].find(t => t.get('messages'))
thread.get('messages').push([{ id: 'long1', author: 'ui-agent', authorType: 'agent', text: long, ts: Date.now() }])
})
await page.waitForTimeout(600)
await page.evaluate(() => {
const card = [...document.querySelectorAll('.card.thread')].find(c => c.textContent.includes('long agent reply')) || document.querySelector('.card.thread')
card?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
})
await waitFor(async () => !!(await page.$('.card.expanded .more-btn')), 'clamped long comment offers Show more')
const clamp = await page.evaluate(() => {
const el = [...document.querySelectorAll('.card.expanded .msg .text')].find(n => n.textContent.includes('run on forever'))
return { clamped: el.classList.contains('clamped'), shown: Math.round(el.getBoundingClientRect().height), full: el.scrollHeight }
})
assert.ok(clamp.clamped && clamp.shown < clamp.full / 2, 'long comment is clamped: ' + JSON.stringify(clamp))
await shot('07-long-comment')
await page.click('.card.expanded .more-btn')
await page.waitForTimeout(300)
const expandedText = await page.evaluate(() => {
const el = [...document.querySelectorAll('.card.expanded .msg .text')].find(n => n.textContent.includes('run on forever'))
return { clamped: el.classList.contains('clamped'), shown: Math.round(el.getBoundingClientRect().height), label: document.querySelector('.card.expanded .more-btn').textContent }
})
assert.ok(!expandedText.clamped && expandedText.shown > clamp.shown, 'Show more reveals the rest: ' + JSON.stringify(expandedText))
assert.equal(expandedText.label, 'Show less', 'toggle flips to Show less')
console.log('✓ mobile: long comments clamp to a max height with Show more / Show less')
await browser.close()
}
async function selectText(page, needle) {
await page.evaluate(text => {
const editor = window.__editor
let found = null
editor.state.doc.descendants((node, pos) => {
if (found || !node.isText) return
const idx = node.text.indexOf(text)
if (idx !== -1) found = { from: pos + idx, to: pos + idx + text.length }
})
if (!found) throw new Error('text not found: ' + text)
editor.chain().focus().setTextSelection(found).run()
}, needle)
await page.waitForTimeout(250)
}
async function waitFor(fn, what, ms = 8000) {
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: ' + what)
}
main().catch(async err => {
console.error('\nBROWSER TEST FAILED:', err.message)
console.error('console errors:', consoleErrors)
server.kill()
process.exit(1)
})