This commit is contained in:
+229
@@ -0,0 +1,229 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user