Spaces:
Sleeping
Sleeping
| // 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 }) | |
| // Nothing will ever sync here, so the skeleton the shell ships has to come | |
| // down explicitly — left up, a first visit reads as a document stuck loading. | |
| await waitFor(async () => await page.evaluate( | |
| () => document.getElementById('doc-skeleton')?.classList.contains('hidden')), | |
| 'the skeleton comes down on a first visit with no documents') | |
| assert.ok(await page.$('.empty-doc'), 'the empty sheet says how to make the first document') | |
| await page.click('#new-doc') | |
| await page.waitForSelector('.ui-modal input') | |
| await page.fill('.ui-modal input', 'Browser Test Doc') | |
| await page.keyboard.press('Enter') | |
| 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') | |
| // close it: later assertions count the open threads on this document | |
| await page.click('.card.thread.expanded .close-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') | |
| // --- double-click a word: the comment box opens without moving the document --- | |
| // Put the word at the bottom of a short viewport, where scrollIntoView() on | |
| // the margin composer used to drag the whole page down to reveal the box. | |
| await page.setViewportSize({ width: 1440, height: 500 }) | |
| const wordPoint = await page.evaluate(() => { | |
| const needle = 'quick' | |
| const walker = document.createTreeWalker(document.querySelector('.tiptap'), NodeFilter.SHOW_TEXT) | |
| let node | |
| while ((node = walker.nextNode())) { | |
| const index = node.textContent.indexOf(needle) | |
| if (index < 0) continue | |
| const range = document.createRange() | |
| range.setStart(node, index) | |
| range.setEnd(node, index + needle.length) | |
| const rect = range.getBoundingClientRect() | |
| window.scrollBy(0, rect.bottom - (window.innerHeight - 20)) | |
| const placed = range.getBoundingClientRect() | |
| return { x: placed.left + placed.width / 2, y: placed.top + placed.height / 2 } | |
| } | |
| throw new Error('quick not found') | |
| }) | |
| await page.mouse.dblclick(wordPoint.x, wordPoint.y) | |
| await page.waitForFunction(() => !window.__editor.state.selection.empty) | |
| const scrollBeforeComposer = await page.evaluate(() => window.scrollY) | |
| 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 scrollAfterComposer = await page.evaluate(() => window.scrollY) | |
| assert.equal(scrollAfterComposer, scrollBeforeComposer, 'opening the comment box preserves document scroll') | |
| 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 }) | |
| await page.waitForSelector('.tiptap .comment-range-active', { timeout: 5000 }) | |
| const commentLocator = await page.evaluate(() => { | |
| const block = document.querySelector('.tiptap .comment-range-active') | |
| const bar = getComputedStyle(block, '::after') | |
| return { width: parseFloat(bar.width), height: parseFloat(bar.height), color: bar.backgroundColor } | |
| }) | |
| assert.ok(commentLocator.width >= 3 && commentLocator.height > 10 && commentLocator.color !== 'rgba(0, 0, 0, 0)', 'active comment has a visible block-height gutter marker: ' + JSON.stringify(commentLocator)) | |
| await page.setViewportSize({ width: 1440, height: 900 }) | |
| console.log('✓ comment card created: chip + text underline + active gutter marker 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') | |
| // close 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 .close-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') | |
| // The Space runs in an iframe where native dialogs are suppressed — the app | |
| // must never open one (everything goes through the in-app ui* helpers now). | |
| page.on('dialog', d => { | |
| throw new Error('native dialog opened: ' + d.message()) | |
| }) | |
| // 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') | |
| // closed 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('✓ closed items 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 }) | |
| 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.waitForSelector('.ui-modal-scrim', { timeout: 8000 }) | |
| assert.match(await page.textContent('.ui-modal-msg'), /not connected/i, 'accepting offline warns instead of silently failing') | |
| await page.keyboard.press('Escape') // dismiss, or the modal blocks the clicks below | |
| 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') | |
| 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') | |
| // --- the sheet is a page: fixed width, panels fold for it, zoom magnifies it --- | |
| // Regression guards for three things that used to be wrong: the sheet shrank | |
| // with the window (so the measure changed as you resized), zoom moved the type | |
| // but not the page, and nothing gave way before the document did. | |
| const geom = () => | |
| page.evaluate(() => { | |
| const col = document.getElementById('editor-col') | |
| return { | |
| sheet: Math.round(document.getElementById('editor').getBoundingClientRect().width), | |
| font: parseFloat(getComputedStyle(document.querySelector('.tiptap')).fontSize), | |
| pages: !document.getElementById('sidebar').classList.contains('hidden'), | |
| comments: !document.getElementById('margin-col').classList.contains('hidden'), | |
| pageArrow: document.getElementById('sidebar-toggle').getBoundingClientRect().width > 0, | |
| sidebarWidth: Math.round(document.getElementById('sidebar').getBoundingClientRect().width), | |
| sidebarRight: Math.round(document.getElementById('sidebar').getBoundingClientRect().right), | |
| sheetLeft: Math.round(document.getElementById('editor').getBoundingClientRect().left), | |
| sheetRight: Math.round(document.getElementById('editor').getBoundingClientRect().right), | |
| textRight: Math.round(document.getElementById('editor').getBoundingClientRect().right - | |
| parseFloat(getComputedStyle(document.getElementById('editor')).paddingRight)), | |
| commentsLeft: Math.round(document.getElementById('margin-col').getBoundingClientRect().left), | |
| appliedOverlap: Math.round(parseFloat(getComputedStyle(document.documentElement) | |
| .getPropertyValue('--margin-applied-overlap')) || 0), | |
| colScroll: col.scrollWidth - col.clientWidth, | |
| colWidth: col.clientWidth, | |
| bodyOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, | |
| } | |
| }) | |
| const resizeTo = async width => { | |
| await page.setViewportSize({ width, height: 900 }) | |
| await page.waitForTimeout(250) | |
| return geom() | |
| } | |
| const wide = await resizeTo(1440) | |
| assert.ok(wide.pages && wide.comments, 'both side panels fit at 1440') | |
| assert.ok(!wide.pageArrow, 'the Pages arrow is absent while the sidebar fits') | |
| assert.ok(Math.abs(wide.sheetLeft - wide.sidebarRight - 20) <= 2, | |
| `the sidebar fills the full gutter up to the page (${wide.sidebarRight} -> ${wide.sheetLeft})`) | |
| assert.ok(Math.abs(wide.commentsLeft - wide.sheetRight - 20) <= 2 && wide.appliedOverlap === 0, | |
| `comments stay outside the page while there is room (${wide.sheetRight} -> ${wide.commentsLeft})`) | |
| const sheetW = wide.sheet | |
| // The page stays fixed while every additional wide-screen pixel belongs to | |
| // Pages — there is no dead strip between a fixed nav and the document. | |
| const extraWide = await resizeTo(1600) | |
| assert.ok(extraWide.sidebarWidth >= wide.sidebarWidth + 150, | |
| `the sidebar grows with the gutter (${wide.sidebarWidth} -> ${extraWide.sidebarWidth})`) | |
| // Borrowing the page's right margin lets Pages survive at 1280, where it | |
| // previously disappeared even though the comments could safely move inward. | |
| const resilient = await resizeTo(1280) | |
| assert.strictEqual(resilient.sheet, sheetW, `the sheet keeps its width at 1280 (${resilient.sheet} vs ${sheetW})`) | |
| assert.ok(resilient.pages && resilient.comments, 'Pages survives at 1280 by bringing comments inward') | |
| assert.ok(!resilient.pageArrow, 'no Pages arrow while the overlapped three-column layout fits') | |
| assert.ok(resilient.appliedOverlap > 0, 'overlap appears only when the full-width columns run short') | |
| assert.ok(resilient.textRight - resilient.commentsLeft >= 18 && resilient.textRight - resilient.commentsLeft <= 30, | |
| `the maximum overlap touches only a little text (${resilient.textRight - resilient.commentsLeft}px)`) | |
| // Below that, Pages is still the first column to yield. | |
| const mid = await resizeTo(1240) | |
| assert.ok(!mid.pages, 'the Pages sidebar folds away first') | |
| assert.ok(mid.pageArrow, 'the Pages arrow appears only after the sidebar auto-collapses') | |
| assert.ok(mid.comments, 'the comments margin is still there when only the sidebar had to go') | |
| assert.equal(mid.appliedOverlap, 0, 'comments stop overlapping as soon as Pages collapses') | |
| assert.ok(mid.commentsLeft >= mid.sheetRight, 'comments return outside the page after Pages collapses') | |
| // narrower still: the comments follow, and the page is STILL the same width | |
| const tight = await resizeTo(1000) | |
| assert.strictEqual(tight.sheet, sheetW, `the sheet keeps its width at 1000 (${tight.sheet} vs ${sheetW})`) | |
| assert.ok(!tight.pages && !tight.comments, 'both columns fold before the page is touched') | |
| // growing back restores them, in reverse | |
| const back = await resizeTo(1440) | |
| assert.ok(back.pages && back.comments, 'panels come back when the room does') | |
| assert.ok(!back.pageArrow, 'the Pages arrow disappears when the sidebar returns') | |
| // zoom magnifies the page itself: sheet and type take the same factor | |
| await page.click('#zoom-in') | |
| await page.click('#zoom-in') | |
| await page.waitForTimeout(250) | |
| const zoomed = await geom() | |
| const sheetRatio = zoomed.sheet / wide.sheet | |
| const fontRatio = zoomed.font / wide.font | |
| assert.ok(zoomed.sheet > wide.sheet, `zoom widens the sheet (${wide.sheet} -> ${zoomed.sheet})`) | |
| assert.ok( | |
| Math.abs(sheetRatio - fontRatio) < 0.02, | |
| `sheet and type scale together (sheet x${sheetRatio.toFixed(3)}, type x${fontRatio.toFixed(3)})` | |
| ) | |
| // a page too wide for the window scrolls sideways INSIDE its column — the | |
| // window itself must never gain a horizontal scrollbar (that unsticks the header) | |
| const overflowing = await resizeTo(1000) | |
| assert.ok(overflowing.colScroll > 0, 'an over-wide page scrolls inside the editor column') | |
| assert.ok(overflowing.bodyOverflow <= 0, `the window itself does not scroll sideways (${overflowing.bodyOverflow}px)`) | |
| await page.click('#zoom-out') | |
| await page.click('#zoom-out') | |
| await resizeTo(1440) | |
| console.log('✓ fixed-width page: folds the side panels, zooms whole, scrolls in place') | |
| // --- the same document at every width: reflow only as late as it must --- | |
| // The document used to keep its page geometry at every width, on the grounds | |
| // that reflowing it moved the line breaks. It did — and it also made a 390px | |
| // phone show 68% of each line off-screen with no zoom that both fitted the | |
| // page and left legible type. So the sheet now goes fluid below 840px, in two | |
| // stages, and this walks the stages: | |
| // >840 nothing changes at all — the page fits, so it stays a page | |
| // ≤840 the sheet is fluid but the text column is still capped at the page's | |
| // own 624px, so the SAME words break on the SAME lines with no scroll | |
| // ≤700 a handset cannot hold 624px, so the measure and the type both give | |
| const shape = () => | |
| page.evaluate(() => { | |
| const t = document.querySelector('.tiptap') | |
| const para = [...t.querySelectorAll('p')].find(e => e.textContent.trim().length > 60) | |
| const col = document.getElementById('editor-col') | |
| return { | |
| sheet: Math.round(document.getElementById('editor').getBoundingClientRect().width), | |
| measure: Math.round(t.getBoundingClientRect().width), | |
| padLeft: Math.round(parseFloat(getComputedStyle(document.getElementById('editor')).paddingLeft)), | |
| font: getComputedStyle(t).fontSize, | |
| // a dragged table carries inline geometry that ignores the measure | |
| table: Math.round(document.querySelector('.tiptap table')?.getBoundingClientRect().width || 0), | |
| // if the lines broke differently, the paragraph and the document change height | |
| paraHeight: para ? Math.round(para.getBoundingClientRect().height) : 0, | |
| docHeight: Math.round(t.getBoundingClientRect().height), | |
| colScroll: col.scrollWidth - col.clientWidth, | |
| colWidth: col.clientWidth, | |
| windowOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, | |
| } | |
| }) | |
| // A resize lands in the renderer asynchronously and the header's own | |
| // ResizeObserver can move --hdr-h a frame later, so a fixed sleep is a | |
| // coin toss at these boundaries: read the shape until two reads agree. | |
| const shapeAfterResize = async (width, height) => { | |
| await page.setViewportSize({ width, height }) | |
| let prev = null | |
| for (let i = 0; i < 25; i++) { | |
| await page.waitForTimeout(100) | |
| const now = await shape() | |
| if (prev && now.sheet === prev.sheet && now.measure === prev.measure && now.docHeight === prev.docHeight) return now | |
| prev = now | |
| } | |
| return prev | |
| } | |
| // Put a table with a DRAGGED column in the document first. A pristine table is | |
| // width:100% and divides the measure, so it can never fail the no-scroll checks | |
| // below — the assertions were sound but pointed at content that could not fail | |
| // them. prosemirror-tables records a drag as an inline min-width on the table | |
| // and an inline width on a <col>, and that is what follows the reader onto a | |
| // phone and puts the sideways pan back. Reviewed and reproduced: 562px of table | |
| // inside a 354px measure, 190px of scroll on #editor-col. | |
| await page.click('.tiptap p') | |
| await page.keyboard.press('End') | |
| await page.click('button[title="Insert table"]') | |
| await page.waitForSelector('.tiptap table th') | |
| await page.waitForTimeout(400) | |
| const tblBorder = 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(tblBorder.x - 1, tblBorder.y) | |
| await page.waitForSelector('.tiptap .column-resize-handle') | |
| await page.mouse.down() | |
| await page.mouse.move(tblBorder.x + 200, tblBorder.y, { steps: 10 }) | |
| await page.mouse.up() | |
| await waitFor( | |
| async () => await page.evaluate(() => /min-width/.test(document.querySelector('.tiptap table').getAttribute('style') || '')), | |
| 'the drag writes an inline min-width onto the table (the thing that overflows)' | |
| ) | |
| await page.waitForTimeout(400) | |
| const onDesktop = await shapeAfterResize(1440, 900) | |
| assert.ok(onDesktop.table > 0, 'the resized table is in the document under test') | |
| // above the reflow width the page is untouched, down to the pixel | |
| for (const width of [1100, 900, 860]) { | |
| const wideEnough = await shapeAfterResize(width, 844) | |
| assert.strictEqual(wideEnough.sheet, onDesktop.sheet, `at ${width}px the page keeps its width`) | |
| assert.strictEqual(wideEnough.measure, onDesktop.measure, `at ${width}px the text column is unchanged`) | |
| assert.strictEqual(wideEnough.font, onDesktop.font, `at ${width}px the document is set at the same size`) | |
| assert.strictEqual(wideEnough.paraHeight, onDesktop.paraHeight, `at ${width}px the lines break identically`) | |
| assert.strictEqual(wideEnough.docHeight, onDesktop.docHeight, `at ${width}px nothing above or below moved`) | |
| assert.strictEqual(wideEnough.colScroll, 0, `at ${width}px the page fits, so nothing scrolls sideways`) | |
| } | |
| // 701-840: the sheet gives up being a page, and NOTHING else gives. This is | |
| // the assertion that keeps the reflow honest — a tablet is not allowed to | |
| // re-break a single line just because the canvas around the page went away. | |
| for (const width of [840, 768, 720]) { | |
| const fluid = await shapeAfterResize(width, 1024) | |
| assert.equal(fluid.sheet, fluid.colWidth, `at ${width}px the sheet is fluid, not a page (${JSON.stringify(fluid)})`) | |
| assert.ok(fluid.sheet <= width, `at ${width}px the sheet fits the window (${fluid.sheet})`) | |
| assert.strictEqual(fluid.measure, onDesktop.measure, | |
| `at ${width}px the text column is still the page's 624px (${fluid.measure})`) | |
| assert.strictEqual(fluid.font, onDesktop.font, `at ${width}px the type is untouched`) | |
| assert.strictEqual(fluid.paraHeight, onDesktop.paraHeight, `at ${width}px the lines break identically`) | |
| assert.strictEqual(fluid.colScroll, 0, `at ${width}px there is nothing left to scroll to (${fluid.colScroll})`) | |
| assert.ok(fluid.windowOverflow <= 0, `at ${width}px the window does not scroll sideways`) | |
| assert.ok(fluid.table <= fluid.measure + 1, | |
| `at ${width}px the dragged table is inside the measure (${fluid.table} in ${fluid.measure})`) | |
| } | |
| // ≤700: a handset. Now the measure and the type both move — the point of the | |
| // whole change — and the one thing that must not move is the fit. | |
| const desktopFont = parseFloat(onDesktop.font) | |
| for (const width of [700, 390, 360, 320]) { | |
| const phone = await shapeAfterResize(width, 844) | |
| assert.strictEqual(phone.sheet, phone.colWidth, `at ${width}px the sheet is exactly the column (${phone.sheet})`) | |
| assert.ok(phone.measure <= width - 32, `at ${width}px the text column fits with margins (${phone.measure})`) | |
| assert.strictEqual(phone.colScroll, 0, `at ${width}px nothing scrolls sideways (${phone.colScroll})`) | |
| // the inline geometry from a column drag is the one thing that reaches this | |
| // far and ignores the measure entirely | |
| assert.ok(phone.table <= phone.measure + 1, | |
| `at ${width}px the dragged table is inside the measure (${phone.table} in ${phone.measure})`) | |
| assert.ok(phone.windowOverflow <= 0, `at ${width}px the window does not scroll sideways`) | |
| // the legibility floor: 16px is what iOS and Android set body text at, and | |
| // what the old "use zoom to fit it" answer arrived at 7px of | |
| assert.ok(parseFloat(phone.font) >= 16, `at ${width}px body text is at least 16px (${phone.font})`) | |
| assert.ok(parseFloat(phone.font) > desktopFont, | |
| `at ${width}px the type is larger than the page's 11pt, not smaller (${phone.font})`) | |
| // margins are a proportion of a small screen now, not a printed inch | |
| assert.ok(phone.padLeft >= 16 && phone.padLeft <= width * 0.09, | |
| `at ${width}px the page margin is 16px-9% of the screen, not 24.6% (${phone.padLeft})`) | |
| } | |
| // and back: the page returns exactly as it was, so this is a view of the | |
| // document and not an edit to it | |
| // named for this block specifically: #14 (agent/reader-text-size) declares its | |
| // own `restored` in this same function scope, and git merges the two files | |
| // without a conflict into something Node then refuses to parse | |
| const restoredPage = await shapeAfterResize(1440, 900) | |
| assert.deepStrictEqual( | |
| [restoredPage.sheet, restoredPage.measure, restoredPage.font, restoredPage.paraHeight, restoredPage.docHeight], | |
| [onDesktop.sheet, onDesktop.measure, onDesktop.font, onDesktop.paraHeight, onDesktop.docHeight], | |
| 'the page comes back identical after a round trip through phone widths' | |
| ) | |
| // A rotation is how a handset crosses 840, and the zoom floor is different | |
| // either side of it. Read at apply time only, a 40% zoom set while the sheet | |
| // was a page stayed 40% after the rotation — 6.4px of body text, held until | |
| // the next reload. | |
| await shapeAfterResize(1000, 900) | |
| for (let i = 0; i < 8; i++) await page.click('#zoom-out') | |
| await page.waitForTimeout(300) | |
| assert.equal(await page.textContent('#zoom-label'), '40%', 'a fixed-width page still zooms to 40%') | |
| await shapeAfterResize(390, 844) | |
| const afterRotate = await page.evaluate(() => ({ | |
| zoom: getComputedStyle(document.documentElement).getPropertyValue('--doc-zoom').trim(), | |
| font: parseFloat(getComputedStyle(document.querySelector('.tiptap')).fontSize), | |
| })) | |
| assert.equal(afterRotate.zoom, '0.8', `rotating into the fluid column re-clamps the floor (${afterRotate.zoom})`) | |
| assert.ok(afterRotate.font >= 12, `and the type is readable rather than 6.4px (${afterRotate.font}px)`) | |
| // back to a width where the zoom buttons are in the toolbar rather than folded | |
| // into the ⋯ sheet, then put the setting back for the checks that follow | |
| await shapeAfterResize(1440, 900) | |
| for (let i = 0; i < 12 && (await page.textContent('#zoom-label')) !== '100%'; i++) await page.click('#zoom-in') | |
| await page.waitForTimeout(300) | |
| assert.equal(await page.textContent('#zoom-label'), '100%', 'zoom restored to 100%') | |
| console.log('✓ rotating across 840 re-clamps the zoom floor (40% -> 80%, not 6.4px type)') | |
| console.log('✓ reflow arrives in two stages: geometry at 840, type at 700, nothing above') | |
| // --- 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') | |
| // --- reader text size: the half of the pair that DOES reflow --- | |
| // Zoom above is guarded on "the sheet and the type take the same factor, so | |
| // the lines never move". This is the exact complement and needs its own guard, | |
| // because the failure mode is the two controls quietly becoming one: a text | |
| // size that also widened the page would leave the words-per-line alone, which | |
| // is the one thing a reader reaches for it to change. | |
| // | |
| // The document this suite has built up by now is all short lines, and a line | |
| // that never wrapped cannot re-break — so the measurement needs a paragraph | |
| // long enough to wrap. It is appended here and taken out again at the end of | |
| // the block, with the document's text asserted back to what it was. | |
| const LONG = | |
| 'A reader who asks for larger type is not asking for a larger page: the sheet should ' + | |
| 'stay exactly where it is on the screen while the words inside it find new lines, ' + | |
| 'which is the one thing zoom can never do and the whole reason this control exists ' + | |
| 'next to it.' | |
| const beforeText = await page.textContent('.tiptap') | |
| const anchor = await page.evaluate(text => { | |
| const e = window.__editor | |
| const at = e.state.doc.content.size | |
| e.chain().focus().insertContentAt(at, { type: 'paragraph', content: [{ type: 'text', text }] }).run() | |
| return at | |
| }, LONG) | |
| await page.waitForTimeout(300) | |
| const sized = () => | |
| page.evaluate(marker => { | |
| const t = document.querySelector('.tiptap') | |
| const p = [...t.querySelectorAll('p')].find(e => e.textContent.startsWith(marker)) | |
| const lh = p ? parseFloat(getComputedStyle(p).lineHeight) : 1 | |
| return { | |
| scale: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--doc-text-scale')), | |
| stored: localStorage.getItem('doc-text-scale'), | |
| label: document.getElementById('docsize-label').textContent, | |
| size: parseFloat(getComputedStyle(t).fontSize), | |
| sheet: Math.round(document.getElementById('editor').getBoundingClientRect().width), | |
| measure: Math.round(t.getBoundingClientRect().width), | |
| lines: p ? Math.round(p.getBoundingClientRect().height / lh) : 0, | |
| } | |
| }, LONG.slice(0, 30)) | |
| const base = await sized() | |
| assert.strictEqual(base.scale, 1, 'text size starts at 1x') | |
| assert.ok(base.lines >= 3, `the measured paragraph wraps to begin with (${base.lines} lines)`) | |
| assert.strictEqual(base.label, '15px', `the readout is the document's own size, in px (got ${base.label})`) | |
| for (let i = 0; i < 3; i++) await page.click('#text-larger') | |
| await page.waitForTimeout(250) | |
| const bigger = await sized() | |
| assert.ok(Math.abs(bigger.size / base.size - 1.3) < 0.01, | |
| `three steps up is 1.3x the type (${base.size} -> ${bigger.size})`) | |
| assert.strictEqual(bigger.sheet, base.sheet, `the page keeps its width (${base.sheet} -> ${bigger.sheet})`) | |
| assert.strictEqual(bigger.measure, base.measure, `and so does the text column (${base.measure} -> ${bigger.measure})`) | |
| assert.ok(bigger.lines > base.lines, `so the paragraph re-breaks onto more lines (${base.lines} -> ${bigger.lines})`) | |
| assert.strictEqual(bigger.label, '19px', `the readout follows (got ${bigger.label})`) | |
| assert.strictEqual(bigger.stored, '1.3', 'the choice is stored') | |
| // it is a factor, not a size: a style switch changes what 100% means and the | |
| // reader's own step up rides along on top of whatever the style asked for | |
| await page.click('#docstyle-btn') | |
| await waitFor(async () => (await typeset()).family === 'Libre Baskerville', 'reading style swaps in the serif') | |
| await page.waitForTimeout(250) | |
| const readingBig = await sized() | |
| assert.strictEqual(readingBig.scale, 1.3, 'the reader\'s step up survives a document style switch') | |
| assert.ok(Math.abs(readingBig.size - 16 * 1.3) < 0.05, | |
| `Reading's own 16px takes the same 1.3x (got ${readingBig.size})`) | |
| assert.strictEqual(readingBig.label, '21px', `the px readout re-reads the new style's base (got ${readingBig.label})`) | |
| assert.strictEqual(readingBig.sheet, base.sheet, 'the page is still the same width in the other style') | |
| await page.click('#docstyle-btn') | |
| await waitFor(async () => (await typeset()).family === 'Arial', 'switches back to docs') | |
| await page.waitForTimeout(250) | |
| // …and it survives a reload, like zoom and the style itself | |
| await page.reload({ waitUntil: 'domcontentloaded' }) | |
| await waitFor(async () => !!(await page.$('.tiptap p')), 'doc reloads') | |
| await page.waitForTimeout(400) | |
| const reloaded = await sized() | |
| assert.strictEqual(reloaded.scale, 1.3, 'text size persists across a reload') | |
| assert.strictEqual(reloaded.label, '19px', 'and so does the readout') | |
| // the two knobs do not touch: zoom on top of a text size still freezes the | |
| // line breaks it was given and magnifies the page whole | |
| await page.click('#zoom-in') | |
| await page.click('#zoom-in') | |
| await page.waitForTimeout(250) | |
| const zoomedToo = await sized() | |
| assert.ok(zoomedToo.sheet > reloaded.sheet, `zoom still widens the page (${reloaded.sheet} -> ${zoomedToo.sheet})`) | |
| assert.strictEqual(zoomedToo.lines, reloaded.lines, 'zoom on top of a text size does not re-break the lines') | |
| assert.strictEqual(zoomedToo.scale, 1.3, 'and does not disturb the text size') | |
| await page.click('#zoom-out') | |
| await page.click('#zoom-out') | |
| await page.waitForTimeout(250) | |
| for (let i = 0; i < 3; i++) await page.click('#text-smaller') | |
| await page.waitForTimeout(250) | |
| const restored = await sized() | |
| assert.strictEqual(restored.scale, 1, 'three steps back down returns to 1x') | |
| assert.strictEqual(restored.size, base.size, `and to the size it started at (${restored.size})`) | |
| await page.evaluate(from => { | |
| const e = window.__editor | |
| e.chain().focus().deleteRange({ from, to: e.state.doc.content.size }).run() | |
| }, anchor) | |
| await page.waitForTimeout(300) | |
| assert.strictEqual(await page.textContent('.tiptap'), beforeText, | |
| 'the measuring paragraph is taken back out, leaving the document as it was') | |
| console.log('✓ reader text size: page width holds, lines re-break, rides on top of the style, persists') | |
| // --- the toolbar always fits its row --- | |
| // Added because it did not. #header-tools scrolls with its scrollbar hidden | |
| // (app.css: scrollbar-width: none), so a toolbar too wide for its track does | |
| // not announce itself — it just ends, and the buttons past the edge are gone | |
| // with nothing on screen saying they exist. Adding the text-size pair took the | |
| // toolbar from 721px to 818px and pushed the image button off a 1280px window. | |
| // | |
| // The width the toolbar needs is not a function of the window width, which is | |
| // the whole reason this is measured rather than fixed at a breakpoint: the | |
| // title is up to 260px of the row (#doc-title max-width) and a full-length one | |
| // moves the point where it stops fitting by ~180px. So the sweep runs at both | |
| // title lengths, and asserts the header grew a row instead of clipping. | |
| const fits = () => | |
| page.evaluate(() => { | |
| const bar = document.getElementById('header-tools') | |
| const track = bar.getBoundingClientRect() | |
| const within = id => { | |
| const e = document.getElementById(id) | |
| if (!e) return null | |
| const b = e.getBoundingClientRect() | |
| return b.width > 0 && b.left >= track.left - 1 && b.right <= track.right + 1 | |
| } | |
| return { | |
| clipped: Math.max(0, bar.scrollWidth - bar.clientWidth), | |
| ownRow: document.body.classList.contains('tools-own-row'), | |
| image: within('image-btn'), | |
| smaller: within('text-smaller'), | |
| larger: within('text-larger'), | |
| label: document.getElementById('docsize-label')?.textContent, | |
| } | |
| }) | |
| const LONG_TITLE = 'Quarterly Planning and Roadmap Review Document 2026' | |
| const shortTitle = await page.textContent('#doc-title') | |
| for (const title of [shortTitle, LONG_TITLE]) { | |
| // the title is layout here, not content — set it directly rather than | |
| // renaming the document out from under every later test | |
| await page.evaluate(t => { document.getElementById('doc-title').textContent = t }, title) | |
| for (const width of [1600, 1440, 1366, 1280, 1200, 1100, 1024]) { | |
| await page.setViewportSize({ width, height: 800 }) | |
| await page.waitForTimeout(200) | |
| const f = await fits() | |
| const at = `${width}px, title "${title.slice(0, 18)}"` | |
| assert.strictEqual(f.clipped, 0, `the toolbar is not cut off at ${at} (${f.clipped}px lost)`) | |
| assert.ok(f.image, `the image button is reachable at ${at}`) | |
| assert.ok(f.smaller && f.larger, `both text-size buttons are reachable at ${at}`) | |
| assert.ok(/^\d+px$/.test(f.label), `the readout is whole, not truncated, at ${at} (got "${f.label}")`) | |
| } | |
| } | |
| // and the way it wins is by taking a row, not by shrinking anything | |
| await page.setViewportSize({ width: 1280, height: 800 }) | |
| await page.waitForTimeout(250) | |
| const narrowFit = await fits() | |
| assert.ok(narrowFit.ownRow, 'at 1280 with a long title the toolbar takes a row of its own') | |
| const headerGrew = await page.evaluate(() => ({ | |
| topbar: Math.round(document.getElementById('topbar').getBoundingClientRect().height), | |
| hdrVar: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--hdr-h')), | |
| })) | |
| assert.ok(headerGrew.topbar > 52, `the header grew a row rather than clipping (${headerGrew.topbar}px)`) | |
| assert.ok(Math.abs(headerGrew.hdrVar - headerGrew.topbar) <= 1, | |
| `--hdr-h follows it, so the drawer and popovers stay under the header (${headerGrew.hdrVar} vs ${headerGrew.topbar})`) | |
| // a wide window puts it back on one row | |
| await page.evaluate(t => { document.getElementById('doc-title').textContent = t }, shortTitle) | |
| await page.setViewportSize({ width: 1440, height: 900 }) | |
| await page.waitForTimeout(250) | |
| const roomy = await fits() | |
| assert.ok(!roomy.ownRow, 'and gives the row back when the window can hold it') | |
| assert.strictEqual(roomy.clipped, 0, 'still nothing clipped at 1440') | |
| console.log('✓ the toolbar fits its row at every desktop width, or takes a row of its own') | |
| // --- 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') | |
| // --- REGRESSION: suggestions and comments must work on a SUBPAGE too --- | |
| // Anchors are relative positions into the page's OWN fragment. The client | |
| // resolved them against 'default' — the home page's fragment — so on every | |
| // other page a suggestion's range came back null: the card appeared in the | |
| // margin, the inline preview never did, and there was nothing to review. | |
| { | |
| const target = await page.$$eval('#page-tree a', as => { | |
| const link = as.find(a => /\/d\/[^/]+\/.+/.test(a.getAttribute('href') || '')) | |
| return link ? link.getAttribute('href') : null | |
| }) | |
| assert.ok(target, 'playground has a subpage to open') | |
| await page.click(`#page-tree a[href="${target}"]`) | |
| await page.waitForURL(`**${target}`) | |
| await page.waitForSelector('.tiptap') | |
| await page.waitForTimeout(600) | |
| // baseline on THIS page: the home page has highlights of its own | |
| const before = (await page.$$('.tiptap .comment-hl')).length | |
| const posted = await page.evaluate(async () => { | |
| const [id, slug] = location.pathname.split('/d/')[1].split('/') | |
| const snap = await (await fetch(`/api/docs/${id}?page=${slug}`)).json() | |
| const idx = (snap.blocks || []).findIndex(b => !/^#/.test(b.markdown || '') && (b.markdown || '').split(/\s+/).length > 3) | |
| // keep the replacement CLOSE to the original: an unrelated rewrite renders | |
| // as an insertion panel by design, and it is the in-place word diff that | |
| // the anchor range is needed for | |
| const original = snap.blocks[idx].markdown | |
| const words = original.split(/\s+/) | |
| const swapped = words.map(w => (w === words[2] ? 'zwitschernd' : w)).join(' ') | |
| const sugg = await ( | |
| await fetch(`/api/docs/${id}/suggestions`, { | |
| method: 'POST', | |
| headers: { 'content-type': 'application/json' }, | |
| body: JSON.stringify({ page: slug, block_index: idx, replacement_markdown: swapped, rationale: 'subpage preview check' }), | |
| }) | |
| ).json() | |
| const loose = await ( | |
| await fetch(`/api/docs/${id}/threads`, { | |
| method: 'POST', | |
| headers: { 'content-type': 'application/json' }, | |
| body: JSON.stringify({ page: slug, text: 'Page-level note: this belongs to the page, not to one sentence.' }), | |
| }) | |
| ).json() | |
| return { slug, idx, sugg, loose, replaced: words[2] } | |
| }) | |
| assert.ok(posted.idx >= 0 && posted.sugg.suggestion_id, 'suggestion posted on the subpage: ' + JSON.stringify(posted)) | |
| await waitFor( | |
| async () => (await page.evaluate(() => [...document.querySelectorAll('.tiptap .sugg-ins')].map(n => n.textContent).join(' '))).includes('zwitschernd'), | |
| 'a subpage suggestion previews inline, not only as a card' | |
| ) | |
| await waitFor( | |
| async () => (await page.evaluate(() => [...document.querySelectorAll('.tiptap .sugg-del')].map(n => n.textContent).join(' '))).includes(posted.replaced), | |
| 'the replaced word is struck through on a subpage' | |
| ) | |
| // an unanchored comment: its own card, marked as page-level, marking no text | |
| assert.equal(posted.loose.anchored, false, 'thread posted without an anchor: ' + JSON.stringify(posted.loose)) | |
| await waitFor(async () => (await page.$$('.card.thread')).length > 0, 'page-level comment gets a card') | |
| await page.click('.card.thread') | |
| await waitFor(async () => !!(await page.$('.card.thread.expanded .page-level')), 'page-level comment says it is about the page') | |
| assert.equal((await page.$$('.tiptap .comment-hl')).length, before, 'a page-level comment highlights no text') | |
| console.log('✓ subpages: suggestions preview inline, page-level comments show as their own card') | |
| // back to the playground home: everything below expects the seeded page | |
| await page.click(`#page-tree a[href$="/d/${target.split('/d/')[1].split('/')[0]}"]`) | |
| await page.waitForSelector('.tiptap') | |
| await waitFor(async () => !!(await page.$('.tiptap ul[data-type="taskList"] input')), 'back on the seeded home page') | |
| } | |
| // 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 page.evaluate(() => { window.__projectCollabProbe = { provider: window.__provider, ydoc: window.__ydoc } }) | |
| 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') | |
| assert.ok(await page.evaluate(() => window.__provider === window.__projectCollabProbe.provider && window.__ydoc === window.__projectCollabProbe.ydoc), | |
| 'opening a page keeps the project collaboration connection') | |
| console.log('✓ new-page suggestion: pending → preview → accept creates the page') | |
| // --- a proposed page shows where it would land --- | |
| // Proposals used to be listed under a "proposed" heading below everything, so | |
| // an agent's placement was invisible until someone accepted it. They are drawn | |
| // in the tree at their {parent, index} instead. | |
| const placed = await page.evaluate(async () => { | |
| const id = location.pathname.split('/')[2] | |
| const post = (u, b) => fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) }).then(r => r.json()) | |
| await post(`/api/docs/${id}/structure/groups`, { label: 'Handbook' }) | |
| await post(`/api/docs/${id}/page-suggestions`, { title: 'Filed Proposal', content_markdown: '# Filed\n\nbody', parent: ['"Handbook"'], index: 0 }) | |
| return id | |
| }) | |
| await page.reload({ waitUntil: 'domcontentloaded' }) // the sidebar polls every 15s; a reload is deterministic | |
| await page.waitForSelector('#page-tree a', { timeout: 20000 }) | |
| await page.waitForFunction(() => !!window.__provider, { timeout: 20000 }) | |
| // the reload replaced the provider this run compares against later | |
| await page.evaluate(() => { window.__projectCollabProbe = { provider: window.__provider, ydoc: window.__ydoc } }) | |
| await waitFor(async () => (await page.$$eval('#page-tree .proposal-row a', as => as.map(a => a.textContent))).some(t => t.includes('Filed Proposal')), 'the proposal appears in the tree') | |
| const proposalShape = await page.evaluate(() => { | |
| const rows = [...document.querySelectorAll('#page-tree .tree-row')] | |
| const groupIdx = rows.findIndex(r => r.querySelector('.group-label')?.textContent.trim() === 'Handbook') | |
| const propIdx = rows.findIndex(r => r.classList.contains('proposal-row') && r.textContent.includes('Filed Proposal')) | |
| return { | |
| rightAfterItsGroup: propIdx === groupIdx + 1, | |
| indentedDeeper: rows[propIdx].getBoundingClientRect().left > rows[groupIdx].getBoundingClientRect().left, | |
| sectionHeadings: [...document.querySelectorAll('#page-tree .unfiled-label')].map(n => n.textContent.trim()), | |
| stillClickable: !!rows[propIdx].querySelector('a.pending-page'), | |
| } | |
| }) | |
| assert.ok(proposalShape.rightAfterItsGroup, 'the proposal sits directly under the group it was proposed for: ' + JSON.stringify(proposalShape)) | |
| assert.ok(proposalShape.indentedDeeper, 'and is indented as that group\'s child') | |
| assert.ok(!proposalShape.sectionHeadings.includes('proposed'), 'no separate "proposed" section remains: ' + JSON.stringify(proposalShape.sectionHeadings)) | |
| assert.ok(proposalShape.stillClickable, 'and it still opens its preview') | |
| console.log('✓ proposed pages render in place in the tree, not in a list below it') | |
| // --- the open page is marked by its highlight alone --- | |
| const currentRow = await page.evaluate(() => { | |
| const row = document.querySelector('#page-tree .page-row:has(a.current)') | |
| if (!row) return null | |
| const cs = getComputedStyle(row) | |
| const alpha = c => Number((c.match(/[\d.]+\)$/) || ['1)'])[0].slice(0, -1)) | |
| return { | |
| leftRuleVisible: cs.borderLeftStyle !== 'none' && parseFloat(cs.borderLeftWidth) > 0 && alpha(cs.borderLeftColor) > 0, | |
| hasTint: cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent', | |
| } | |
| }) | |
| assert.ok(currentRow, 'the current page has a row in the tree') | |
| assert.ok(!currentRow.leftRuleVisible, 'no accent rule down the left of the current row: ' + JSON.stringify(currentRow)) | |
| assert.ok(currentRow.hasTint, 'the highlight still marks it: ' + JSON.stringify(currentRow)) | |
| console.log('✓ current page: highlight only, no left accent rule') | |
| // --- download: page vs project, markdown vs pdf --- | |
| await page.click('#download-btn') | |
| await page.waitForSelector('#download-pop:not(.hidden)') | |
| const dlPanel = await page.evaluate(() => ({ | |
| scope: [...document.querySelectorAll('#dl-scope .dl-opt')].map(b => ({ label: b.textContent, on: b.classList.contains('on') })), | |
| format: [...document.querySelectorAll('#dl-format .dl-opt')].map(b => ({ label: b.textContent, on: b.classList.contains('on') })), | |
| action: document.getElementById('dl-go').textContent, | |
| })) | |
| assert.deepEqual(dlPanel.scope.map(o => o.label), ['This page', 'Whole project'], 'scope choices: ' + JSON.stringify(dlPanel)) | |
| assert.deepEqual(dlPanel.format.map(o => o.label), ['Markdown', 'PDF'], 'format choices: ' + JSON.stringify(dlPanel)) | |
| assert.ok(dlPanel.scope[0].on && dlPanel.format[0].on, 'defaults to this page as markdown: ' + JSON.stringify(dlPanel)) | |
| // markdown, this page: a real download with the server's filename | |
| const [pageZip] = await Promise.all([page.waitForEvent('download'), page.click('#dl-go')]) | |
| assert.match(pageZip.suggestedFilename(), /\.zip$/, 'page download is a zip: ' + pageZip.suggestedFilename()) | |
| // markdown, whole project | |
| await page.click('#download-btn') | |
| await page.click('#dl-scope .dl-opt:nth-child(2)') | |
| const [projZip] = await Promise.all([page.waitForEvent('download'), page.click('#dl-go')]) | |
| assert.match(projZip.suggestedFilename(), /-project\.zip$/, 'project download is named for the project: ' + projZip.suggestedFilename()) | |
| // pdf: goes through the browser's print dialog, which the test must stand in for | |
| await page.evaluate(() => { | |
| window.__print = { calls: 0, seen: null } | |
| window.print = () => { | |
| window.__print.calls++ | |
| window.__print.seen = { | |
| sheets: document.querySelectorAll('#print-view .print-page').length, | |
| printingClass: document.body.classList.contains('printing'), | |
| headings: [...document.querySelectorAll('#print-view h1, #print-view h2')].map(h => h.textContent.trim()).slice(0, 6), | |
| emptyMath: document.querySelectorAll('#print-view .math-view:empty').length, | |
| } | |
| } | |
| }) | |
| await page.click('#download-btn') | |
| await page.click('#dl-format .dl-opt:nth-child(2)') | |
| assert.match(await page.textContent('#dl-go'), /print/i, 'the action says what will happen for PDF') | |
| await page.click('#dl-go') | |
| await page.waitForFunction(() => window.__print?.calls > 0, { timeout: 30000 }) | |
| const printed = await page.evaluate(() => window.__print.seen) | |
| const pageCount = await page.evaluate(() => document.querySelectorAll('#page-tree .page-row:not(.proposal-row) a:not(.ghost-page)').length) | |
| assert.ok(printed.sheets > 1 && printed.sheets <= pageCount + 1, `every project page gets a sheet (${printed.sheets} sheets, ${pageCount} pages in the tree)`) | |
| assert.ok(printed.printingClass, 'the print-only body class is on while printing') | |
| assert.equal(printed.emptyMath, 0, 'formulas are typeset, not left as empty placeholders') | |
| // and on paper the app itself must not appear | |
| await page.emulateMedia({ media: 'print' }) | |
| const onPaper = await page.evaluate(() => { | |
| const painted = id => { const n = document.getElementById(id); if (!n) return false; const r = n.getBoundingClientRect(); return r.width > 0 && r.height > 0 } | |
| return { topbar: painted('topbar'), sidebar: painted('sidebar'), editor: painted('editor'), printView: painted('print-view') } | |
| }) | |
| await page.emulateMedia({ media: null }) | |
| assert.ok(onPaper.printView, 'the print rendition is what gets printed: ' + JSON.stringify(onPaper)) | |
| assert.ok(!onPaper.topbar && !onPaper.sidebar && !onPaper.editor, 'no app chrome on the page: ' + JSON.stringify(onPaper)) | |
| // the print view is scaffolding: it must not outlive the dialog | |
| await page.evaluate(() => window.dispatchEvent(new Event('afterprint'))) | |
| await waitFor(async () => page.evaluate(() => !document.getElementById('print-view') && !document.body.classList.contains('printing')), 'print view is torn down afterwards') | |
| console.log('✓ download: page/project × markdown/pdf, print shows the document alone') | |
| // --- the home star must not indent home's title --- | |
| // top-level real pages only: a nested page or a proposal is indented on purpose | |
| const titleXs = await page.evaluate(() => | |
| [...document.querySelectorAll('#page-tree .page-row[data-depth="0"]:not(.proposal-row)')].map(r => ({ | |
| title: r.querySelector('a')?.textContent.trim(), | |
| left: Math.round(r.querySelector('a')?.getBoundingClientRect().left), | |
| star: !!r.querySelector('.home-star'), | |
| })) | |
| ) | |
| const starred = titleXs.find(t => t.star) | |
| assert.ok(starred, 'the front page still carries its star: ' + JSON.stringify(titleXs)) | |
| // and it must not land on the caret when home has children of its own | |
| const starClash = await page.evaluate(() => { | |
| const row = document.querySelector('#page-tree .page-row:has(.home-star)') | |
| const star = row?.querySelector('.home-star') | |
| const caret = row?.querySelector('.tree-caret') | |
| if (!star || !caret) return { bothPresent: false } | |
| const l = n => n.getBoundingClientRect().left | |
| return { bothPresent: true, apart: Math.abs(l(star) - l(caret)) >= 12 } | |
| }) | |
| if (starClash.bothPresent) assert.ok(starClash.apart, 'star and caret do not sit on top of each other: ' + JSON.stringify(starClash)) | |
| const others = titleXs.filter(t => !t.star) | |
| assert.ok(others.length > 0, 'there are sibling pages to compare against: ' + JSON.stringify(titleXs)) | |
| for (const other of others) { | |
| assert.strictEqual(starred.left, other.left, `"${starred.title}" starts where "${other.title}" does: ` + JSON.stringify(titleXs)) | |
| } | |
| console.log('✓ the star sits in the gutter; every sidebar title starts at the same x') | |
| // 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') | |
| assert.ok(await page.evaluate(() => window.__provider === window.__projectCollabProbe.provider && window.__ydoc === window.__projectCollabProbe.ydoc), | |
| 'returning home keeps the same project collaboration connection') | |
| console.log('✓ page switches reuse one synced project document') | |
| // --- search: page text, not just titles ------------------------------------- | |
| // The sidebar box indexes every page of the project out of the one synced Yjs | |
| // document, so "suggestion" (which is in the prose, not in any title) has to | |
| // find home, list each occurrence with the heading it sits under, and mark | |
| // them in the open document. | |
| const playgroundHome = page.url() | |
| // hits under one page's row — by default the open one, whose marks we can also | |
| // count in the document; '*' for every page, since more than one of them | |
| // mentions "suggestion" | |
| const hitRows = (scope = 'li:has(> .page-row > a.current)') => | |
| page.$$eval(`#page-tree ${scope} > .tree-hits .tree-hit:not(.more)`, rs => rs.map(r => ({ | |
| heading: r.querySelector('.tree-hit-sec')?.textContent || '', | |
| snippet: r.querySelector('.tree-hit-text')?.textContent || '', | |
| href: r.getAttribute('href'), | |
| }))) | |
| await page.fill('#sidebar-search', 'suggestion') | |
| await waitFor(async () => (await hitRows()).length > 0, 'typing text finds hits inside a page body') | |
| const suggestionHits = await hitRows() | |
| const marked = await page.$$eval('#editor mark.search-hl', ms => ms.map(m => m.textContent)) | |
| assert.ok(marked.length >= 5, `every occurrence is marked in the document (${marked.length}): ` + JSON.stringify(marked)) | |
| assert.ok(marked.every(t => t.toLowerCase() === 'suggestion'), 'marks cover the query and nothing else: ' + JSON.stringify(marked)) | |
| // the pill is the page's TRUE count, which is more than the snippets listed | |
| const trueCount = Number(await page.$eval('#page-tree .page-row:has(a.current) .tree-hit-count', n => n.textContent)) | |
| assert.strictEqual(trueCount, marked.length, `the count on the row is the real number of hits (${trueCount} vs ${marked.length} marks)`) | |
| assert.ok(suggestionHits.every(h => /suggestion/i.test(h.snippet)), 'each snippet quotes the match in context: ' + JSON.stringify(suggestionHits)) | |
| assert.ok(suggestionHits.some(h => h.heading === 'Checklist') && suggestionHits.some(h => h.heading === 'Table'), | |
| 'each hit is tagged with the heading it sits under: ' + JSON.stringify(suggestionHits.map(h => h.heading))) | |
| // hits are links: /d/<id>/<page>?q=<term>&n=<hit>, shareable and openable in a tab | |
| assert.match(suggestionHits[0].href, /\?q=suggestion$/, 'the first hit links to the plain query: ' + suggestionHits[0].href) | |
| assert.match(suggestionHits[1].href, /\?q=suggestion&n=1$/, 'later hits carry their ordinal: ' + suggestionHits[1].href) | |
| // Clicking a hit is a place you can come back from, on the open page as much as | |
| // on another one — typing the query is what must not litter the history, not | |
| // deciding to go and look at something. | |
| await page.click('#page-tree .tree-hits .tree-hit:nth-child(2)') | |
| await page.waitForURL(u => /\?q=suggestion&n=1$/.test(u.toString()), { timeout: 15000 }) | |
| await page.evaluate(() => history.back()) | |
| await page.waitForURL(u => /\?q=suggestion$/.test(u.toString()), { timeout: 15000 }) | |
| assert.ok(await page.$('#editor mark.search-hl'), 'going back from a hit keeps the search running') | |
| // a common word overruns the per-page snippet list, and the ones it did not | |
| // print are still counted — the pill and the "+n more" row have to add up | |
| await page.fill('#sidebar-search', 'the') | |
| await waitFor(async () => !!(await page.$('#page-tree .tree-hit.more')), 'a common word overruns the snippet list') | |
| const capped = await page.evaluate(() => { | |
| const li = document.querySelector('#page-tree li:has(> .page-row > a.current)') | |
| return { | |
| count: Number(li.querySelector('.tree-hit-count').textContent), | |
| listed: li.querySelectorAll(':scope > .tree-hits .tree-hit:not(.more)').length, | |
| more: li.querySelector(':scope > .tree-hits .tree-hit.more').textContent, | |
| marks: document.querySelectorAll('#editor mark.search-hl').length, | |
| } | |
| }) | |
| assert.ok(capped.listed < capped.count, `the snippet list is capped (${capped.listed}) where the count is not (${capped.count})`) | |
| assert.strictEqual(capped.count, capped.marks, 'the count still matches what is marked in the document: ' + JSON.stringify(capped)) | |
| assert.strictEqual(capped.more, `+${capped.count - capped.listed} more matches`, 'the unlisted hits are accounted for: ' + JSON.stringify(capped)) | |
| // Two occurrences in one sentence produce the same excerpt, and each must bold | |
| // its OWN one — re-finding the query inside the excerpt would bold the first | |
| // occurrence twice and send the second hit to the first hit's words. | |
| const bolded = await page.$$eval('#page-tree .tree-hit-text', ts => ts.map(t => ({ | |
| text: t.textContent, | |
| at: t.firstChild.textContent.length, // the excerpt is [before, <b>, after] | |
| }))) | |
| const twins = bolded.filter(a => bolded.some(b => b !== a && b.text === a.text)) | |
| assert.ok(twins.length >= 2, 'the fixture has two hits sharing one excerpt: ' + JSON.stringify(bolded.map(b => b.text))) | |
| assert.ok(new Set(twins.map(t => t.at)).size >= 2, 'they bold their own occurrence, not the same one twice: ' + JSON.stringify(twins)) | |
| console.log('✓ search: body hits with per-hit headings, true counts, in-document marks') | |
| // a title match bolds the matched part of the title and stays quiet otherwise: | |
| // there is no body hit to list for it | |
| await page.fill('#sidebar-search', 'note') | |
| await waitFor(async () => !!(await page.$('#page-tree .page-row a b')), 'a title query bolds what it matched') | |
| const titleMatches = await page.$$eval('#page-tree .page-row a:has(b)', as => as.map(a => ({ | |
| title: a.querySelector('.tree-title')?.textContent, | |
| bold: a.querySelector('b')?.textContent, | |
| }))) | |
| assert.ok(titleMatches.some(m => m.title === 'Notes' && m.bold === 'Note'), | |
| 'the matched part of the title is bolded: ' + JSON.stringify(titleMatches)) | |
| // a query that only exists on another page pulls that page up, and clicking the | |
| // hit navigates there and scrolls to it | |
| await page.fill('#sidebar-search', 'defines the tree') | |
| await waitFor(async () => (await hitRows('*')).length === 1, 'a phrase only on the Notes page finds only that page') | |
| assert.deepStrictEqual( | |
| await page.$$eval('#page-tree .page-row a .tree-title', ts => ts.map(t => t.textContent.trim())), | |
| ['Notes'], | |
| 'the tree is filtered down to the page that contains the phrase' | |
| ) | |
| await page.click('#page-tree .tree-hit') | |
| await page.waitForURL(u => /\/notes\?q=/.test(u.toString()), { timeout: 15000 }) | |
| await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 1, 'the hit is marked on the page it navigated to') | |
| const onScreen = await page.evaluate(() => { | |
| const m = document.querySelector('#editor mark.search-hl') | |
| const r = m.getBoundingClientRect() | |
| return r.top > 0 && r.bottom < window.innerHeight | |
| }) | |
| assert.ok(onScreen, 'the clicked hit is scrolled into view') | |
| console.log('✓ search: clicking a hit on another page navigates and lands on it') | |
| // ?q= is a deep link: it starts the search, marks the document and jumps to the | |
| // named hit — this is what the hrefs above resolve to for someone you send one to | |
| await page.goto(`${playgroundHome}?q=greet`, { waitUntil: 'domcontentloaded' }) | |
| await page.waitForSelector('.tiptap') | |
| await waitFor(async () => (await page.inputValue('#sidebar-search')) === 'greet', 'a ?q= link arrives with the search box filled in') | |
| await waitFor(async () => (await page.$$('#editor mark.search-hl')).length > 0, 'a ?q= link marks the document once it has synced') | |
| // the hit list can only fill in once the socket has synced the text, which on a | |
| // cold load lands after the shell has already painted the tree | |
| await waitFor(async () => JSON.stringify((await hitRows()).map(h => h.heading)) === '["Code"]', | |
| 'the deep-linked hit is listed under its heading') | |
| await waitFor( | |
| async () => page.evaluate(() => { | |
| const r = document.querySelector('#editor mark.search-hl').getBoundingClientRect() | |
| return r.top > 0 && r.bottom < window.innerHeight | |
| }), | |
| 'a ?q= link scrolls its first hit into view' | |
| ) | |
| // `n=` is whatever the URL said, so it can name a hit that does not exist. That | |
| // must not leave the reader on an unscrolled page while the frame budget drains | |
| // (~10s of it) — once the marks stop arriving, the last hit is the answer. | |
| await page.goto(`${playgroundHome}?q=greet&n=99999`, { waitUntil: 'domcontentloaded' }) | |
| await page.waitForSelector('.tiptap') | |
| await waitFor( | |
| async () => page.evaluate(() => { | |
| const ms = document.querySelectorAll('#editor mark.search-hl') | |
| if (!ms.length) return false | |
| const r = ms[ms.length - 1].getBoundingClientRect() | |
| return r.top > 0 && r.bottom < window.innerHeight | |
| }), | |
| 'an out-of-range n= settles on the last hit instead of spinning', | |
| 6000 | |
| ) | |
| // The index is the live CRDT, not a snapshot the server took: text typed now is | |
| // searchable now. Done on Notes, which carries none of home's seeded | |
| // suggestions — typing into a block one of those is anchored to is a different | |
| // test, and not this one's business. | |
| await page.goto(`${playgroundHome}/notes`, { waitUntil: 'domcontentloaded' }) | |
| await page.waitForSelector('.tiptap') | |
| await page.fill('#sidebar-search', 'quokka') | |
| await waitFor(async () => !!(await page.$('#page-tree .tree-empty')), 'a query that matches nothing says so instead of emptying the sidebar') | |
| await page.evaluate(() => window.__editor.chain().focus('end').insertContent(' quokka ').run()) | |
| await waitFor(async () => (await hitRows()).length === 1, 'text typed while the search is running becomes a hit') | |
| await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 1, 'and is marked as it is typed') | |
| await page.evaluate(() => window.__editor.commands.undo()) | |
| await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 0, 'undoing the text takes its hit away again') | |
| // The invariant both walks rest on: a non-text inline node counts as exactly | |
| // one character, because that is what it costs in a ProseMirror position. Get | |
| // that wrong and every mark after an atom in the same block slides left by one | |
| // per atom — so this paragraph puts a mark AND a formula ahead of the hit. | |
| // Nothing in the seeded fixture does (its images are their own blocks), which | |
| // is why this is built here rather than searched for. Built as explicit JSON | |
| // because the markdown seeder does not parse inline `$…$` — it survives as | |
| // literal text, and literal text would not test anything: a marked text node | |
| // still costs one position per character, so only a real atom bites. | |
| await page.evaluate(() => | |
| window.__editor | |
| .chain() | |
| .focus('end') | |
| .insertContent({ | |
| type: 'paragraph', | |
| content: [ | |
| { type: 'text', marks: [{ type: 'bold' }], text: 'Bold' }, | |
| { type: 'text', text: ' and ' }, | |
| { type: 'mathInline', attrs: { latex: 'x^2' } }, | |
| { type: 'text', text: ' then wombat here, but wom' }, | |
| { type: 'mathInline', attrs: { latex: 'y' } }, | |
| { type: 'text', text: 'bat is two words with a formula in it.' }, | |
| ], | |
| }) | |
| .run() | |
| ) | |
| await page.waitForSelector('.tiptap .math-inline', { timeout: 5000 }) | |
| await page.fill('#sidebar-search', 'wombat') | |
| await waitFor(async () => (await page.$$('#editor mark.search-hl')).length > 0, 'a hit sitting after an atom is still marked') | |
| // Two things at once, and each fails on a different slip. The count: a formula | |
| // interrupts a word, so `wom`+formula+`bat` is NOT the query — count the atom | |
| // as zero characters and it becomes a spurious hit the sidebar never counted. | |
| // The text: the mark has to land ON the query, which is what the position | |
| // arithmetic after two atoms is for. | |
| const searchAtomHit = await page.evaluate(() => ({ | |
| marks: [...document.querySelectorAll('#editor mark.search-hl')].map(m => m.textContent), | |
| pill: Number(document.querySelector('#page-tree .page-row:has(a.current) .tree-hit-count')?.textContent || 1), | |
| })) | |
| assert.deepStrictEqual(searchAtomHit.marks, ['wombat'], | |
| 'an atom is one unmatchable character: the query is marked once, exactly, and does not span a formula: ' + JSON.stringify(searchAtomHit)) | |
| assert.strictEqual(searchAtomHit.pill, searchAtomHit.marks.length, | |
| 'and the sidebar counted the same hits the document marked: ' + JSON.stringify(searchAtomHit)) | |
| // put the page back for the sub-tests after this one (seeded text predates this | |
| // session, so it is not in the undo history and cannot be walked back by mistake) | |
| await page.evaluate(() => { for (let i = 0; i < 60; i++) window.__editor.commands.undo() }) | |
| await waitFor(async () => !(await page.$('.tiptap .math-inline')), 'the probe paragraph is undone again') | |
| assert.ok(!(await page.textContent('.tiptap')).includes('wombat'), 'and takes its text with it') | |
| // clearing puts everything back, including drag-and-drop | |
| await page.click('#sidebar-search-key') | |
| await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 0, 'clearing the box drops the marks') | |
| assert.strictEqual(await page.inputValue('#sidebar-search'), '', 'the ✕ badge empties the box') | |
| assert.ok(!/\?q=/.test(page.url()), 'and takes ?q= out of the URL: ' + page.url()) | |
| assert.ok(await page.evaluate(() => document.querySelectorAll('#page-tree [data-td-row]').length > 0), 'rows are draggable again') | |
| console.log('✓ search: ?q= deep links, live index, empty state, clear restores the tree') | |
| // back where the rest of the playground sub-tests expect to be | |
| await page.goto(playgroundHome, { waitUntil: 'domcontentloaded' }) | |
| await waitFor(async () => (await page.$$('.card.suggestion')).length >= 1, 'back on the playground home page') | |
| // 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 ›') | |
| // Feedback derived from one request stays as standalone cards. Focusing the | |
| // source comment highlights every related card and its inline change. | |
| const family = await page.evaluate(() => { | |
| const origin = [...document.querySelectorAll('.card.thread')].find(c => c.textContent.includes('Can we end on something stronger?')) | |
| const related = origin && document.querySelector(`.card.suggestion[data-origin="${origin.dataset.item}"]`) | |
| return { origin: origin?.dataset.item || null, related: related?.dataset.item || null } | |
| }) | |
| assert.ok(family.origin && family.related, 'origin and related suggestion are separate cards: ' + JSON.stringify(family)) | |
| const relatedCardLayout = await page.evaluate(id => { | |
| const card = document.querySelector(`.card.suggestion[data-item="${id}"]`) | |
| const head = card.querySelector('.head') | |
| const cardRect = card.getBoundingClientRect() | |
| const actionsRect = card.querySelector('.head-actions').getBoundingClientRect() | |
| return { | |
| head: [head.clientWidth, head.scrollWidth], | |
| cardRight: Math.round(cardRect.right), | |
| actionsRight: Math.round(actionsRect.right), | |
| } | |
| }, family.related) | |
| assert.ok(relatedCardLayout.head[1] <= relatedCardLayout.head[0] + 1, 'suggestion header does not overflow: ' + JSON.stringify(relatedCardLayout)) | |
| assert.ok(relatedCardLayout.actionsRight <= relatedCardLayout.cardRight, 'suggestion actions remain inside the card: ' + JSON.stringify(relatedCardLayout)) | |
| await page.click(`.card.thread[data-item="${family.origin}"]`) | |
| await waitFor(async () => !!(await page.$(`.card.suggestion[data-item="${family.related}"].origin-related`)), 'related suggestion highlighted from origin') | |
| assert.ok(await page.$(`.tiptap .suggestion-hl[data-sugg="${family.related}"].active`), 'related inline change highlighted from origin') | |
| // Closing the source must not sever the trail. The derived card names the | |
| // closed source and can reveal/focus it again through the existing resolved | |
| // items view. | |
| await page.click(`.card.thread[data-item="${family.origin}"] .close-btn`) | |
| await waitFor(async () => await page.evaluate(id => { | |
| const card = document.querySelector(`.card.thread[data-item="${id}"]`) | |
| return !card || card.textContent.includes('closed') | |
| }, family.origin), 'origin closes') | |
| await waitFor(async () => (await page.textContent(`.card.suggestion[data-item="${family.related}"] .origin-link`)).includes('closed'), 'derived card identifies its closed origin') | |
| await page.click(`.card.suggestion[data-item="${family.related}"] .origin-link`) | |
| await waitFor(async () => !!(await page.$(`.card.thread[data-item="${family.origin}"].expanded`)), 'closed origin revealed and focused') | |
| assert.ok(await page.$(`.card.suggestion[data-item="${family.related}"].origin-related`), 'related card remains highlighted for a closed origin') | |
| // Restore the seeded source so the independent close-button regression below | |
| // still has an open comment to exercise. | |
| await page.evaluate(id => window.__ydoc.getMap('threads').get(id).set('resolved', false), family.origin) | |
| await waitFor(async () => !!(await page.$(`.card.thread[data-item="${family.origin}"] .close-btn`)), 'origin restored for remaining playground checks') | |
| await page.click(`.card.suggestion[data-item="${family.related}"] .accept-btn`) | |
| await waitFor(async () => (await page.textContent('.tiptap')).includes('go break things'), 'standalone accept applies') | |
| console.log('✓ standalone feedback cards retain and highlight their common origin') | |
| // comment closable without opening the card | |
| assert.equal(await page.textContent('.card.thread .close-btn'), 'Close', 'collapsed thread has a Close button') | |
| await page.click('.card.thread .close-btn') | |
| await waitFor(async () => !(await page.$('.card.thread:not(.expanded) .close-btn')), 'thread closed from collapsed head') | |
| console.log('✓ comment closes 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)') | |
| // the structure page left the sidebar but stays reachable by URL | |
| assert.ok(!(await page.$('#structure-link')), 'no structure link in the sidebar') | |
| { | |
| const docPath = await page.evaluate(() => location.pathname.split('/').slice(0, 3).join('/')) | |
| await page.goto(BASE + docPath + '/_structure') | |
| await page.waitForSelector('#structure-hint', { timeout: 8000 }) | |
| await page.goBack() | |
| await page.waitForSelector('.tiptap', { timeout: 8000 }) | |
| } | |
| // [[ 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') | |
| // --- a link you can actually click --- | |
| // In an editor a plain click has to place the caret, so following a link | |
| // needed ctrl/cmd+click — with nothing on screen saying so, a cross-reference | |
| // just looked broken. A plain click now raises a bubble naming the target, | |
| // with an Open button for a mouse to press. | |
| const linkSel = '.tiptap a[href$="/notes"]' | |
| await page.click(linkSel) | |
| await waitFor(async () => !(await page.evaluate(() => document.getElementById('link-bubble').classList.contains('hidden'))), 'a plain click raises the link bubble') | |
| const bubble = await page.evaluate(() => { | |
| const b = document.getElementById('link-bubble') | |
| const link = document.querySelector('.tiptap a[href$="/notes"]').getBoundingClientRect() | |
| const box = b.getBoundingClientRect() | |
| return { | |
| target: b.querySelector('.lb-target').textContent, | |
| actions: [...b.querySelectorAll('button')].map(x => x.textContent.trim() || x.dataset.tip), | |
| nearTheLink: Math.abs(box.left - link.left) < 80 && box.top > link.top, | |
| onScreen: box.left >= 0 && box.right <= innerWidth && box.bottom <= innerHeight, | |
| } | |
| }) | |
| assert.strictEqual(bubble.target, 'Notes', 'the bubble names the page it goes to, not a raw URL') | |
| assert.ok(bubble.actions.includes('Open'), 'and offers Open: ' + JSON.stringify(bubble.actions)) | |
| assert.ok(bubble.nearTheLink && bubble.onScreen, 'anchored to the link, inside the window: ' + JSON.stringify(bubble)) | |
| const urlBeforeOpen = page.url() | |
| await page.click('#link-bubble .btn') | |
| await waitFor(async () => page.url().endsWith('/notes'), 'Open follows the link') | |
| assert.notStrictEqual(page.url(), urlBeforeOpen, 'and it actually moved') | |
| // ctrl+click must keep working for anyone who already knows it | |
| await page.goBack() | |
| await page.waitForSelector('.tiptap', { timeout: 10000 }) | |
| await page.waitForTimeout(800) | |
| await page.click(linkSel, { modifiers: ['Control'] }) | |
| await waitFor(async () => page.url().endsWith('/notes'), 'ctrl+click still goes straight there') | |
| await page.goBack() | |
| await page.waitForSelector('.tiptap', { timeout: 10000 }) | |
| await page.waitForTimeout(800) | |
| console.log('✓ link bubble: a plain click offers Open, ctrl+click still follows') | |
| // --- what you are commenting on stays marked while you type about it --- | |
| // Focusing the composer moves focus out of the editor, which drops the | |
| // browser's own selection highlight: the text you picked went unmarked while | |
| // the box asked you about it. | |
| await focusDocEnd(page) | |
| await page.keyboard.press('Enter') | |
| await page.keyboard.type('A line worth discussing at some length.') | |
| await page.waitForTimeout(400) | |
| const pickLine = () => page.evaluate(() => { | |
| const target = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('A line worth discussing')) | |
| const pos = window.__editor.view.posAtDOM(target, 0) | |
| window.__editor.commands.setTextSelection({ from: pos, to: pos + target.textContent.length }) | |
| }) | |
| await pickLine() | |
| await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 }) | |
| await page.click('#composer-text') | |
| await waitFor(async () => page.evaluate(() => document.querySelectorAll('.tiptap .pending-hl').length === 1), 'the pending range is marked once the composer has focus') | |
| const pending = await page.evaluate(() => { | |
| const n = document.querySelector('.tiptap .pending-hl') | |
| const cs = getComputedStyle(n) | |
| return { text: n.textContent, bg: cs.backgroundColor, style: cs.borderBottomStyle, nativeSelectionGone: getSelection().isCollapsed } | |
| }) | |
| assert.ok(pending.text.startsWith('A line worth discussing'), 'it marks the text the composer is about: ' + JSON.stringify(pending)) | |
| assert.notStrictEqual(pending.bg, 'rgba(0, 0, 0, 0)', 'and it is actually visible: ' + JSON.stringify(pending)) | |
| assert.strictEqual(pending.style, 'dashed', 'dashed, to read as not-yet-committed: ' + JSON.stringify(pending)) | |
| // and it is scaffolding: cancelling must not leave it behind | |
| await page.evaluate(() => [...document.querySelectorAll('#composer button')].find(b => /cancel/i.test(b.textContent))?.click()) | |
| await waitFor(async () => page.evaluate(() => document.querySelectorAll('.tiptap .pending-hl').length === 0), 'cancelling clears the mark') | |
| console.log('✓ composer marks the text it is about, and clears it on cancel') | |
| // --- a comment and a suggestion on the same words --- | |
| // Two filled highlights over one range cancelled each other out: the | |
| // suggestion's insert/delete spans were invisible under the comment tint. | |
| // the box opens when the selection CHANGES, so move the caret before picking | |
| // the same line again | |
| await focusDocEnd(page) | |
| await page.waitForTimeout(200) | |
| await pickLine() | |
| await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 }) | |
| await page.fill('#composer-text', 'can this be tightened?') | |
| await page.click('#composer .primary, #composer button:has-text("Comment")') | |
| await page.waitForSelector('.card.thread', { timeout: 10000 }) | |
| await page.click('.card.thread') // active, as it is right after commenting | |
| await page.waitForTimeout(300) | |
| 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('A line worth discussing')) | |
| await fetch(`/api/docs/${id}/suggestions`, { | |
| method: 'POST', | |
| headers: { 'content-type': 'application/json' }, | |
| body: JSON.stringify({ block_index: idx, replacement_markdown: 'A line worth debating at some length.', rationale: 'sharper verb' }), | |
| }) | |
| }) | |
| await waitFor(async () => page.evaluate(() => document.querySelectorAll('.tiptap .sugg-ins').length > 0), 'the suggestion reaches the document') | |
| const overlap = await page.evaluate(() => { | |
| const opaque = c => c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent' | |
| const commentHl = [...document.querySelectorAll('.tiptap .comment-hl')] | |
| const ins = [...document.querySelectorAll('.tiptap .sugg-ins')] | |
| return { | |
| commentMarked: commentHl.every(n => n.classList.contains('with-sugg')), | |
| commentUnderlined: commentHl.every(n => ['dotted', 'dashed', 'solid'].includes(getComputedStyle(n).borderBottomStyle)), | |
| commentFilled: commentHl.some(n => opaque(getComputedStyle(n).backgroundColor)), | |
| insFilled: ins.length > 0 && ins.every(n => opaque(getComputedStyle(n).backgroundColor)), | |
| insText: ins.map(n => n.textContent.trim()), | |
| } | |
| }) | |
| assert.ok(overlap.commentMarked, 'the comment knows it is sharing text with a suggestion: ' + JSON.stringify(overlap)) | |
| assert.ok(!overlap.commentFilled, 'so it gives up its fill: ' + JSON.stringify(overlap)) | |
| assert.ok(overlap.commentUnderlined, 'but stays marked by its underline: ' + JSON.stringify(overlap)) | |
| assert.ok(overlap.insFilled, 'and the inserted words keep a visible fill of their own: ' + JSON.stringify(overlap)) | |
| console.log('✓ a suggestion stays visible on text that already carries a comment') | |
| // --- hover the right of the page: a comment anchored to a place, not a span --- | |
| // The Google-Docs affordance. Everything here is about it staying out of the | |
| // way: no gutter, nothing visible until the pointer is in the right third, | |
| // and nothing at all while a selection owns the comment flow. | |
| // Clear the margin first. Cards are stacked, never overlapped: a card whose | |
| // anchor is already occupied gets pushed down the panel, and the alignment this | |
| // feature promises could not be measured through the pile the tests above left. | |
| // (The playground reset further down puts the seeded cards back.) | |
| await waitFor( | |
| async () => | |
| (await page.evaluate(() => { | |
| document.querySelectorAll('#margin-items .close-btn, #margin-items .reject-btn').forEach(b => b.click()) | |
| return document.querySelectorAll('#margin-items .card').length | |
| })) === 0, | |
| 'margin cleared for the hover checks', | |
| 20000 | |
| ) | |
| // appended through the editor rather than typed, so it is definitely the LAST | |
| // block on the page — which is what makes the deletion case below deterministic | |
| await page.evaluate(() => { | |
| const e = window.__editor | |
| e.commands.insertContentAt(e.state.doc.content.size, { | |
| type: 'paragraph', | |
| content: [{ type: 'text', text: 'Bees navigate by polarised light, which is the part nobody expects.' }], | |
| }) | |
| }) | |
| await page.waitForTimeout(400) | |
| const hlBefore = (await page.$$('.tiptap .comment-hl')).length | |
| // measure fresh each time — typing at the end of the document scrolls | |
| const beeGeom = () => | |
| page.evaluate(() => { | |
| const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Bees navigate')) | |
| p.scrollIntoView({ block: 'center' }) | |
| const r = p.getBoundingClientRect() | |
| const sheet = document.getElementById('editor').getBoundingClientRect() | |
| const text = document.querySelector('.tiptap').getBoundingClientRect() | |
| return { top: r.top, mid: r.top + r.height / 2, sheet, textRight: text.right } | |
| }) | |
| let g = await beeGeom() | |
| const hoverBtn = () => | |
| page.evaluate(() => { | |
| const b = document.getElementById('hover-comment') | |
| const r = b.getBoundingClientRect() | |
| return { hidden: b.classList.contains('hidden'), left: r.left, right: r.right, mid: r.top + r.height / 2 } | |
| }) | |
| // arming the zone must not move the page: the button lives in the page's own | |
| // right margin, absolutely positioned, so nothing reflows | |
| const widthBefore = await page.evaluate(() => document.getElementById('editor').getBoundingClientRect().width) | |
| await page.mouse.move(g.sheet.left + g.sheet.width * 0.2, g.mid) | |
| await page.waitForTimeout(120) | |
| assert.ok((await hoverBtn()).hidden, 'no button while the pointer is in the left two thirds') | |
| await page.mouse.move(g.sheet.left + g.sheet.width * 0.85, g.mid) | |
| await waitFor(async () => !(await hoverBtn()).hidden, 'the right third reveals the comment button') | |
| const btn = await hoverBtn() | |
| const widthAfter = await page.evaluate(() => document.getElementById('editor').getBoundingClientRect().width) | |
| assert.equal(widthAfter, widthBefore, 'revealing it does not add a gutter') | |
| assert.ok(btn.left > g.textRight, `it sits out in the page margin, clear of the text (${btn.left} > ${g.textRight})`) | |
| assert.ok(btn.right < g.sheet.right, 'and still on the paper, not off its edge') | |
| assert.ok(Math.abs(btn.mid - (g.top + 9)) < 14, `level with the paragraph it is beside (btn ${btn.mid}, para top ${g.top})`) | |
| // A live selection owns the comment flow, and TWO separate rules enforce that. | |
| // They have to be checked separately: the button is armed by a real pointer move | |
| // above, and then nothing but the selection changes, so the first assertion can | |
| // only be satisfied by the selectionUpdate drop in bindSelectionMenu. Dispatching | |
| // a mousemove first — which is what this test used to do — re-enters | |
| // updateHoverComment and is caught by its own empty-selection bail instead, so | |
| // the keyboard rule went uncovered and deleting it left the suite green. | |
| // Both run inside the 220ms settle, before the selection composer opens, so | |
| // neither is really testing "a box is open". | |
| const hoverSelRules = await page.evaluate(async () => { | |
| const btnHidden = () => document.getElementById('hover-comment').classList.contains('hidden') | |
| const frame = () => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done))) | |
| const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Bees navigate')) | |
| const armed = !btnHidden() // the pointer move above really did arm it | |
| // rule 1: a keyboard selection, with no pointer event anywhere near it | |
| const pos = window.__editor.view.posAtDOM(p, 0) | |
| window.__editor.commands.setTextSelection({ from: pos, to: pos + 12 }) | |
| await frame() | |
| const afterKeyboardSelection = btnHidden() | |
| // rule 2: a pointer event arriving while that selection still stands must not | |
| // re-arm it — the drag that ends in the right third | |
| const sheet = document.getElementById('editor').getBoundingClientRect() | |
| const r = p.getBoundingClientRect() | |
| document.getElementById('editor-col').dispatchEvent( | |
| new MouseEvent('mousemove', { clientX: sheet.left + sheet.width * 0.85, clientY: r.top + r.height / 2, bubbles: true }) | |
| ) | |
| await frame() | |
| return { | |
| armed, | |
| afterKeyboardSelection, | |
| afterPointerDuringSelection: btnHidden(), | |
| composerOpen: !document.getElementById('composer').classList.contains('hidden'), | |
| } | |
| }) | |
| assert.ok(hoverSelRules.armed, 'the pointer had really armed the button first: ' + JSON.stringify(hoverSelRules)) | |
| assert.ok(hoverSelRules.afterKeyboardSelection, 'a keyboard selection drops it with no pointer event to notice: ' + JSON.stringify(hoverSelRules)) | |
| assert.ok(hoverSelRules.afterPointerDuringSelection, 'and a pointer move cannot re-arm it while the selection stands: ' + JSON.stringify(hoverSelRules)) | |
| // and it stays away over the box that selection opens | |
| await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 }) | |
| g = await beeGeom() | |
| await page.mouse.move(g.sheet.left + g.sheet.width * 0.85, g.mid) | |
| await page.waitForTimeout(150) | |
| assert.ok((await hoverBtn()).hidden, 'and hidden while the selection composer is open') | |
| await page.evaluate(() => [...document.querySelectorAll('#composer button')].find(b => /cancel/i.test(b.textContent))?.click()) | |
| await focusDocEnd(page) | |
| await page.waitForTimeout(250) | |
| // click it: a composer with a locator instead of a quote, and the paragraph | |
| // marked as where the comment is about to land | |
| g = await beeGeom() | |
| await page.mouse.move(g.sheet.left + g.sheet.width * 0.85, g.mid) | |
| await waitFor(async () => !(await hoverBtn()).hidden, 'button back after the selection is gone') | |
| await page.click('#hover-comment') | |
| await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 }) | |
| const opened = await page.evaluate(() => { | |
| const mark = document.querySelector('.tiptap .comment-anchor.pending') | |
| return { | |
| quoteHidden: document.getElementById('composer-quote').classList.contains('hidden'), | |
| loc: document.getElementById('composer-loc').textContent, | |
| locShown: !document.getElementById('composer-loc').classList.contains('hidden'), | |
| markText: mark?.textContent || null, | |
| pendingSpans: document.querySelectorAll('.tiptap .pending-hl').length, | |
| composerTop: document.getElementById('composer').getBoundingClientRect().top, | |
| } | |
| }) | |
| assert.ok(opened.quoteHidden, 'no blockquote: there is nothing quoted: ' + JSON.stringify(opened)) | |
| assert.ok(opened.locShown && opened.loc.includes('Bees navigate'), 'the locator says where instead: ' + JSON.stringify(opened)) | |
| assert.equal(opened.pendingSpans, 0, 'and no span is underlined as if the words were the subject') | |
| assert.ok(opened.markText?.startsWith('Bees navigate'), 'the paragraph it will land beside is marked: ' + JSON.stringify(opened)) | |
| assert.ok(Math.abs(opened.composerTop - g.top) < 40, `the box opens level with that paragraph (${opened.composerTop} vs ${g.top})`) | |
| await page.fill('#composer-text', 'Worth a footnote here?') | |
| await page.click('#composer .primary, #composer button:has-text("Comment")') | |
| await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 1, 'a position-anchored card lands in the margin') | |
| const posted = await page.evaluate(async () => { | |
| const card = document.querySelector('.card.thread .anchor-loc').closest('.card') | |
| const id = location.pathname.split('/d/')[1].split('/')[0] | |
| const snap = await (await fetch(`/api/docs/${id}`)).json() | |
| const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Bees navigate')) | |
| return { | |
| loc: card.querySelector('.anchor-loc').textContent, | |
| quotes: card.querySelectorAll('blockquote').length, | |
| orphaned: card.querySelector('.anchor-loc').classList.contains('orphaned'), | |
| cardTop: card.getBoundingClientRect().top, | |
| paraTop: p.getBoundingClientRect().top, | |
| activeMark: document.querySelector('.tiptap .comment-anchor.active')?.textContent || null, | |
| hls: document.querySelectorAll('.tiptap .comment-hl').length, | |
| thread: (snap.threads || []).find(t => t.anchor_kind === 'position') || null, | |
| } | |
| }) | |
| assert.ok(posted.loc.includes('Bees navigate'), 'the card shows the paragraph it sits beside: ' + JSON.stringify(posted)) | |
| assert.equal(posted.quotes, 0, 'and quotes nothing') | |
| assert.ok(!posted.orphaned, 'the paragraph is still there, so it is not orphaned') | |
| assert.ok(Math.abs(posted.cardTop - posted.paraTop) < 40, `the card sits next to that paragraph (${posted.cardTop} vs ${posted.paraTop})`) | |
| assert.ok(posted.activeMark?.startsWith('Bees navigate'), 'the open card marks its paragraph: ' + JSON.stringify(posted)) | |
| assert.equal(posted.hls, hlBefore, 'and adds no comment underline — nothing here is about those words') | |
| assert.ok(posted.thread, 'the API reports the anchor kind: ' + JSON.stringify(posted.thread)) | |
| assert.equal(posted.thread.excerpt, null, 'excerpt stays null — no words are quoted') | |
| assert.ok(posted.thread.anchor_context.startsWith('Bees navigate'), 'anchor_context carries the place: ' + JSON.stringify(posted.thread)) | |
| console.log('✓ hover comment: right third only, anchored to a place, quotes nothing') | |
| // --- the anchored paragraph is deleted --- | |
| // Kept and never auto-closed: deleting text must not silently end a conversation | |
| // about it. The anchor resolves to the gap the paragraph left, which is not | |
| // inside any block, so the card reads as orphaned — and because that gap is | |
| // still where the paragraph was, it holds its row in the panel rather than | |
| // dropping to the bottom. | |
| const paraTopBefore = posted.paraTop | |
| await page.evaluate(() => { | |
| const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Bees navigate')) | |
| const pos = window.__editor.view.posAtDOM(p, 0) | |
| const $p = window.__editor.state.doc.resolve(pos) | |
| window.__editor.view.dispatch(window.__editor.state.tr.delete($p.before($p.depth), $p.after($p.depth))) | |
| }) | |
| // the editor, not the body: the card's locator still carries those words, which | |
| // is the whole point of the assertions below | |
| await waitFor(async () => page.evaluate(() => !document.querySelector('.tiptap').textContent.includes('Bees navigate by polarised')), 'the paragraph is gone') | |
| await waitFor( | |
| async () => page.evaluate(() => document.querySelector('.card.thread .anchor-loc')?.classList.contains('orphaned') === true), | |
| 'the card notices its paragraph was deleted' | |
| ) | |
| const afterDelete = await page.evaluate(() => { | |
| const loc = document.querySelector('.card.thread .anchor-loc') | |
| return { | |
| words: loc.textContent, | |
| struck: getComputedStyle(loc.querySelector('.words')).textDecorationLine, | |
| title: loc.title, | |
| closed: loc.closest('.card').textContent.includes('closed'), | |
| cardTop: loc.closest('.card').getBoundingClientRect().top, | |
| marks: document.querySelectorAll('.tiptap .comment-anchor').length, | |
| } | |
| }) | |
| assert.ok(afterDelete.words.includes('Bees navigate'), 'the words are all that is left of the place, so they stay: ' + JSON.stringify(afterDelete)) | |
| assert.equal(afterDelete.struck, 'line-through', 'struck through to say the paragraph is gone') | |
| assert.ok(/deleted/.test(afterDelete.title), 'and it says so: ' + JSON.stringify(afterDelete)) | |
| assert.ok(!afterDelete.closed, 'the thread is NOT auto-closed') | |
| assert.equal(afterDelete.marks, 0, 'nothing in the document is marked any more: ' + JSON.stringify(afterDelete)) | |
| assert.ok(Math.abs(afterDelete.cardTop - paraTopBefore) < 90, `the card stays where the paragraph was (${afterDelete.cardTop} vs ${paraTopBefore})`) | |
| // close only this one, so the margin the later checks see is the one they left | |
| await page.evaluate(() => document.querySelector('.card.thread .anchor-loc')?.closest('.card').querySelector('.close-btn')?.click()) | |
| await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 0, 'an orphaned thread still closes normally') | |
| console.log('✓ a deleted paragraph orphans its position comment, keeps it, and keeps its place') | |
| // Same again with content AFTER the anchored paragraph, which is the case that | |
| // could plausibly differ (y-prosemirror's diff is free to reuse a surviving | |
| // sibling element instead of deleting one, and then the anchor would still be | |
| // inside a live block). Measured, it does not differ here — but rather than | |
| // assert a branch of someone else's diff, the assertion below is the invariant | |
| // that has to hold whichever branch runs: the card reads as orphaned exactly | |
| // when the words really are gone from the page. | |
| await page.evaluate(() => { | |
| const e = window.__editor | |
| e.commands.insertContentAt(e.state.doc.content.size, { | |
| type: 'doc', | |
| content: [ | |
| { type: 'paragraph', content: [{ type: 'text', text: 'Wasps, by contrast, remember faces for weeks on end.' }] }, | |
| { type: 'paragraph', content: [{ type: 'text', text: 'Trailing paragraph so the one above is not the last.' }] }, | |
| ], | |
| }) | |
| }) | |
| await page.waitForTimeout(400) | |
| const waspGeom = () => | |
| page.evaluate(() => { | |
| const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Wasps, by contrast')) | |
| p.scrollIntoView({ block: 'center' }) | |
| const r = p.getBoundingClientRect() | |
| const sheet = document.getElementById('editor').getBoundingClientRect() | |
| return { top: r.top, mid: r.top + r.height / 2, sheet } | |
| }) | |
| const wg = await waspGeom() | |
| await page.mouse.move(wg.sheet.left + wg.sheet.width * 0.85, wg.mid) | |
| await waitFor(async () => !(await hoverBtn()).hidden, 'button arms beside the middle paragraph') | |
| await page.click('#hover-comment') | |
| await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 }) | |
| await page.fill('#composer-text', 'Source for this?') | |
| await page.click('#composer .primary, #composer button:has-text("Comment")') | |
| await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 1, 'second position comment posted') | |
| const waspTopBefore = (await waspGeom()).top | |
| await page.evaluate(() => { | |
| const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Wasps, by contrast')) | |
| const pos = window.__editor.view.posAtDOM(p, 0) | |
| const $p = window.__editor.state.doc.resolve(pos) | |
| window.__editor.view.dispatch(window.__editor.state.tr.delete($p.before($p.depth), $p.after($p.depth))) | |
| }) | |
| await waitFor(async () => page.evaluate(() => !document.querySelector('.tiptap').textContent.includes('Wasps, by contrast')), 'the middle paragraph is gone') | |
| await page.waitForTimeout(400) | |
| const midDelete = await page.evaluate(() => { | |
| const loc = document.querySelector('.card.thread .anchor-loc') | |
| if (!loc) return null | |
| return { | |
| words: loc.textContent, | |
| orphaned: loc.classList.contains('orphaned'), | |
| docHasWords: document.querySelector('.tiptap').textContent.includes('Wasps, by contrast'), | |
| closed: loc.closest('.card').textContent.includes('closed'), | |
| top: loc.closest('.card').getBoundingClientRect().top, | |
| } | |
| }) | |
| assert.ok(midDelete, 'the comment survives its paragraph being deleted') | |
| assert.ok(midDelete.words.includes('Wasps, by contrast'), 'and still names the paragraph it was left beside: ' + JSON.stringify(midDelete)) | |
| assert.ok(!midDelete.closed, 'deleting text never closes the conversation about it') | |
| assert.equal(midDelete.orphaned, !midDelete.docHasWords, 'orphaned exactly when the words really are gone: ' + JSON.stringify(midDelete)) | |
| assert.ok(Math.abs(midDelete.top - waspTopBefore) < 90, `and it stays where that paragraph was (${midDelete.top} vs ${waspTopBefore})`) | |
| await page.evaluate(() => document.querySelector('.card.thread .anchor-loc')?.closest('.card').querySelector('.close-btn')?.click()) | |
| await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 0, 'and it closes normally') | |
| console.log('✓ deleting a paragraph mid-page keeps the comment, its label and its place') | |
| // --- a MERGE is not a deletion --- | |
| // Backspace at the start of the anchored paragraph joins it into the one above: | |
| // the most ordinary way a paragraph stops existing. The block boundary goes, so | |
| // the anchor lands outside any text block exactly as it does for a real | |
| // deletion — but every word survives, one line up. The first cut of this feature | |
| // read "not inside a block" as "deleted" and struck the card through while | |
| // pointing straight at the text it claimed was gone. Same anchor, same resolve, | |
| // opposite verdict, decided by whether the remembered words are still beside the | |
| // boundary. | |
| const mergeHost = 'Hoverfly larvae eat aphids, which is why gardeners like them.' | |
| const mergeAnchored = 'Their wing beat is close to two hundred hertz in level flight.' | |
| await page.evaluate(texts => { | |
| const e = window.__editor | |
| e.commands.insertContentAt(e.state.doc.content.size, { | |
| type: 'doc', | |
| content: texts.map(t => ({ type: 'paragraph', content: [{ type: 'text', text: t }] })), | |
| }) | |
| }, [mergeHost, mergeAnchored]) | |
| await page.waitForTimeout(400) | |
| const mergeGeom = () => | |
| page.evaluate(t => { | |
| const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18))) | |
| p.scrollIntoView({ block: 'center' }) | |
| const r = p.getBoundingClientRect() | |
| const sheet = document.getElementById('editor').getBoundingClientRect() | |
| return { top: r.top, mid: r.top + r.height / 2, sheet } | |
| }, mergeAnchored) | |
| const mg = await mergeGeom() | |
| await page.mouse.move(mg.sheet.left + mg.sheet.width * 0.85, mg.mid) | |
| await waitFor(async () => !(await hoverBtn()).hidden, 'button arms beside the paragraph about to be merged') | |
| await page.click('#hover-comment') | |
| await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 }) | |
| await page.fill('#composer-text', 'Is two hundred right?') | |
| await page.click('#composer .primary, #composer button:has-text("Comment")') | |
| await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 1, 'comment posted beside it') | |
| const mergeProbe = () => | |
| page.evaluate(t => { | |
| const loc = document.querySelector('.card.thread .anchor-loc') | |
| if (!loc) return { collapsed: true, paraCount: document.querySelectorAll('.tiptap p').length } | |
| return { | |
| orphaned: loc.classList.contains('orphaned'), | |
| title: loc.title, | |
| struck: getComputedStyle(loc.querySelector('.words')).textDecorationLine, | |
| words: loc.textContent, | |
| docHasWords: document.querySelector('.tiptap').textContent.includes(t), | |
| paraCount: document.querySelectorAll('.tiptap p').length, | |
| } | |
| }, mergeAnchored) | |
| const beforeMerge = await mergeProbe() | |
| assert.ok(!beforeMerge.orphaned, 'live before the merge: ' + JSON.stringify(beforeMerge)) | |
| // Caret to the very start of the anchored paragraph, then Backspace, as a person | |
| // would. The click is what takes DOM focus — commands.focus() sets the selection | |
| // but leaves the keystrokes going to the body in headless Chromium (see | |
| // focusDocEnd) — and setTextSelection then makes the position exact. | |
| const mergeParaBox = await page.evaluate(t => { | |
| const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18))) | |
| p.scrollIntoView({ block: 'center' }) | |
| const r = p.getBoundingClientRect() | |
| return { x: r.left + 3, y: r.top + r.height / 2 } | |
| }, mergeAnchored) | |
| await page.mouse.click(mergeParaBox.x, mergeParaBox.y) | |
| await page.waitForFunction(() => window.__editor?.isFocused, null, { timeout: 5000 }) | |
| await page.evaluate(t => { | |
| const e = window.__editor | |
| const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18))) | |
| e.commands.setTextSelection(e.view.posAtDOM(p, 0)) | |
| }, mergeAnchored) | |
| await page.keyboard.press('Backspace') | |
| await waitFor( | |
| async () => page.evaluate(n => document.querySelectorAll('.tiptap p').length === n - 1, beforeMerge.paraCount), | |
| 'the two paragraphs really did join into one' | |
| ) | |
| // Clicking into the document to place the caret deselected the card, and a | |
| // collapsed card has no locator to read — reopen it. | |
| await page.click('.card.thread .head') | |
| await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 1, 'the card is open again after the merge') | |
| await page.waitForTimeout(400) | |
| const afterMerge = await mergeProbe() | |
| assert.ok(afterMerge.docHasWords, 'the words are still on the page after the merge: ' + JSON.stringify(afterMerge)) | |
| assert.ok(!afterMerge.orphaned, 'so the card must NOT claim the paragraph was deleted: ' + JSON.stringify(afterMerge)) | |
| assert.equal(afterMerge.struck, 'none', 'and must not strike the words through') | |
| assert.ok(!/deleted/.test(afterMerge.title), 'and the tooltip still offers to jump to it: ' + JSON.stringify(afterMerge)) | |
| assert.ok(afterMerge.words.includes('Their wing beat'), 'still naming the place it was left: ' + JSON.stringify(afterMerge)) | |
| // and it still resolves to somewhere sane — the locator jumps without throwing | |
| const mergeJump = await page.evaluate(() => { | |
| document.querySelector('.card.thread .anchor-loc').click() | |
| const e = window.__editor | |
| return { pos: e.state.selection.from, size: e.state.doc.content.size } | |
| }) | |
| assert.ok(mergeJump.pos > 0 && mergeJump.pos <= mergeJump.size, 'the locator still resolves: ' + JSON.stringify(mergeJump)) | |
| await page.evaluate(() => document.querySelector('.card.thread .anchor-loc')?.closest('.card').querySelector('.close-btn')?.click()) | |
| await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 0, 'and closes normally') | |
| console.log('✓ merging the anchored paragraph away is not reported as a deletion') | |
| // --- the reason the anchor is a relative position and not an offset --- | |
| // Insert whole blocks ABOVE a position comment. An offset would slide the | |
| // comment down the document; the relative position must keep naming the same | |
| // paragraph and travel with it. | |
| const driftAnchored = 'Bumblebees can fly in colder air than honeybees manage.' | |
| await page.evaluate(t => { | |
| const e = window.__editor | |
| e.commands.insertContentAt(e.state.doc.content.size, { | |
| type: 'doc', | |
| content: [ | |
| { type: 'paragraph', content: [{ type: 'text', text: t }] }, | |
| { type: 'paragraph', content: [{ type: 'text', text: 'A trailing paragraph, so the one above is not the last.' }] }, | |
| ], | |
| }) | |
| }, driftAnchored) | |
| await page.waitForTimeout(400) | |
| const driftGeom = () => | |
| page.evaluate(t => { | |
| const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18))) | |
| p.scrollIntoView({ block: 'center' }) | |
| const r = p.getBoundingClientRect() | |
| const sheet = document.getElementById('editor').getBoundingClientRect() | |
| return { top: r.top, mid: r.top + r.height / 2, sheet } | |
| }, driftAnchored) | |
| const dg = await driftGeom() | |
| await page.mouse.move(dg.sheet.left + dg.sheet.width * 0.85, dg.mid) | |
| await waitFor(async () => !(await hoverBtn()).hidden, 'button arms beside the paragraph that must not drift') | |
| await page.click('#hover-comment') | |
| await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 }) | |
| await page.fill('#composer-text', 'Worth a citation.') | |
| await page.click('#composer .primary, #composer button:has-text("Comment")') | |
| await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 1, 'comment posted beside it') | |
| await page.waitForTimeout(300) | |
| // where the anchor resolves BEFORE anything is inserted above it | |
| const driftPosBefore = await page.evaluate(() => { | |
| document.querySelector('.card.thread .anchor-loc').click() | |
| return window.__editor.state.selection.from | |
| }) | |
| // three whole paragraphs, inserted above it | |
| await page.evaluate(t => { | |
| const e = window.__editor | |
| const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18))) | |
| const $p = e.state.doc.resolve(e.view.posAtDOM(p, 0)) | |
| e.commands.insertContentAt($p.before($p.depth), { | |
| type: 'doc', | |
| content: [0, 1, 2].map(i => ({ type: 'paragraph', content: [{ type: 'text', text: `An inserted paragraph, number ${i}.` }] })), | |
| }) | |
| }, driftAnchored) | |
| await page.waitForTimeout(600) | |
| const afterInsertAbove = await page.evaluate(t => { | |
| const loc = document.querySelector('.card.thread .anchor-loc') | |
| const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18))) | |
| // where the anchor actually points now, via the app's own resolve | |
| loc.click() | |
| const $sel = window.__editor.state.doc.resolve(window.__editor.state.selection.from) | |
| return { | |
| orphaned: loc.classList.contains('orphaned'), | |
| words: loc.textContent, | |
| cardTop: loc.closest('.card').getBoundingClientRect().top, | |
| paraTop: p.getBoundingClientRect().top, | |
| landedIn: $sel.parent.isTextblock ? $sel.parent.textContent.slice(0, 30) : null, | |
| pos: window.__editor.state.selection.from, | |
| } | |
| }, driftAnchored) | |
| assert.ok(!afterInsertAbove.orphaned, 'inserting blocks above does not orphan it: ' + JSON.stringify(afterInsertAbove)) | |
| assert.ok(afterInsertAbove.words.includes('Bumblebees can fly'), 'and it still names the same paragraph: ' + JSON.stringify(afterInsertAbove)) | |
| assert.ok( | |
| afterInsertAbove.landedIn?.startsWith('Bumblebees can fly'), | |
| 'the anchor still points INSIDE that paragraph: ' + JSON.stringify(afterInsertAbove) | |
| ) | |
| // The pair of assertions is the whole point, and neither alone would do it: the | |
| // resolved position MOVED (an offset would have stayed put and landed in one of | |
| // the inserted paragraphs) and it moved to the same paragraph as before. | |
| assert.ok( | |
| afterInsertAbove.pos > driftPosBefore, | |
| `the anchor moved down by what was inserted above it (${driftPosBefore} -> ${afterInsertAbove.pos})` | |
| ) | |
| assert.ok( | |
| Math.abs(afterInsertAbove.cardTop - afterInsertAbove.paraTop) < 90, | |
| `and the card travelled down with it (${afterInsertAbove.cardTop} vs ${afterInsertAbove.paraTop})` | |
| ) | |
| await page.evaluate(() => document.querySelector('.card.thread .anchor-loc')?.closest('.card').querySelector('.close-btn')?.click()) | |
| await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 0, 'and closes normally') | |
| console.log('✓ blocks inserted above a position comment do not move it off its paragraph') | |
| // --- scrolling drops the button --- | |
| // It is positioned inside #editor-col, so it travels with the text and keeps | |
| // pointing at its own paragraph — but the pointer that armed it has not moved, | |
| // so it ends up offering to comment on something nowhere near the cursor. | |
| const scrollGeom = await page.evaluate(() => { | |
| const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.length > 40) | |
| p.scrollIntoView({ block: 'center' }) | |
| const r = p.getBoundingClientRect() | |
| const sheet = document.getElementById('editor').getBoundingClientRect() | |
| return { mid: r.top + r.height / 2, sheet } | |
| }) | |
| await page.mouse.move(scrollGeom.sheet.left + scrollGeom.sheet.width * 0.85, scrollGeom.mid) | |
| await waitFor(async () => !(await hoverBtn()).hidden, 'armed before scrolling') | |
| await page.mouse.wheel(0, 350) | |
| await waitFor(async () => (await hoverBtn()).hidden, 'a scroll drops the button rather than leaving it beside a paragraph the pointer has left') | |
| console.log('✓ scrolling without moving the pointer drops the hover button') | |
| // --- a table row anchors to its leftmost cell --- | |
| // Every cell in a row shares one Y band, so document order picks the first. | |
| // Documented at textblockAtHeight; asserted here so it stays a decision. | |
| await page.evaluate(() => { | |
| const e = window.__editor | |
| e.commands.insertContentAt(e.state.doc.content.size, { | |
| type: 'doc', | |
| content: [{ | |
| type: 'table', | |
| content: [{ | |
| type: 'tableRow', | |
| content: ['Leftmost cell of the row', 'Middle cell', 'Rightmost cell'].map(t => ({ | |
| type: 'tableCell', | |
| content: [{ type: 'paragraph', content: [{ type: 'text', text: t }] }], | |
| })), | |
| }], | |
| }], | |
| }) | |
| }) | |
| await page.waitForTimeout(500) | |
| const cellGeom = await page.evaluate(() => { | |
| const c = [...document.querySelectorAll('.tiptap td, .tiptap th')].find(n => n.textContent.startsWith('Rightmost cell')) | |
| c.scrollIntoView({ block: 'center' }) | |
| const r = c.getBoundingClientRect() | |
| const sheet = document.getElementById('editor').getBoundingClientRect() | |
| return { mid: r.top + r.height / 2, sheet } | |
| }) | |
| await page.mouse.move(cellGeom.sheet.left + cellGeom.sheet.width * 0.85, cellGeom.mid) | |
| await waitFor(async () => !(await hoverBtn()).hidden, 'button arms beside a table row') | |
| await page.click('#hover-comment') | |
| await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 }) | |
| const rowLoc = await page.evaluate(() => document.getElementById('composer-loc').textContent) | |
| assert.ok(rowLoc.includes('Leftmost cell of the row'), 'level with the last cell, it anchors to the row\'s first: ' + JSON.stringify(rowLoc)) | |
| await page.evaluate(() => [...document.querySelectorAll('#composer button')].find(b => /cancel/i.test(b.textContent))?.click()) | |
| await page.waitForTimeout(200) | |
| console.log('✓ hovering beside a table row anchors to the row\'s leftmost cell') | |
| // 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 (through the in-app confirm modal) | |
| await page.click('#reset-btn') | |
| await page.waitForSelector('.ui-modal') | |
| await page.click('.ui-modal .btn.primary, .ui-modal .ui-danger') | |
| 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 hover comment affordance does not exist on touch, on purpose: there is | |
| // no hover to reveal it with, and a button parked in the margin is exactly the | |
| // gutter the desktop design avoids. Selecting text and tapping Comment is the | |
| // whole flow here, and it is exercised further down. | |
| const hoverOnTouch = await page.evaluate(async () => { | |
| const sheet = document.getElementById('editor').getBoundingClientRect() | |
| const p = document.querySelector('.tiptap p').getBoundingClientRect() | |
| document.getElementById('editor-col').dispatchEvent( | |
| new MouseEvent('mousemove', { clientX: sheet.left + sheet.width * 0.85, clientY: p.top + p.height / 2, bubbles: true }) | |
| ) | |
| await new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done))) | |
| const b = document.getElementById('hover-comment') | |
| return { | |
| noHover: matchMedia('(hover: none)').matches, | |
| classHidden: b.classList.contains('hidden'), | |
| display: getComputedStyle(b).display, | |
| } | |
| }) | |
| assert.ok(hoverOnTouch.noHover, 'the phone viewport really is a no-hover one: ' + JSON.stringify(hoverOnTouch)) | |
| assert.ok(hoverOnTouch.classHidden, 'a synthetic hover cannot arm it: ' + JSON.stringify(hoverOnTouch)) | |
| assert.equal(hoverOnTouch.display, 'none', 'and CSS keeps it gone even if something did') | |
| // 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') | |
| // --- the document surface itself, at 390px --- | |
| // The two symptoms this replaced: an 816px sheet in a 390px window put 68% of | |
| // every line off screen, and a 1in page margin spent 24.6% of the viewport | |
| // before the first letter. Both are measured here, not asserted by eye. | |
| const surface = () => | |
| page.evaluate(() => { | |
| const ed = document.getElementById('editor') | |
| const t = document.querySelector('.tiptap') | |
| const col = document.getElementById('editor-col') | |
| const para = [...t.querySelectorAll('p')].find(e => e.textContent.trim().length > 120) | |
| const lh = para ? parseFloat(getComputedStyle(para).lineHeight) : 1 | |
| const lines = para ? Math.max(1, Math.round(para.getBoundingClientRect().height / lh)) : 0 | |
| return { | |
| sheet: Math.round(ed.getBoundingClientRect().width), | |
| pad: Math.round(parseFloat(getComputedStyle(ed).paddingLeft)), | |
| measure: Math.round(t.getBoundingClientRect().width), | |
| font: parseFloat(getComputedStyle(t).fontSize), | |
| line: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--doc-line')), | |
| titleFont: Math.round(parseFloat(getComputedStyle(t.querySelector('h1')).fontSize)), | |
| colScroll: col.scrollWidth - col.clientWidth, | |
| // the whole point: how much of a line a reader can actually see | |
| offScreen: Math.max(0, Math.round(ed.getBoundingClientRect().right - window.innerWidth)), | |
| charsPerLine: lines ? Math.round(para.textContent.trim().length / lines) : 0, | |
| } | |
| }) | |
| const phone = await surface() | |
| assert.equal(phone.sheet, 390, `the sheet is the screen, edge to edge (${phone.sheet})`) | |
| assert.equal(phone.offScreen, 0, `no part of the page is off screen (${phone.offScreen}px)`) | |
| assert.equal(phone.colScroll, 0, `and there is nothing to scroll sideways to (${phone.colScroll}px)`) | |
| assert.ok(phone.pad >= 16 && phone.pad <= 34, | |
| `the margin is a phone margin, not a printed inch (${phone.pad}px = ${Math.round((phone.pad / 390) * 100)}% a side)`) | |
| assert.ok(phone.font >= 16, `body text is at least 16px, iOS and Android's own body size (${phone.font})`) | |
| assert.ok(phone.charsPerLine >= 32 && phone.charsPerLine <= 52, | |
| `a phone line, not a page line (${phone.charsPerLine} characters)`) | |
| // the Docs Title is 2.364em against a 624px column; at 354px that was 37.8px | |
| assert.ok(phone.titleFont < 34, `the display title is re-struck for the column (${phone.titleFont}px)`) | |
| console.log(`✓ mobile: the page reflows into the screen — ${phone.measure}px column, ${phone.font}px type, ${phone.charsPerLine} chars/line`) | |
| // No reflow jitter: the drawer and the comment sheet are position:fixed, so | |
| // revealing either one must not resize the column and re-break a line. This | |
| // was the real risk in making the sheet fluid — a sheet sized off a container | |
| // that panels can shrink would re-set the text every time one opened. | |
| const stable = [] | |
| for (const [what, sel] of [['pages drawer', '#m-pages'], ['comment sheet', '#m-comments']]) { | |
| await page.click(sel) | |
| await page.waitForTimeout(400) | |
| stable.push([what, await surface()]) | |
| await page.click(sel) | |
| await page.waitForTimeout(400) | |
| } | |
| for (const [what, open] of stable) { | |
| assert.equal(open.measure, phone.measure, `the ${what} does not re-break a line (${open.measure} vs ${phone.measure})`) | |
| } | |
| // typing must not move the column either | |
| await page.click('.tiptap', { position: { x: 40, y: 12 } }) | |
| await page.keyboard.type('x') | |
| await page.waitForTimeout(300) | |
| const typed = await surface() | |
| assert.equal(typed.measure, phone.measure, `typing does not re-break the column (${typed.measure})`) | |
| await page.keyboard.press('Backspace') | |
| console.log('✓ mobile: opening the drawer, the sheet, or the keyboard reflows nothing') | |
| // zoom is now a type control here, not a fit control: the sheet is already the | |
| // screen, so ± only reaches the words — and its floor lifts, because 40% of a | |
| // fluid column is 6.4px of text and buys nothing | |
| await page.evaluate(() => { document.body.classList.add('fmt-open') }) | |
| await page.waitForTimeout(200) | |
| for (let i = 0; i < 6; i++) await page.click('#zoom-out') | |
| await page.waitForTimeout(350) | |
| const zoomedOut = await surface() | |
| assert.equal(zoomedOut.sheet, phone.sheet, `zooming out does not narrow the sheet (${zoomedOut.sheet})`) | |
| assert.ok(zoomedOut.font >= 12.8, | |
| `the zoom floor is 80% here, so the type stays readable (${zoomedOut.font}px at ${await page.textContent('#zoom-label')})`) | |
| // back to 100% by the readout, not by counting clicks: the floor swallows the | |
| // extra ones, so six out and six in would land at 140% | |
| for (let i = 0; i < 12 && (await page.textContent('#zoom-label')) !== '100%'; i++) await page.click('#zoom-in') | |
| await page.waitForTimeout(350) | |
| assert.equal(await page.textContent('#zoom-label'), '100%', 'zoom back to 100% for the rest of the checks') | |
| await page.evaluate(() => { document.body.classList.remove('fmt-open') }) | |
| console.log('✓ mobile: zoom sets the type instead of trying to fit a page that already fits') | |
| // the Reading style is measured against a 624px column (10.69 words a line); | |
| // on a phone it gets 354px and only its leading is allowed to give | |
| // the ⋯ sheet is a toggle, and the style button lives inside it — so open it | |
| // by its state rather than by clicking and hoping it was closed | |
| const moreOpen = () => page.evaluate(() => !document.getElementById('more-pop').classList.contains('hidden')) | |
| const openMore = async () => { | |
| if (!(await moreOpen())) await page.click('#m-more') | |
| await page.waitForSelector('#more-pop:not(.hidden)') | |
| await page.waitForTimeout(150) | |
| } | |
| // and put it back down afterwards: the header-chrome checks measure whether | |
| // the desktop buttons are visible, and on a phone they live inside this sheet | |
| const closeMore = async () => { | |
| if (await moreOpen()) await page.click('#m-more') | |
| await waitFor(async () => !(await moreOpen()), 'the ⋯ sheet is back down') | |
| } | |
| await openMore() | |
| await page.click('#docstyle-btn') | |
| await waitFor(async () => await page.evaluate(() => document.documentElement.dataset.docStyle === 'reading'), 'reading style on') | |
| await page.waitForTimeout(700) | |
| const reading = await surface() | |
| assert.equal(reading.measure, phone.measure, 'Reading gets the same phone column as Docs') | |
| assert.equal(reading.colScroll, 0, 'Reading does not overflow it either') | |
| assert.ok(reading.font >= 16, `Reading keeps the 16px base it was measured at (${reading.font})`) | |
| assert.ok(reading.line < 1.8 && reading.line >= 1.5, | |
| `only the leading gives: 1.8 was struck for a long line (${reading.line})`) | |
| assert.ok(reading.titleFont < 40, `the display serif headline is re-struck too (${reading.titleFont}px)`) | |
| await shot('02-reading') | |
| await openMore() | |
| await page.click('#docstyle-btn') | |
| await waitFor(async () => await page.evaluate(() => document.documentElement.dataset.docStyle === 'docs'), 'docs style back') | |
| await closeMore() | |
| console.log(`✓ mobile: Reading holds its 16px base and gives its leading instead (${reading.line})`) | |
| // 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):not(.pending-page)') // a real page, not a proposal | |
| 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) | |
| }) | |