| |
| |
| |
| 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)) |
|
|
| const consoleErrors = [] |
|
|
| async function main() { |
| await waitFor(async () => (await fetch(`${BASE}/healthz`)).ok, 'server') |
|
|
| const browser = await chromium.launch({ |
| headless: true, |
| 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)) |
|
|
| |
| await page.goto(`${BASE}/auth/dev?u=alice&next=/`) |
| await page.waitForSelector('#docs-list') |
| page.once('dialog', d => d.accept('Browser Test Doc')) |
| await page.click('#new-doc') |
| await page.waitForURL('**/d/**') |
| await page.waitForSelector('.tiptap', { timeout: 10000 }) |
| console.log('✓ login + doc creation + editor mounted') |
|
|
| |
| await page.click('#agents-btn') |
| await page.fill('#agent-handle', 'ui-agent') |
| await page.click('#agent-form button') |
| await page.waitForSelector('.agent-row') |
| |
| 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)) |
| await page.click('#agents-btn') |
| console.log('✓ agent registered via header panel, key issued') |
|
|
| |
| 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') |
| console.log('✓ share panel: add collaborator') |
|
|
| |
| await page.click('.tiptap') |
| await page.keyboard.press('Control+End') |
| 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') |
|
|
| |
| await selectText(page, 'quick brown fox') |
| await page.waitForSelector('#selection-menu:not(.hidden)', { timeout: 5000 }) |
| await page.click('#comment-btn') |
| await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 }) |
| await page.waitForTimeout(300) |
| const composerTop = parseFloat(await page.evaluate(() => document.getElementById('composer').style.top)) |
| assert.ok(composerTop > 30, `composer aligned to anchored text, not at column top (top=${composerTop})`) |
| 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: 5000 }) |
| await page.waitForSelector('.tiptap .comment-hl', { timeout: 5000 }) |
| console.log('✓ comment card created: chip + text highlight visible') |
|
|
| |
| await page.mouse.click(40, 500) |
| await waitFor(async () => !(await page.$('.card.thread.expanded')), 'card collapses') |
| console.log('✓ cards collapse when clicking away') |
|
|
| |
| await selectText(page, 'lazy dog') |
| await page.waitForSelector('#selection-menu:not(.hidden)', { timeout: 5000 }) |
| await page.click('#suggest-btn') |
| await page.waitForSelector('#suggest-composer:not(.hidden)', { timeout: 5000 }) |
| await waitFor(async () => { |
| const v = await page.inputValue('#suggest-text') |
| return v && v !== '…' |
| }, 'suggest prefill loaded') |
| const prefill = await page.inputValue('#suggest-text') |
| assert.ok(prefill.includes('lazy dog'), 'suggest composer prefilled with block markdown: ' + prefill) |
| await page.fill('#suggest-text', 'The quick brown fox jumps over the **energetic** dog.') |
| await page.fill('#suggest-rationale', 'more positive energy') |
| page.on('dialog', d => { |
| console.log('[dialog]', d.message()) |
| d.dismiss().catch(() => {}) |
| }) |
| await page.click('#suggest-send') |
| await page.waitForSelector('.card.suggestion.expanded', { timeout: 5000 }) |
| console.log('✓ human suggestion created via UI') |
|
|
| |
| await page.waitForSelector('.tiptap .sugg-ins', { timeout: 5000 }) |
| const insText = await page.textContent('.tiptap .sugg-ins') |
| assert.ok(insText.includes('energetic'), 'inserted words shown inline: ' + insText) |
| const delText = await page.textContent('.tiptap .sugg-del') |
| assert.ok(delText.includes('lazy'), 'deleted words struck through inline: ' + delText) |
| |
| 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') |
|
|
| |
| await page.fill('.card.suggestion .reply-row input', '@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') |
| |
| 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)') |
|
|
| |
| 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') |
| |
| 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') |
| |
| await waitFor(async () => (await page.innerHTML('.tiptap')).includes('<strong>energetic</strong>'), 'suggestion applied with marks') |
| console.log('✓ suggestion accepted -> text replaced with marks') |
|
|
| |
| await waitFor(async () => !(await page.$('.card.suggestion')), 'accepted suggestion hidden') |
| await page.check('#show-resolved') |
| await page.waitForSelector('.card.suggestion', { timeout: 5000 }) |
| assert.ok((await page.textContent('.card.suggestion')).includes('accepted'), 'status visible when shown') |
| await page.uncheck('#show-resolved') |
| console.log('✓ resolved hidden by default, toggle shows them') |
|
|
| |
| 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 }) |
| await waitFor(async () => (await page.$('.tiptap .sugg-ins')) !== null, 'inline diff back after reopen') |
| console.log('✓ undo reverts the accept and reopens the suggestion') |
|
|
| |
| 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') |
|
|
| |
| 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', { timeout: 10000 }) |
| const src = await page.getAttribute('.tiptap img', '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') |
|
|
| |
| await page.click('.card.thread') |
| await page.waitForSelector('.card.thread.expanded', { timeout: 5000 }) |
| await page.fill('.card.thread .reply-row input', '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') |
|
|
| |
| await page.click('.tiptap') |
| await page.keyboard.press('Control+End') |
| 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)) |
| |
| await page.click('.tiptap') |
| await page.keyboard.press('Control+End') |
| await page.keyboard.press('Enter') |
| await page.keyboard.press('Enter') |
| 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') |
|
|
| |
| 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') |
|
|
| |
| 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)') |
|
|
| |
| await page.click('.tiptap') |
| await page.keyboard.press('Control+End') |
| 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') |
|
|
| |
| 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') |
|
|
| |
| await page.click('.tiptap') |
| await page.keyboard.press('Control+End') |
| 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 }) |
|
|
| |
| |
| await page.click('.tiptap') |
| await page.keyboard.press('Control+End') |
| 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') |
|
|
| |
| await page.goto(`${BASE}/`) |
| await page.waitForSelector('#docs-list') |
| 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') |
|
|
| |
| 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') |
| console.log('✓ task lists + tables render in the editor') |
|
|
| |
| await waitFor(async () => !!(await page.$('#page-tree a.pending-page')), 'proposed page shows in sidebar') |
| await page.click('#page-tree a.pending-page') |
| await page.waitForSelector('#proposal-view:not(.hidden)') |
| await waitFor(async () => !!(await page.$('#proposal-view table')), 'proposal preview renders content') |
| await page.click('#proposal-view .proposal-actions .primary') |
| await waitFor(async () => (await page.$$eval('#page-tree a', as => as.map(a => a.textContent))).some(t => t.includes('Proposed Page') && !t.includes('proposed')), 'accepted page joins the tree') |
| await waitFor(async () => !(await page.$('#page-tree a.pending-page')), 'no longer pending after accept') |
| console.log('✓ new-page suggestion: pending → preview → accept creates the page') |
|
|
| |
| 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.$('.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 ›') |
|
|
| |
| assert.ok(await page.$('.card.thread .sugg-strip'), 'suggestion renders as a strip inside its comment card') |
| await page.click('.sugg-strip .accept-btn') |
| await waitFor(async () => (await page.textContent('.tiptap')).includes('go break things'), 'strip accept applies') |
| console.log('✓ comment-attached suggestion strip (accept works collapsed)') |
|
|
| |
| assert.ok(await page.$('.card.thread .resolve-btn'), 'collapsed thread has a resolve button') |
| await page.click('.card.thread .resolve-btn') |
| await waitFor(async () => !(await page.$('.card.thread:not(.expanded) .resolve-btn')), 'thread resolved from collapsed head') |
| console.log('✓ comment resolve without opening') |
|
|
| |
| const img = await page.$('.tiptap img') |
| 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') |
| 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)') |
|
|
| |
| const stLink = await page.evaluate(() => ({ |
| text: document.getElementById('structure-link').textContent.trim(), |
| hasIcon: !!document.querySelector('#structure-link svg'), |
| })) |
| assert.deepEqual(stLink, { text: 'Structure', hasIcon: true }, 'structure link polished: ' + JSON.stringify(stLink)) |
|
|
| |
| await page.click('.tiptap') |
| await page.keyboard.press('Control+End') |
| 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') |
|
|
| |
| 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) |
|
|
| |
| await page.evaluate(() => (window.confirm = () => true)) |
| await page.click('#reset-btn') |
| await page.waitForSelector('.tiptap') |
| await waitFor(async () => (await page.textContent('.tiptap')).includes('happy testing'), 'reset restores content') |
| console.log('✓ playground reset') |
|
|
| 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) |
| } |
|
|
| 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) |
| }) |
|
|