initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+229
View File
@@ -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
}
}
}