import assert from 'node:assert/strict' import { execFileSync } from 'node:child_process' import { chromium } from 'playwright' // Run with the Docker stack up. Only uniquely created QA accounts are cleaned // up; existing users, bookmarks and database tables are never reset. const base = process.env.BASE_URL || 'http://localhost:3000' // Review TEST-03: the cleanup path deletes accounts through the LOCAL compose // database, so the runner must prove the browser target is that same stack // before creating anything. Non-loopback targets are refused unless the // operator explicitly acknowledges (E2E_ALLOW_REMOTE=1) that no cleanup is // possible and accounts will persist (E2E_KEEP_DATA=1). const repoRoot = new URL('..', import.meta.url) const baseUrl = new URL(base) const loopback = ['localhost', '127.0.0.1', '::1'].includes(baseUrl.hostname) const keepData = process.env.E2E_KEEP_DATA === '1' const localDbRunning = () => { try { return execFileSync('docker', ['compose', 'ps', '--status', 'running', '-q', 'db'], { cwd: repoRoot, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() !== '' } catch { return false } } if (!loopback) { if (process.env.E2E_ALLOW_REMOTE !== '1') { console.error(`Refusing to run against non-local target ${base}: cleanup is tied to the local compose database. Use the local stack, or set E2E_ALLOW_REMOTE=1 together with E2E_KEEP_DATA=1 to accept that QA accounts will persist on the remote target.`) process.exit(1) } if (!keepData) { console.error(`Remote target ${base} cannot be cleaned up through local docker; set E2E_KEEP_DATA=1 to acknowledge created accounts will persist.`) process.exit(1) } } if (!keepData && !localDbRunning()) { console.error('Cleanup requires the local compose db service to be running (docker compose up -d db); refusing to create QA accounts that could not be removed.') process.exit(1) } const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', headless: true, args: ['--no-sandbox'] }) const accounts = [] const errors = [] let testFailed = false const password = 'qa-test-long-password' const email = `qa-${Date.now()}@computer.test` const context = await browser.newContext() const page = await context.newPage() page.on('pageerror', error => errors.push(error.message)) async function json(response, status = 200) { assert.equal(response.status(), status, `${response.url()}: ${await response.text()}`) return response.json() } try { // Review CR-03: every accepted registration answers with the SAME uniform // {ok:true} body — the account id is private until authentication // establishes ownership, so it comes from /api/me after login. const signup = await json(await context.request.post(`${base}/api/signup`, { data: { email, password, name: 'Browser QA' } }), 201) assert.deepEqual(signup, { ok: true }) await page.goto(`${base}/login`) await page.getByPlaceholder('Email', { exact: true }).fill(email) await page.getByPlaceholder('Password', { exact: true }).fill(password) await page.getByRole('button', { name: 'Sign in with Email', exact: true }).click() await page.waitForURL('**/pages') await page.getByRole('heading', { name: 'No pages yet', exact: true }).waitFor() const me = await json(await context.request.get(`${base}/api/me`)) accounts.push(me.user.id) // Target/cleanup identity preflight (review TEST-03): the account created // through BASE_URL must exist in the local compose database that cleanup // deletes from. If it does not, the browser target is backed by some other // database — abort before creating further data. if (!keepData && loopback) { assert.match(me.user.id, /^[a-z0-9]+$/i) const found = execFileSync('docker', ['compose', 'exec', '-T', 'db', 'psql', '-U', 'computer', '-d', 'computer', '-Atc', `SELECT count(*) FROM "User" WHERE id='${me.user.id}';`], { cwd: repoRoot, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() if (found !== '1') { throw new Error(`preflight failed: QA account ${me.user.id} created through ${base} is not present in the local compose database; target and cleanup environments differ (remove it on the target environment)`) } console.log('PASS: browser target and cleanup database are the same stack') } await page.getByRole('button', { name: 'New', exact: true }).first().click() const dialog = page.getByRole('dialog') await dialog.getByPlaceholder('https://...').fill('https://example.org/browser-qa') await dialog.getByPlaceholder('Auto-filled from URL').fill('Browser QA bookmark') // Wait for the create to land server-side; navigating earlier cancels the // in-flight POST (Go fetches the preview before inserting, so a cancelled // request context aborts creation). const saved = page.waitForResponse(r => r.url().includes('/api/bookmarks') && r.request().method() === 'POST' && r.status() === 201) await dialog.getByRole('button', { name: 'Save Bookmark', exact: true }).click() await saved await page.goto(`${base}/bookmarks`) await page.getByText('Browser QA bookmark', { exact: true }).first().waitFor() let bookmarks = await json(await context.request.get(`${base}/api/bookmarks`)) const bookmark = bookmarks.find(b => b.title === 'Browser QA bookmark') assert.ok(bookmark?.id, 'UI-created bookmark was not persisted by Go') await page.getByText('Browser QA bookmark', { exact: true }).first().click() await page.getByRole('link', { name: 'Download Markdown', exact: true }).waitFor() // Optimistic concurrency (review CR-04): the PUT carries the version it edited from. const update = await json(await context.request.put(`${base}/api/bookmarks/${bookmark.id}`, { data: { notes: '# Browser verified', isPublic: true, version: bookmark.version } })) assert.equal(update.notes, '# Browser verified') // Notion-style Quick Find (Cmd/Ctrl+K): open, dismiss, then jump straight // to this bookmark's detail sheet through the ?open= deep link. await page.keyboard.press('Escape') await page.getByRole('link', { name: 'Download Markdown', exact: true }).waitFor({ state: 'hidden' }) await page.keyboard.press('Control+k') const quickFind = page.getByPlaceholder('Search pages, bookmarks, tags, and commands…') await quickFind.waitFor() await page.keyboard.press('Escape') await quickFind.waitFor({ state: 'hidden' }) await page.keyboard.press('Control+k') await quickFind.waitFor() await quickFind.fill('Browser QA') await page.getByRole('option', { name: /Browser QA bookmark/ }).first().click() await page.waitForURL('**/bookmarks?open=*') await page.getByRole('link', { name: 'Download Markdown', exact: true }).waitFor() console.log('PASS: Quick Find opens with Ctrl+K and deep-links to the bookmark sheet') const publicContext = await browser.newContext() const publicPage = await publicContext.newPage() publicPage.on('pageerror', error => errors.push(error.message)) await publicPage.goto(`${base}/share/bookmark/${bookmark.id}`) await publicPage.getByText('Browser QA bookmark', { exact: true }).first().waitFor() // Privacy field assertions (review PRIV-04): the public API response is an // allowlist — no owner attribution, no contact/recipient/operational data. const publicBody = await json(await publicContext.request.get(`${base}/api/public/bookmarks/${bookmark.id}`)) assert.equal(publicBody.title, 'Browser QA bookmark') assert.equal('# Browser verified', publicBody.notes, 'public link must publish notes (documented behavior)') for (const key of ['owner', 'ownerId', 'contact', 'contactId', 'sharedWith', 'imapQuery', 'imapWatchEnabled', 'alertAt', 'alertSent', 'kanbanColumnId', 'kanbanPosition', 'kanbanStatus', 'pageLinks']) { assert.equal(key in publicBody, false, `public response must not include ${key}`) } console.log('PASS: public response matches the documented privacy policy (no owner attribution)') await publicContext.close() await page.goto(`${base}/kanban`) await page.getByRole('heading', { name: 'Kanban Board' }).waitFor() await page.getByText('Browser QA bookmark', { exact: true }).waitFor() const columns = await json(await context.request.get(`${base}/api/kanban/columns`)) assert.equal(columns.length, 4) const custom = await json(await context.request.post(`${base}/api/kanban/columns`, { data: { label: 'QA done' } }), 201) await json(await context.request.put(`${base}/api/kanban/cards/${bookmark.id}`, { data: { columnId: custom.id, position: 0, version: update.version } })) const moved = await json(await context.request.delete(`${base}/api/kanban/columns/${custom.id}`)) assert.equal(moved.moved, 1) await page.goto(`${base}/pages`) await page.locator('main').getByRole('button', { name: 'New page', exact: true }).click() await page.getByRole('textbox', { name: 'Page title' }).waitFor() await page.getByPlaceholder('Write Markdown notes… type / for blocks, paste links, lists, thoughts.').fill('# QA page\n\nAutosaved markdown.') await page.getByText('Saved', { exact: true }).waitFor() const pages = await json(await context.request.get(`${base}/api/pages`)) const detail = await json(await context.request.get(`${base}/api/pages/${pages[0].id}`)) assert.equal(detail.content, '# QA page\n\nAutosaved markdown.') // Notion-style slash menu: "/h2" at the start of a line inserts a heading. const editor = page.getByPlaceholder('Write Markdown notes… type / for blocks, paste links, lists, thoughts.') await editor.click() await page.keyboard.press('Control+End') await page.keyboard.press('Enter') await page.keyboard.type('/h2') await page.getByRole('option', { name: /Heading 2/ }).click() await editor.evaluate((el, suffix) => new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error(`slash command did not insert heading: ${JSON.stringify(el.value)}`)), 5000) const check = setInterval(() => { if (el.value.endsWith(suffix)) { clearTimeout(timeout); clearInterval(check); resolve() } }, 25) }), '\n## ') console.log('PASS: slash command menu inserts Markdown blocks') await json(await context.request.patch(`${base}/api/pages/${pages[0].id}`, { data: { addBookmarkId: bookmark.id } })) const markdown = await context.request.get(`${base}/api/pages/${pages[0].id}/markdown`) assert.equal(markdown.status(), 200) assert.ok((await markdown.text()).includes('Browser QA bookmark')) // Collections (Notion-like upgrades phase 2, validation scenario): an // existing bookmark joins a collection without duplication, gets a // collection-scoped status, and the same membership is driven from both // the table view and a board view. await page.goto(`${base}/collections`) await page.getByRole('heading', { name: 'Collections', exact: true }).waitFor() await page.getByLabel('New collection name').fill('Research') await page.getByRole('button', { name: 'Create collection' }).click() await page.getByText('Collection created').waitFor() await page.getByRole('button', { name: 'Add bookmarks' }).click() await page.getByLabel('Search bookmarks').fill('Browser QA') await page.getByRole('button', { name: 'Add', exact: true }).first().click() await page.keyboard.press('Escape') await page.getByText('Browser QA bookmark', { exact: true }).first().waitFor() await page.getByRole('button', { name: 'Property' }).click() await page.getByLabel('Property name').fill('Status') await page.getByRole('button', { name: 'Option', exact: true }).click() await page.getByLabel('Option label').first().fill('Backlog') await page.getByRole('button', { name: 'Option', exact: true }).click() await page.getByLabel('Option label').nth(1).fill('Doing') await page.getByRole('button', { name: 'Create property' }).click() await page.getByText('Property added').waitFor() await page.getByLabel('Status', { exact: true }).first().click() await page.getByRole('option', { name: 'Doing' }).click() await page.getByRole('button', { name: 'Add view' }).click() await page.getByLabel('View name').fill('Board') await page.getByLabel('View type').click() await page.getByRole('option', { name: 'Board' }).click() await page.getByRole('button', { name: 'Create view' }).click() await page.getByText('View added').waitFor() await page.getByLabel('Group by property').click() await page.getByRole('option', { name: 'Status' }).click() await page.getByLabel('Move Browser QA bookmark').getByText('Doing').waitFor() // Moving the card on the board and reading it back in the table proves both // views edit one membership, not copies. const boardCard = page.locator('[data-collection-item-id]').filter({ hasText: 'Browser QA bookmark' }) const backlogColumn = page.locator('[data-board-group]').filter({ hasText: 'Backlog' }) const movedMembership = page.waitForResponse(r => r.url().includes('/items/') && r.request().method() === 'PATCH' && r.status() === 200) await boardCard.locator('[data-drag-handle]').dragTo(backlogColumn) await movedMembership await page.getByRole('button', { name: 'Table', exact: true }).click() await page.getByLabel('Status', { exact: true }).first().getByText('Backlog').waitFor() const membership = await json(await context.request.get(`${base}/api/collections`)) assert.equal(membership[0]._count.items, 1, 'bookmark count after membership add') console.log('PASS: collections — membership without duplication, typed properties, table+board over one membership') for (const [path, heading] of [['bookmarks', 'All Bookmarks'], ['tags', 'Tags'], ['calendar', 'Calendar'], ['collections', 'Collections'], ['contacts', 'Contacts'], ['settings', 'Settings']]) { await page.goto(`${base}/${path}`) await page.getByRole('heading', { name: heading, exact: true }).first().waitFor() } assert.deepEqual(errors, [], 'Browser runtime errors') await json(await context.request.delete(`${base}/api/bookmarks/${bookmark.id}`)) console.log('PASS: browser login, Go-backed bookmark create/edit/delete, public sharing, quick find deep links, kanban, collections, pages/autosave/slash-commands/export, all application screens') } catch (error) { testFailed = true throw error } finally { await browser.close() if (!keepData && accounts.length) { // Cleanup failures must not mask the real test error; log them and fail // the run only when the tests themselves passed. try { assert.ok(accounts.every(id => /^[a-z0-9]+$/i.test(id))) execFileSync('docker', ['compose', 'exec', '-T', 'db', 'psql', '-U', 'computer', '-d', 'computer', '-v', 'ON_ERROR_STOP=1', '-c', `DELETE FROM "User" WHERE id IN (${accounts.map(id => `'${id}'`).join(',')});`], { cwd: repoRoot, stdio: 'inherit' }) } catch (cleanupError) { console.error(`QA account cleanup failed (${accounts.join(',')}); delete manually.`, cleanupError.message) if (!testFailed) process.exitCode = 1 } } }