diff --git a/docker-compose.yml b/docker-compose.yml index 9ae3aed0a..0e1f86ff2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -216,6 +216,8 @@ services: KNOT_XRPC_PREAUTH_BURST: "500" KNOT_XRPC_PREAUTH_REFILL_MS: "5" KNOT_XRPC_PER_PEER_INFLIGHT: "64" + # same caddy peer for everyone, and e2e opens a firehose per worker on top of hydrant's + KNOT_XRPC_EVENTS_MAX_PER_PEER: "64" KNOT_LISTEN_REQUEST_TIMEOUT_MS: "600000" KNOT_TLS_HTTP3: "false" KNOT_CONTRIBUTION_POLICY: anyone diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 000000000..4f70632a8 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules +test-results +playwright-report +.auth diff --git a/e2e/.prettierrc.json b/e2e/.prettierrc.json new file mode 100644 index 000000000..56c921750 --- /dev/null +++ b/e2e/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "useTabs": true, + "singleQuote": false, + "trailingComma": "none", + "printWidth": 100 +} diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 000000000..8d5f79459 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,69 @@ +# e2e + +browser tests that follow a write through every service. a flow drives the web app in a +real browser against the localinfra stack, then checks that every service the write passed +through (pds, knot, hydrant, bobbin, spindle) agrees with what the page showed. + +```sh +pnpm e2e # the whole suite, bringing the stack up if it isn't +pnpm e2e specs/social.spec.ts # one spec +pnpm e2e --report=report.html # also write an html report +pnpm e2e --har=run.har # also write the requests as a har +pnpm e2e --login=ui # every new account logs in through the login page +pnpm e2e:up # start the stack without running anything +pnpm e2e:sweep # cleans up after a crashed run +``` + +with 12 workers its about 4-5 minutes for the whole suite on an m5 macbook. `--workers=6` +or lower might be useful for less RAM / CPU (it uses a ton of cpu in a vm like colima anyway). + +the html report is drawn with the web's own components, so it needs `pnpm install` in `web/` +(or `--web-modules=`). + +the web runs as a prod build (`localinfra/docker-compose.web-build.yml`), made when the web +container starts. `pnpm e2e` and `pnpm e2e:up` rebuild it by themselves when anything under +`web/` changed. + +you need the localinfra stack, its dev ca in `localinfra/certs`, and `pnpm install` here. +service urls come from `E2E_*_URL` (see `harness/config.ts`) and default to the localinfra +hostnames. making accounts needs the pds admin password, read from `localinfra/pds.env` or +`E2E_PDS_ADMIN_PASSWORD`. + +## cleanup + +every flow removes its own accounts and repos. `pnpm e2e:sweep` cleans up what the tests +add in case a crash or something occurs. + +## layout + +``` +run checks the stack, rebuilds the web if it changed, starts playwright +up brings the stack up +harness/ + flow.ts what a spec is written with, flow("...").open(...).click(...) + vocabulary.ts the things a spec points at, like buttons, pages and records + shared.ts steps and checks that more than one spec uses + run.ts who acts, which repo, what each step does + services.ts asking pds, knot, etc. what they have after a write + cobgit.ts looking inside the knot's git for what a write left there + versions.ts every version the knot has of a record + stream.ts watching hydrant's event stream for a write to show up + firehose.ts watching the knot and pds for the action and cid of a write + frames.ts bounded frame history and waking checks when a frame arrives + api.ts writes from node, accounts, repos, tickets, pulls + git.ts pushing and cloning over ssh like a real git client + net.ts tracing every http request node makes + tabs.ts one browser window per account, waiting for a page to go quiet + signin.ts signs in, through the login page or with a session made in node + auth.setup.ts logs the four seeded accounts in once so flows start signed in + config.ts the service urls and the seeded accounts + global.ts cleans up what a killed run left, starts the shared browser + chores.ts a list of what a run has to cleanup + record.ts remembers what each step did, for the report + lexicons.ts record types for checking what setup wrote + reporter.ts the terminal output + report/ the html report +specs/ one file per product area +tools/sweep.spec.ts deletes leaked accounts, only runs with `pnpm e2e:sweep` +tools/tidy.spec.ts a stopped worker starts this to finish its cleanup +``` diff --git a/e2e/harness/api.ts b/e2e/harness/api.ts new file mode 100644 index 000000000..32550a446 --- /dev/null +++ b/e2e/harness/api.ts @@ -0,0 +1,368 @@ +import type { Did, ResourceUri } from "@atcute/lexicons/syntax"; + +import { config, handleOf, seeded, type Account } from "./config"; +import type { Comment, Declaration, LabelDefinition, Patch, Profile, Ticket } from "./lexicons"; +import { get, ok, partsOf, recordUrl, request, retry429, stamp, xrpc, type Written } from "./net"; + +export interface RepoOptions { + spindle?: string; + forkOf?: string; +} + +const sessions = new Map>(); + +export const sessionOf = (who: Account) => { + if (!sessions.has(who.did)) { + const login = request(`${config.pdsUrl}/xrpc/com.atproto.server.createSession`, { + method: "POST", + json: { identifier: who.handle, password: config.password } + }).then(({ status, body }) => { + if (status !== 200) + throw new Error( + `pds refused ${who.name}'s login: ${status} ${JSON.stringify(body)}` + ); + return body.accessJwt as string; + }); + // otherwise a refused login stays cached and every call after it fails + login.catch(() => sessions.delete(who.did)); + sessions.set(who.did, login); + } + return sessions.get(who.did)!; +}; + +// 401 on login means the account is gone, the other texts are not-found answers +const GONE = /not ?found|does not exist|no .* to erase|pds refused .*login: 401/i; +export const alreadyGone = (cause: unknown) => GONE.test(String(cause)); + +export const getOk = async (url: string) => ok(url, await get(url)); + +export const tokenFor = async (who: Account, aud: string, lxm: string): Promise => { + const auth = await request( + xrpc(config.pdsUrl, "com.atproto.server.getServiceAuth", { aud, lxm }), + { token: await sessionOf(who) } + ); + return ok("pds getServiceAuth", auth).token; +}; + +// calls an xrpc on a service using service auth +export const serviceDo = async ( + who: Account, + base: string, + aud: string, + nsid: string, + { json, params }: { json?: object; params?: Record } = {} +) => + request(xrpc(base, nsid, params), { + method: json === undefined ? "GET" : "POST", + token: await tokenFor(who, aud, nsid), + json + }); + +// the knot takes a service token once, so every retry mints a new one +const serviceReply = (who: Account, aud: string, nsid: string, input: object) => + retry429(() => serviceDo(who, config.knotUrl, aud, nsid, { json: input })); + +export const asKnot = (who: Account, nsid: string, input: object) => + serviceReply(who, config.knotDid, nsid, input); + +const serviceCall = async (who: Account, aud: string, nsid: string, input: object) => + ok(`knot ${nsid}`, await serviceReply(who, aud, nsid, input)); + +const knotCall = (who: Account, repo: string, nsid: string, input: object) => + serviceCall(who, repo, nsid, { repo, ...input }); + +// unlike knotCall this doesn't throw on a refusal, the caller checks the status +export const knotReply = (who: Account, repo: string, nsid: string, input: object) => + serviceReply(who, repo, nsid, { repo, ...input }); + +export type Policy = "anyone" | "collaborators" | "members"; +export const setPolicy = async (who: Account, repo: string, policy: Policy) => { + await serviceCall(who, config.knotDid, "sh.tangled.repo.setContributionPolicy", { + repo, + contributionPolicy: policy + }); +}; + +export const pdsCall = async (who: Account, nsid: string, input: object) => + ok( + `pds ${nsid}`, + await retry429(async () => + request(`${config.pdsUrl}/xrpc/${nsid}`, { + method: "POST", + token: await sessionOf(who), + json: { repo: who.did, ...input } + }) + ) + ); + +const admin = async (nsid: string, json: object) => { + if (!config.pdsAdminPassword) + throw new Error("set E2E_PDS_ADMIN_PASSWORD, localinfra/pds.env has none to read"); + return ok( + `pds ${nsid}`, + await request(`${config.pdsUrl}/xrpc/${nsid}`, { + method: "POST", + basic: `admin:${config.pdsAdminPassword}`, + json + }) + ); +}; + +const slug = (text: string) => text.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + +// no other test shares an account's stars, tickets or rate limits +export const accounts = { + create: async (label: string): Promise => { + const name = `${slug(label)}-${stamp()}`; + const handle = handleOf(name); + const { code } = await admin("com.atproto.server.createInviteCode", { useCount: 1 }); + const made = ok( + "pds createAccount", + await request(`${config.pdsUrl}/xrpc/com.atproto.server.createAccount`, { + method: "POST", + json: { + email: `${name}@pds.tngl.boltless.dev`, + handle, + password: config.password, + inviteCode: code + } + }) + ); + const account = { name, handle, did: made.did as string }; + // hydrant only indexes an account once it writes a record in a signal collection + try { + await pdsCall(account, "com.atproto.repo.putRecord", { + collection: "org.tangled.actor.profile", + rkey: "self", + record: { $type: "org.tangled.actor.profile" } satisfies Profile + }); + } catch (cause) { + // the caller never gets the account back, so nothing else would clean it up + await admin("com.atproto.admin.deleteAccount", { did: account.did }).catch(() => {}); + throw cause; + } + return account; + }, + // the knot is closed to anyone it hasn't been told about + makeMember: (who: Account) => + serviceCall(seeded.alice, config.knotDid, "sh.tangled.knot.addMember", { + subject: who.did + }), + removeMember: (who: Account) => + serviceCall(seeded.alice, config.knotDid, "sh.tangled.knot.removeMember", { + subject: who.did + }), + remove: async (who: Account) => { + await admin("com.atproto.admin.deleteAccount", { did: who.did }); + sessions.delete(who.did); + } +}; + +const deleteKnotRepo = (who: Account, repo: { did: string; name: string }) => + serviceCall(who, config.knotDid, "sh.tangled.repo.delete", { + repo: repo.did, + did: who.did, + name: repo.name, + rkey: repo.name + }); + +// a knot repo already gone must not stop the declaration from being deleted +const removeRepo = async (who: Account, repo: { did: string; name: string }) => { + await deleteKnotRepo(who, repo).catch((cause) => { + if (!alreadyGone(cause)) throw cause; + }); + await pdsCall(who, "com.atproto.repo.deleteRecord", { + collection: "org.tangled.repo.declaration", + rkey: repo.name + }); +}; + +export const repos = { + create: async (who: Account, repoName: string, options: RepoOptions = {}) => { + const createdAt = new Date().toISOString(); + const made = await serviceCall(who, config.knotDid, "org.tangled.repo.createRepo", { + name: repoName, + createdAt, + ...(options.spindle ? { spindle: options.spindle } : {}), + ...(options.forkOf + ? { source: { $type: "org.tangled.repo.defs#sourceTangled", did: options.forkOf } } + : {}) + }); + const declaration = await pdsCall(who, "com.atproto.repo.createRecord", { + collection: "org.tangled.repo.declaration", + rkey: repoName, + record: { + $type: "org.tangled.repo.declaration", + repo: made.did, + createdAt + } satisfies Declaration + }).catch(async (cause) => { + // the caller never gets this repo, so nothing else would remove the knot's half + await deleteKnotRepo(who, { did: made.did, name: repoName }).catch(() => {}); + throw cause; + }); + return { + did: made.did as Did, + name: repoName, + declaration: { uri: declaration.uri as string, cid: declaration.cid as string } + }; + }, + remove: removeRepo +}; + +const TICKET = "org.tangled.track.ticket"; + +export const decodeRefs = (value: { refs: { $bytes: string } }) => + Buffer.from(value.refs.$bytes, "base64") + .toString() + .split("\n") + .filter(Boolean) + .map((line) => line.split(" ") as [sha: string, ref: string]); + +// a missing record or repo is a 404 or a RecordNotFound/RepoNotFound body +export const knotRecord = async (uri: string) => { + const { status, body } = await get(recordUrl(config.knotUrl, uri)); + if (status === 404 || ["RecordNotFound", "RepoNotFound"].includes(body?.error)) + return undefined; + return ok("knot getRecord", { status, body }); +}; + +export const refsOf = async ( + uri: string +): Promise<{ cid: string; named: ReturnType } | undefined> => { + const record = await knotRecord(uri); + return record && { cid: record.cid, named: decodeRefs(record.value) }; +}; + +export const knotRecordFor = async (uri: string, doing: string) => { + const found = await knotRecord(uri); + if (!found) throw new Error(`the knot has no ${uri} to ${doing}`); + return found; +}; + +// the knot only takes an edit that names the version it was read at +export const editCob = async ( + who: Account, + uri: string, + doing: string, + change: (stored: Record) => Record +) => { + const { repo, collection, rkey } = partsOf(uri); + const current = await knotRecordFor(uri, doing); + // the knot sets x-tngl-editor itself, so it can't be sent back + const { "x-tngl-editor": _editor, ...stored } = current.value; + return knotReply(who, repo, "com.atproto.repo.putRecord", { + collection, + rkey, + swapRecord: current.cid, + record: change(stored) + }); +}; + +export const ticketRecord = (title: string, more: Partial = {}) => + ({ + $type: TICKET, + title: { $type: "org.tangled.markup.markdown#inline", text: title }, + state: "open", + createdAt: new Date().toISOString(), + ...more + }) satisfies Ticket; + +export const tickets = { + open: async (who: Account, repo: string, title: string): Promise => { + const made = await knotCall(who, repo, "com.atproto.repo.createRecord", { + collection: TICKET, + record: ticketRecord(title) + }); + return { uri: made.uri, cid: made.cid }; + }, + setState: async (who: Account, ticket: Written, state: "open" | "closed"): Promise => { + const made = ok( + "knot com.atproto.repo.putRecord", + await editCob(who, ticket.uri, "change", (stored) => ({ ...stored, state })) + ); + return { uri: made.uri, cid: made.cid }; + } +}; + +const PATCH = "org.tangled.review.patch"; + +export const pulls = { + open: async ( + who: Account, + repo: string, + title: string, + targetBranch: string, + range: { base: string; head: string } + ): Promise<{ ticket: Written; patch: Written }> => { + const ticket = await knotCall(who, repo, "com.atproto.repo.createRecord", { + collection: TICKET, + record: ticketRecord(title, { targetBranch }) + }); + const patch = await knotCall(who, repo, "com.atproto.repo.createRecord", { + collection: PATCH, + record: { + $type: PATCH, + ticket: partsOf(ticket.uri).rkey, + base: { oid: range.base }, + head: { oid: range.head }, + createdAt: new Date().toISOString() + } satisfies Patch + }); + return { + ticket: { uri: ticket.uri, cid: ticket.cid }, + patch: { uri: patch.uri, cid: patch.cid } + }; + } +}; + +const COMMENT = "org.tangled.feed.comment"; + +// the subject's cid must be its current version or the knot refuses it +export const comments = { + add: (who: Account, repo: string, subject: Written, text: string) => + knotReply(who, repo, "com.atproto.repo.createRecord", { + collection: COMMENT, + record: { + $type: COMMENT, + subject: { + $type: "com.atproto.repo.strongRef", + uri: subject.uri as ResourceUri, + cid: subject.cid + }, + body: { $type: "org.tangled.markup.markdown", text }, + createdAt: new Date().toISOString() + } satisfies Comment + }) +}; + +const LABEL = "org.tangled.label.definition"; + +// the knot only takes a client rkey on label definitions, and this one is the name +export const labels = { + define: (who: Account, repo: string, name: string) => + knotReply(who, repo, "com.atproto.repo.createRecord", { + collection: LABEL, + rkey: name, + record: { + $type: LABEL, + name, + color: "#a855f7", + createdAt: new Date().toISOString() + } satisfies LabelDefinition + }) +}; + +export const erase = async (who: Account, record: Pick) => { + const { repo, collection, rkey } = partsOf(record.uri); + if (repo === who.did) { + await pdsCall(who, "com.atproto.repo.deleteRecord", { collection, rkey }); + return; + } + const current = await knotRecordFor(record.uri, "erase"); + await knotCall(who, repo, "com.atproto.repo.deleteRecord", { + collection, + rkey, + swapRecord: current.cid + }); +}; diff --git a/e2e/harness/auth.setup.ts b/e2e/harness/auth.setup.ts new file mode 100644 index 000000000..404015a95 --- /dev/null +++ b/e2e/harness/auth.setup.ts @@ -0,0 +1,19 @@ +import { test as setup } from "@playwright/test"; + +import { authFile, seeded } from "./config"; +import { recorded, watchBrowser } from "./record"; +import { logIn } from "./signin"; + +for (const account of Object.values(seeded)) { + setup(`log in ${account.name}`, async ({ page }) => { + watchBrowser(page.context()); + await setup.step("log in", (info) => + recorded( + info, + () => logIn(page, account), + () => page.url() + ) + ); + await page.context().storageState({ path: authFile(account) }); + }); +} diff --git a/e2e/harness/chores.ts b/e2e/harness/chores.ts new file mode 100644 index 000000000..89b722ac8 --- /dev/null +++ b/e2e/harness/chores.ts @@ -0,0 +1,226 @@ +import { spawn } from "node:child_process"; +import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { accounts, alreadyGone, erase, repos } from "./api"; +import type { Account } from "./config"; +import { forgetKey } from "./git"; +import type { Written } from "./net"; +import type { Slot } from "./vocabulary"; + +export type Chore = + | { kind: "record"; by: Account; uri: string } + | { kind: "repo"; owner: Account; made: { did: string; name: string } } + | { kind: "member"; account: Account } + | { kind: "account"; account: Account }; + +interface Entry { + pid: number; + label: string; + chore: Chore; +} + +interface Undo { + label: string; + id: string; + chore: Chore; +} + +export const LEDGER = resolve(".auth/chores"); + +// flows take minutes, so an old chore with a live pid means that pid got reused +const STALE_MS = 60 * 60_000; + +const alive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch (cause) { + return (cause as NodeJS.ErrnoException).code === "EPERM"; + } +}; + +const stillOwes = (pid: number, id: string) => + alive(pid) && Date.now() - Number(id.slice(0, 13)) < STALE_MS; + +let counter = 0; + +// on disk before this returns, so a kill right after can't lose it +export const owe = (label: string, chore: Chore) => { + mkdirSync(LEDGER, { recursive: true }); + // the counter keeps same-millisecond chores in order, newest first on replay + const stamp = Date.now().toString().padStart(13, "0"); + const id = `${stamp}-${process.pid}-${String(counter++).padStart(6, "0")}`; + const entry: Entry = { pid: process.pid, label, chore }; + writeFileSync(resolve(LEDGER, `${id}.tmp`), JSON.stringify(entry)); + renameSync(resolve(LEDGER, `${id}.tmp`), resolve(LEDGER, `${id}.json`)); + return id; +}; + +export const settled = (id: string) => rmSync(resolve(LEDGER, `${id}.json`), { force: true }); + +// someone else may have finished it already, so gone counts as done +const unlessGone = async (what: () => Promise) => { + try { + await what(); + } catch (cause) { + if (!alreadyGone(cause)) throw cause; + } +}; + +const perform = async (chore: Chore) => { + switch (chore.kind) { + case "record": + return unlessGone(() => erase(chore.by, chore)); + case "repo": + return unlessGone(() => repos.remove(chore.owner, chore.made)); + case "member": + return unlessGone(() => accounts.removeMember(chore.account)); + case "account": + try { + await unlessGone(() => accounts.remove(chore.account)); + } finally { + await forgetKey(chore.account); + } + } +}; + +const ownerOf = (chore: Chore) => + "by" in chore ? chore.by : "owner" in chore ? chore.owner : chore.account; + +// an account goes last, and once it's gone nothing it owns can be reached, so it waits +export const holdsUp = (chore: Chore, failed: Set) => + (chore.kind === "member" || chore.kind === "account") && failed.has(chore.account.did); + +export const replay = async (whose: "mine" | "dead") => { + const failed: string[] = []; + const failedFor = new Set(); + mkdirSync(LEDGER, { recursive: true }); + const names = readdirSync(LEDGER).sort().reverse(); + for (const name of names) { + const path = resolve(LEDGER, name); + const pid = Number(name.split("-")[1]); + if (name.endsWith(".tmp")) { + // a tmp file means the process died before the rename, so it never reached the ledger + if (whose === "dead" && !alive(pid)) rmSync(path, { force: true }); + continue; + } + let entry: Entry; + try { + entry = JSON.parse(readFileSync(path, "utf8")); + } catch (cause) { + // its owner can settle it between the listing and the read + if ((cause as NodeJS.ErrnoException).code === "ENOENT") continue; + failed.push(`${name}: the ledger entry can't be read, ${String(cause).slice(0, 100)}`); + continue; + } + if (whose === "mine" ? entry.pid !== process.pid : stillOwes(entry.pid, name)) continue; + if (holdsUp(entry.chore, failedFor)) continue; + try { + await perform(entry.chore); + rmSync(path, { force: true }); + } catch (cause) { + failedFor.add(ownerOf(entry.chore).did); + failed.push(`${entry.label}: ${String(cause).slice(0, 200)}`); + } + } + return failed; +}; + +// stopped from outside, a worker is killed a moment later, long before its tidying is done. so a +// process in its own session waits for it to go and replays what it still owed +let hooked = false; +let helped = false; +const tidyFromOutside = () => { + if (helped) return; + helped = true; + spawn("pnpm", ["exec", "playwright", "test", "--project=sweep", "tidy.spec.ts"], { + detached: true, + stdio: "ignore", + env: { + ...process.env, + E2E_SWEEP: "1", + E2E_SHARED_BROWSER: "0", + E2E_TIDY_FOR: `${process.pid}` + } + }).unref(); +}; + +const tidyWhenStopped = () => { + if (hooked) return; + hooked = true; + for (const signal of ["SIGTERM", "SIGHUP"] as const) { + process.once(signal, () => { + tidyFromOutside(); + void replay("mine").finally(() => process.exit(1)); + }); + } + // a runner killed by the second ctrl+c leaves its workers to disconnect, with no signal at all + process.once("disconnect", () => { + const owed = readdirSync(LEDGER).some((name) => name.split("-")[1] === `${process.pid}`); + if (owed) tidyFromOutside(); + }); +}; + +export class Tidy { + private undo: Undo[] = []; + private closed = false; + private erasers = new Map(); + private owedUris = new Set(); + + constructor() { + tidyWhenStopped(); + } + + // a step still running at timeout lands after close, so it is tried right now + add(label: string, chore: Chore) { + const entry = { label, id: owe(label, chore), chore }; + if (!this.closed) { + this.undo.unshift(entry); + return; + } + void this.attempt(entry).then((failure) => { + if (failure) console.warn("could not tidy up late:", failure); + }); + } + + private async attempt({ label, id, chore }: Undo) { + try { + await perform(chore); + settled(id); + } catch (cause) { + return `${label}: ${String(cause).slice(0, 300)}`; + } + } + + // the first account to write to a slot erases every record version of it + record(slot: Slot, by: Account, written: Written) { + if (this.owedUris.has(written.uri)) return; + this.owedUris.add(written.uri); + if (!this.erasers.has(slot)) this.erasers.set(slot, by); + this.add(`${slot.nsid} record`, { + kind: "record", + by: this.erasers.get(slot)!, + uri: written.uri + }); + } + + slotHasEraser(slot: Slot) { + return this.erasers.has(slot); + } + + // a leftover throws, so a leak fails the test instead of only logging + async close() { + this.closed = true; + const failedFor = new Set(); + const leftovers: string[] = []; + for (const entry of this.undo) { + if (holdsUp(entry.chore, failedFor)) continue; + const failure = await this.attempt(entry); + if (!failure) continue; + failedFor.add(ownerOf(entry.chore).did); + leftovers.push(failure); + } + if (leftovers.length) throw new Error(`could not tidy up:\n ${leftovers.join("\n ")}`); + } +} diff --git a/e2e/harness/cobgit.ts b/e2e/harness/cobgit.ts new file mode 100644 index 000000000..16aff8473 --- /dev/null +++ b/e2e/harness/cobgit.ts @@ -0,0 +1,255 @@ +import { encode } from "@atcute/cbor"; +import { CODEC_DCBOR, createSync, toString as cidString } from "@atcute/cid"; +import { execFile, spawn } from "node:child_process"; +import { realpath } from "node:fs/promises"; +import type { Socket } from "node:net"; +import { createInterface } from "node:readline"; +import { promisify } from "node:util"; + +import { config } from "./config"; +import { get, partsOf, until } from "./net"; +import { versionsOf, type Version } from "./versions"; + +const exec = promisify(execFile); + +const GIT_IMAGE = process.env.E2E_GIT_IMAGE ?? "docker.io/alpine/git"; + +const installed = (program: string) => + exec(program, ["--version"]).then( + () => true, + () => false + ); + +const findKnot = async () => { + const engine = process.env.E2E_ENGINE ?? ((await installed("podman")) ? "podman" : "docker"); + const root = await realpath(".."); + const listed = await exec(engine, [ + "ps", + "-q", + "--filter", + "label=com.docker.compose.service=knot2", + "--filter", + `label=com.docker.compose.project.working_dir=${root}` + ]); + const container = process.env.E2E_KNOT_CONTAINER ?? listed.stdout.trim().split("\n")[0]; + if (!container) + throw new Error( + `${engine} should be running a knot2 container from the compose project in ${root} but lists none, set E2E_KNOT_CONTAINER for a knot started some other way` + ); + const { stdout } = await exec(engine, [ + "inspect", + "--format", + "{{range .Config.Env}}{{println .}}{{end}}", + container + ]); + const scan = /^KNOT_SCAN_PATH=(.+)$/m.exec(stdout)?.[1]; + if (!scan) + throw new Error(`the knot container ${container} should set KNOT_SCAN_PATH but doesn't`); + return { engine, container, scan }; +}; + +const shardOf = (did: string) => { + const [, method, ...rest] = did.split(":"); + const id = rest.join(":"); + return `${method}/${id.slice(0, 2)}/${id.slice(2)}`; +}; + +// one line in, "end" out, so one container serves every check +const LOOP = `check() { + set -eu + cd "$1" + tip=$(git rev-parse -q --verify "refs/cobs/$2/$3^{commit}") || { echo missing; return 0; } + echo "tip $tip" + for change in $(git rev-list --reverse --topo-order "$tip"); do + head=$(git cat-file commit "$change" | sed '/^$/q') + type=$(echo "$head" | sed -n 's/^cob-type //p') + author=$(echo "$head" | sed -n 's/^cob-author //p') + echo "change $change $type $author $(git ls-tree --name-only "$change" | tr '\\n' ,)" + done + if git cat-file -e "$tip:record" 2>/dev/null; then + echo "record $(git cat-file blob "$tip:record" | base64 -w0)" + fi + echo "payload $(git cat-file blob "$3:payload" | base64 -w0)" +} +while read -r repo nsid root; do + (check "$repo" "$nsid" "$root") 2>&1 || echo "failed $?" + echo end +done +`; + +interface Asked { + lines: string[]; + done: (lines: string[]) => void; + fail: (cause: Error) => void; +} + +const startHelper = async () => { + const box = await findKnot(); + const child = spawn( + box.engine, + [ + "run", + "-i", + "--rm", + "--network=none", + `--volumes-from=${box.container}:ro`, + // git refuses repos owned by another user, the knot's are + "--env=GIT_CONFIG_COUNT=1", + "--env=GIT_CONFIG_KEY_0=safe.directory", + "--env=GIT_CONFIG_VALUE_0=*", + "--entrypoint=sh", + GIT_IMAGE, + "-c", + LOOP + ], + { stdio: ["pipe", "pipe", "pipe"] } + ); + // closing stdin ends it and --rm takes the container with it + const answers = child.stdout as unknown as Socket; + child.unref(); + for (const pipe of [child.stdin, child.stdout, child.stderr]) + (pipe as unknown as Socket).unref(); + const waiting: Asked[] = []; + let said = ""; + child.stderr.on("data", (chunk) => (said = (said + chunk).slice(-2000))); + createInterface({ input: child.stdout }).on("line", (line) => { + const asked = waiting[0]; + if (!asked) return; + if (line !== "end") return void asked.lines.push(line); + waiting.shift(); + if (waiting.length === 0) answers.unref(); + asked.done(asked.lines); + }); + const ended = new Promise((resolve) => { + const gone = (why: string) => + resolve(new Error(`the git helper in ${GIT_IMAGE} ${why}: ${said.trim()}`)); + child.on("error", (cause) => gone(cause.message)); + child.on("exit", (code) => gone(`exited with ${code}`)); + }); + void ended.then((cause) => { + helper = undefined; + for (const asked of waiting.splice(0)) asked.fail(cause); + }); + return { + scan: box.scan, + ask: (line: string) => + new Promise((done, fail) => { + answers.ref(); + waiting.push({ lines: [], done, fail }); + child.stdin.write(`${line}\n`); + }) + }; +}; + +let helper: ReturnType | undefined; + +interface Change { + id: string; + type: string; + author: string; + files: string[]; +} + +interface CobInGit { + tip: string; + changes: Change[]; + record?: Buffer; + payload: Buffer; +} + +// ls-remote can't see refs/cobs and the knot hides them from reads, so read the disk +const cobInGit = async ( + repoDid: string, + nsid: string, + root: string +): Promise => { + const git = await (helper ??= startHelper()); + const lines = await git.ask(`${git.scan}/${shardOf(repoDid)} ${nsid} ${root}`); + if (lines[0] === "missing") return undefined; + const field = (name: string) => + lines + .filter((line) => line.startsWith(`${name} `)) + .map((line) => line.slice(name.length + 1)); + if (field("payload").length === 0) + throw new Error(`git couldn't read refs/cobs/${nsid}/${root}: ${lines.join(" ")}`); + const record = field("record")[0]; + return { + tip: field("tip")[0], + changes: field("change").map((line) => { + const [id, type, author, files] = line.split(" "); + return { id, type, author, files: files.split(",").filter(Boolean) }; + }), + record: record === undefined ? undefined : Buffer.from(record, "base64"), + payload: Buffer.from(field("payload")[0], "base64") + }; +}; + +const cidOf = (bytes: Buffer) => cidString(createSync(CODEC_DCBOR, bytes)); + +// the root payload holds address {rkey, collection} as dag-cbor, so match those bytes +const namesRecord = (payload: Buffer, collection: string, rkey: string) => + payload.includes(Buffer.concat([encode("address"), encode({ rkey, collection })])); + +const signingKey = async () => { + const { body } = await get(`${config.knotUrl}/.well-known/did.json`); + const methods: { id: string; publicKeyMultibase: string }[] = body?.verificationMethod ?? []; + return methods.find((method) => method.id.endsWith("#atproto"))?.publicKeyMultibase; +}; + +let knotKey: Promise | undefined; + +// a cid of undefined means the record was deleted, so the chain ends in a tombstone +const gitDisagrees = async ( + { uri, cid }: { uri: string; cid?: string }, + versions: Version[] +): Promise => { + const { repo, collection, rkey } = partsOf(uri); + if (versions.length === 0) return "listCobVersions lists no versions to look up in git"; + const root = versions[0].changeId; + const ref = `refs/cobs/${collection}/${root}`; + const [cob, key] = await Promise.all([ + cobInGit(repo, collection, root), + (knotKey ??= signingKey()) + ]); + if (!cob) return `there is no ${ref}`; + const last = versions.at(-1)!; + if (cob.tip !== last.changeId) + return `${ref} points at ${cob.tip} but listCobVersions ends at ${last.changeId}`; + const walked = cob.changes.map((change) => change.id); + const listed = versions.map((version) => version.changeId); + if (JSON.stringify([...walked].sort()) !== JSON.stringify([...listed].sort())) + return `git walks ${walked.join(" ")} from ${ref} but listCobVersions lists ${listed.join(" ")}`; + const stranger = cob.changes.find( + (change) => change.type !== collection || change.author !== key + ); + if (stranger) + return `change ${stranger.id} says cob-type ${stranger.type} by ${stranger.author}, it should be ${collection} by the knot's key ${key}`; + if (!namesRecord(cob.payload, collection, rkey)) + return `the root change of ${ref} doesn't name rkey ${rkey} in ${collection}`; + const holds = cob.changes.at(-1)!.files; + if (cid === undefined) + return holds.includes("tombstone") && !cob.record + ? undefined + : `the tip of ${ref} should hold a tombstone but holds ${holds.join(", ")}`; + if (!cob.record) return `the tip of ${ref} should hold a record but holds ${holds.join(", ")}`; + const hashed = cidOf(cob.record); + return hashed === cid + ? undefined + : `the record blob at the tip of ${ref} hashes to ${hashed} but the write answered ${cid}`; +}; + +// versions are read again only when git disagrees, because a write can land between reads +export const checkGit = (written: { uri: string; cid?: string }, read: Version[]) => { + const { collection } = partsOf(written.uri); + let versions: Version[] | undefined = read; + return until( + `refs/cobs/${collection}/${read[0]?.changeId} should end at ${written.cid ?? "a tombstone"}`, + 10_000, + async () => { + versions ??= (await versionsOf(written.uri)).versions; + const problem = await gitDisagrees(written, versions); + versions = undefined; + return [!problem, problem ?? ""]; + } + ); +}; diff --git a/e2e/harness/config.ts b/e2e/harness/config.ts new file mode 100644 index 000000000..e3e131b2e --- /dev/null +++ b/e2e/harness/config.ts @@ -0,0 +1,64 @@ +import { readFileSync } from "node:fs"; + +const host = (name: string) => `https://${name}.tngl.boltless.dev`; + +// localinfra's env file has the dev pds admin password, so a local run needs no setup +const devPdsAdmin = () => { + try { + const env = readFileSync("../localinfra/pds.env", "utf8"); + return env.match(/^PDS_ADMIN_PASSWORD=(.+)$/m)?.[1]; + } catch { + return undefined; + } +}; + +const loginThrough = (value = "minted") => { + if (value !== "minted" && value !== "ui") + throw new Error(`--login is ui or minted, not ${value}`); + return value; +}; + +// every stack name resolves to the host's caddy in the browser +export const browserArgs = ["--host-resolver-rules=MAP *.tngl.boltless.dev 127.0.0.1"]; + +export const config = { + webUrl: process.env.E2E_WEB_URL ?? host("web"), + pdsUrl: process.env.E2E_PDS_URL ?? host("pds"), + knotUrl: process.env.E2E_KNOT_URL ?? host("knot2"), + hydrantUrl: process.env.E2E_HYDRANT_URL ?? host("hydrant"), + bobbinUrl: process.env.E2E_BOBBIN_URL ?? host("bobbin"), + spindleUrl: process.env.E2E_SPINDLE_URL ?? host("spindle"), + // deliberi follows the bobbin url, so a run pointed at another bobbin gets its own deliberi + deliberiUrl: + process.env.E2E_DELIBERI_URL ?? + (process.env.E2E_BOBBIN_URL ?? host("bobbin")).replace("bobbin", "deliberi"), + knotDid: "did:web:knot2.tngl.boltless.dev", + spindleDid: "did:web:spindle.tngl.boltless.dev", + // ssh isn't behind caddy, it's the port the compose file publishes + knotSsh: process.env.E2E_KNOT_SSH ?? "127.0.0.1:2223", + password: process.env.E2E_PASSWORD ?? "password", + login: loginThrough(process.env.E2E_LOGIN), + pdsAdminPassword: process.env.E2E_PDS_ADMIN_PASSWORD ?? devPdsAdmin() +}; + +export interface Account { + name: string; + handle: string; + did: string; +} + +export const handleOf = (name: string) => `${name}.pds.tngl.boltless.dev`; + +const seed = (name: string, did: string): Account => ({ name, handle: handleOf(name), did }); + +// seeded by localinfra's init-data, so they exist on every stack +export const seeded = { + alice: seed("alice", "did:plc:igzuueb63dytyy3tpaweofuu"), + bob: seed("bob", "did:plc:xbserj4jfuhzkbl3mszkvvrb"), + charlie: seed("charlie", "did:plc:pgkgoz6rpvmgynswviohltpx"), + david: seed("david", "did:plc:ryxq2u2b6sadhhjflpwiefg4") +}; + +export const isSeeded = (account: Account) => + Object.values(seeded).some((known) => known.did === account.did); +export const authFile = (account: Account) => `.auth/${account.name}.json`; diff --git a/e2e/harness/firehose.ts b/e2e/harness/firehose.ts new file mode 100644 index 000000000..799dc827d --- /dev/null +++ b/e2e/harness/firehose.ts @@ -0,0 +1,205 @@ +import { ComAtprotoSyncSubscribeRepos } from "@atcute/atproto"; +import { FirehoseError, FirehoseSubscription, type MessageOf } from "@atcute/firehose"; +import { WebSocket } from "undici"; + +import { config } from "./config"; +import { Frames } from "./frames"; +import { dispatcher } from "./net"; + +type Action = "create" | "update" | "delete"; +interface Seen { + id: number; + action: Action; + cid?: string; + at: number; +} +type Message = MessageOf; + +const BUDGET = 10_000; +const isAction = (action: string): action is Action => + action === "create" || action === "update" || action === "delete"; +const told = (seen: Seen) => `${seen.action}${seen.cid ? ` ${seen.cid}` : ""} #${seen.id}`; + +// the subscription makes its own sockets, so the stack's dns and dev ca ride in on a subclass +class Dispatched extends WebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, { protocols, dispatcher }); + } +} + +export class Firehose extends Frames { + private stopper?: AbortController; + private last = -1; + private stopped = true; + private downSince?: number; + private lastWords = ""; + private lost = ""; + + constructor( + private name: string, + private base: string + ) { + super(); + } + + async start() { + if (!this.stopped) return; + this.stopped = false; + const stopper = (this.stopper = new AbortController()); + const service = this.base.replace(/^http/, "ws"); + const url = `${service}/xrpc/${ComAtprotoSyncSubscribeRepos.mainSchema.nsid}`; + await new Promise((open, fail) => { + let up = false; + const timer = setTimeout( + () => fail(new Error(`${url} would not open within ${BUDGET / 1000}s`)), + BUDGET + ); + const subscription = new FirehoseSubscription({ + service, + nsid: ComAtprotoSyncSubscribeRepos.mainSchema, + // both servers replay strictly after the cursor, unlike hydrant's inclusive id + params: () => (this.last < 0 ? {} : { cursor: this.last }), + signal: stopper.signal, + ws: { + WebSocket: Dispatched, + minReconnectionDelay: 250, + maxReconnectionDelay: 2_000 + }, + onConnectionOpen: () => { + up = true; + clearTimeout(timer); + this.downSince = undefined; + open(); + }, + onConnectionError: () => { + this.downSince ??= Date.now(); + this.lastWords = `${url} would not open`; + if (up) return; + clearTimeout(timer); + fail(new Error(this.lastWords)); + }, + onConnectionClose: (event) => { + if (this.stopped) return; + this.downSince ??= Date.now(); + this.lastWords ||= `close ${event.code} ${event.reason}`.trim(); + // no first seq means there is no safe cursor to recover the gap from + if (this.last < 0) + this.lost = "disconnected before the first cursor, frames may be missing"; + this.wake(); + }, + onError: (cause) => this.unreadable(cause) + }); + void this.read(subscription); + }).catch((cause) => { + this.stop(); + throw cause; + }); + } + + stop() { + this.stopped = true; + this.stopper?.abort(); + this.wake(); + } + + mark() { + return this.last; + } + + private describe(since: number) { + const parts = [`the watcher is at #${this.last}`]; + if (since < this.forgotUpTo) parts.push(`it forgot frames up to #${this.forgotUpTo}`); + if (this.downSince !== undefined) { + const down = ((Date.now() - this.downSince) / 1000).toFixed(1); + parts.push( + `it has been disconnected for ${down}s (${this.lastWords || "no reason given"})` + ); + } else if (this.lastWords) parts.push(this.lastWords); + if (this.lost) parts.push(this.lost); + return parts.join(", "); + } + + private unreadable(cause: unknown) { + this.lost = + cause instanceof FirehoseError + ? `${this.name} said ${cause.error}: ${cause.message}` + : `could not read a frame: ${(cause as Error).message}`; + this.wake(); + } + + private async read(messages: AsyncIterable) { + try { + for await (const message of messages) { + try { + this.take(message); + } catch (cause) { + this.unreadable(cause); + } + } + } catch (cause) { + if (!this.stopped) this.unreadable(cause); + } + } + + private take(message: Message) { + if (message.$type === "com.atproto.sync.subscribeRepos#info") { + this.lastWords = `${this.name} said ${message.name}: ${message.message}`; + if (message.name === "OutdatedCursor") this.lost = this.lastWords; + this.wake(); + return; + } + if (message.seq <= this.last) return; + if (message.$type === "com.atproto.sync.subscribeRepos#commit") { + if (message.tooBig && message.ops.length === 0) + this.lost = `commit #${message.seq} for ${message.repo} is tooBig and has no ops`; + for (const op of message.ops) { + if (!isAction(op.action)) + throw new Error(`commit #${message.seq} has an invalid op for ${op.path}`); + if (op.action !== "delete" && !op.cid) + throw new Error(`commit #${message.seq} has no cid for ${op.path}`); + this.append(`at://${message.repo}/${op.path}`, { + id: message.seq, + action: op.action, + cid: op.cid?.$link, + at: Date.now() + }); + } + } + this.last = message.seq; + this.lastWords = ""; + this.wake(); + } + + async check({ uri, cid }: { uri: string; cid?: string }, action?: Action, since = -1) { + const wanted = cid === undefined ? "delete" : (action ?? "create or update"); + const what = `${this.name}'s firehose should announce ${wanted} of ${uri}${cid ? ` at ${cid}` : ""}`; + const deadline = Date.now() + BUDGET; + for (;;) { + if (this.stopped) + throw new Error(`${what} but this process never opened it or stopped it`); + const frames = this.seen(uri).filter((frame) => frame.id > since); + const newest = frames.at(-1); + if ( + newest && + newest.cid === cid && + (cid === undefined + ? newest.action === "delete" + : newest.action !== "delete" && (!action || newest.action === action)) + ) + return newest; + const after = since < 0 ? "since the watcher opened" : `after #${since}`; + const said = frames.length + ? `${after} it announced ${frames.slice(-5).map(told).join(", ")}` + : `it announced nothing for that uri ${after}`; + const left = deadline - Date.now(); + if (left <= 0) + throw new Error( + `${what}, gave up after ${BUDGET / 1000}s: ${said}, ${this.describe(since)}` + ); + await this.changed(left); + } + } +} + +export const knotFirehose = new Firehose("knot", config.knotUrl); +export const pdsFirehose = new Firehose("pds", config.pdsUrl); diff --git a/e2e/harness/flow.ts b/e2e/harness/flow.ts new file mode 100644 index 000000000..973cb9c44 --- /dev/null +++ b/e2e/harness/flow.ts @@ -0,0 +1,366 @@ +import { expect, test, type Browser, type TestStepInfo } from "@playwright/test"; +import { fileURLToPath } from "node:url"; + +import { setPolicy, type Policy } from "./api"; +import { browserArgs, config, type Account } from "./config"; +import type { Reply } from "./net"; +import { recorded } from "./record"; +import { Run, openPull, openTicket, pushFiles, traceNewRepo, waitForMirror } from "./run"; +import { bobbin, hydrant, knot, lookBefore, settle, type Hop } from "./services"; +import { watcher } from "./stream"; +import { knotFirehose, pdsFirehose } from "./firehose"; +import type { Held } from "./tabs"; +import { + replacing, + resolve, + through, + type Ctx, + type Lazy, + type Slot, + type Target, + type Where +} from "./vocabulary"; + +export * from "./vocabulary"; +export { bobbin, git, hydrant, knot, knotCopy, pds } from "./services"; +export { note } from "./record"; + +interface Place { + file: string; + line: number; + column: number; +} + +interface Step { + name: string; + run: (run: Run) => Promise; + checkpoint?: boolean; + asked: Error; +} + +// reading .stack is slow, so --list and steps that never run skip it +const askedHere = () => { + const limit = Error.stackTraceLimit; + Error.stackTraceLimit = 30; + const asked = new Error(); + Error.stackTraceLimit = limit; + return asked; +}; + +const placeOf = (frame: string): Place | undefined => { + const found = /\(?([^()\s]+):(\d+):(\d+)\)?$/.exec(frame); + if (!found) return undefined; + // specs load as modules, so their frames come as file:// urls + const file = found[1].startsWith("file:") ? fileURLToPath(found[1]) : found[1]; + return { file, line: +found[2], column: +found[3] }; +}; + +const placesOf = (asked: Error) => { + const places = (asked.stack ?? "") + .split("\n") + .slice(1) + .map(placeOf) + .filter((place): place is Place => !!place && !place.file.endsWith("harness/flow.ts")); + const at = places.find((place) => place.file.includes("/specs/")) ?? places[0]; + const via = places[0] === at ? undefined : places[0]; + return { at, via }; +}; + +const onlyPresent = (verb: string, what: Target) => { + if (what.absent) + throw new Error(`can't ${verb} ${what.describe}, a not() target is only for .sees()`); +}; + +const route = (hops: Hop[]) => hops.map((hop) => hop.name).join(", "); + +// never add a then() here, or playwright treats the chain as a promise +class Flow { + constructor(private steps: Step[]) {} + + private add(name: string, run: Step["run"], checkpoint?: boolean) { + this.steps.push({ name, run, checkpoint, asked: askedHere() }); + return this; + } + + actor(who: string | Account, options: { login?: "ui" } = {}) { + if (typeof who === "string") { + const kind = `new account${options.login ? ", login page" : ""}`; + return this.add(`actor ${who} (${kind})`, (run) => run.switchTo(who, options)); + } + if (options.login) + throw new Error( + `${who.name} is seeded, it logged in through the login page before the run` + ); + return this.add(`actor ${who.name} (seeded)`, (run) => run.switchTo(who, options)); + } + + repo(options: { spindle?: string } = {}) { + return this.add("repo", (run) => run.own(options)); + } + + fork() { + return this.add("fork", async (run) => { + const upstream = run.repo; + await run.own({ forkOf: upstream.did }); + run.upstream = upstream; + }); + } + + push(files: Record, options: { branch?: string; base?: string } = {}) { + return this.add("push", (run) => pushFiles(run, files, options)); + } + + // a page that makes a repo can't make its actor a knot member + member() { + return this.add("member", (run) => run.becomeMember()); + } + + joinedKnot() { + return this.add("joined the knot", async (run) => run.oweMembership()); + } + + mirrored() { + return this.add("mirrored", waitForMirror); + } + + pull(pull: Slot, patch: Slot, options: { title?: string; from: string; into: string }) { + return this.add("pull", (run) => openPull(run, pull, patch, options)); + } + + // for a record a service changed on its own, like a merge closing its ticket + learn(slot: Slot) { + return this.add(`learn ${slot.nsid}`, (run) => run.learn(slot, run.get(slot).uri, "learn")); + } + + ticket(slot: Slot, options: { title?: string; state?: "open" | "closed" } = {}) { + return this.add(`ticket ${options.state ?? "open"}`, (run) => + openTicket(run, slot, options) + ); + } + + open(at: Where) { + return this.add(`open ${at.describe}`, async (run) => { + await run.openTab(); + // the page is inert until it hydrates + await run.page.goto(at.path(run)); + await run.settle(); + }); + } + + window(name = "") { + return this.add(name ? `window ${name}` : "first window", (run) => run.openWindow(name)); + } + + at(here: Where) { + return this.add(`at ${here.describe}`, async (run) => { + await expect(run.page).toHaveURL(`${config.webUrl}${here.path(run)}`); + }); + } + + click(what: Target) { + onlyPresent("click", what); + return this.add(`click ${what.describe}`, (run) => what.locate(run.page, run).click()); + } + + fill(what: Target, value: Lazy) { + onlyPresent("fill", what); + return this.add(`fill ${what.describe}`, (run) => + what.locate(run.page, run).fill(resolve(value, run)) + ); + } + + press(key: string) { + return this.add(`press ${key}`, async (run) => { + await run.page.keyboard.press(key); + await run.settle(); + }); + } + + reload() { + return this.add("reload", async (run) => { + await run.page.reload(); + await run.settle(); + }); + } + + sees(what: Target, words?: string) { + if (what.absent && words !== undefined) { + throw new Error(`${what.describe} can't say "${words}", there is nothing to read`); + } + return this.add(`sees ${what.describe}${words ? ` saying ${words}` : ""}`, async (run) => { + const found = what.locate(run.page, run); + if (what.absent) await expect(found).toHaveCount(0); + else if (words === undefined) await expect(found).toBeVisible(); + else await expect(found).toHaveText(words); + }); + } + + // the write is held here, so the server hasn't seen it yet + sends( + slot: Slot, + to: { name: string; host: string }, + body: Lazy>, + method?: Held["method"] + ) { + return this.add(`sends ${slot.nsid} to ${to.name}`, async (run) => { + const held = await run.hold(slot); + expect(held.host, `${slot.nsid} should be sent to the ${to.name}`).toBe(to.host); + if (method) expect(held.method, `${slot.nsid} should be a ${method}`).toBe(method); + expect(held.body).toMatchObject(resolve(body, run)); + }); + } + + traceNewRepo(name: Lazy, hops: Hop[]) { + return this.add("trace the new repo", (run) => traceNewRepo(run, resolve(name, run), hops)); + } + + sendsNothing(slot: Slot) { + return this.add(`sends no ${slot.nsid}`, (run) => run.expectNoWrite(slot)); + } + + trace(slot: Slot, hops: Hop[]) { + return this.add(`trace ${slot.nsid} through ${route(hops)}`, async (run) => { + const held = await run.hold(slot); + const before = { ...(await lookBefore(hops, held)), released: Date.now() }; + const written = await run.release(slot, held); + await settle(written, hops, run.actor, slot.nsid, held.method, before); + }); + } + + follow(slot: Slot, hops: Hop[]) { + return this.add(`follow ${slot.nsid} through ${route(hops)}`, (run) => + settle(run.get(slot), hops, run.actor, slot.nsid, "settled") + ); + } + + // the knot's own copy of the record says this state, whatever the page shows + knotStored(slot: Slot, state: string) { + return this.add(`the knot stored it as ${state}`, async (run) => { + expect((await knot.has(run.get(slot))).state).toBe(state); + }); + } + + // no page control for this, so it goes through the api + policy(policy: Policy) { + return this.add(`policy ${policy}`, (run) => setPolicy(run.actor, run.repo.did, policy)); + } + + rejects(name: string, attempt: (ctx: Ctx) => Promise, status: number | number[]) { + const allowed = [status].flat(); + return this.add(`rejects ${name}`, async (run) => { + const reply = await attempt(run); + expect( + allowed, + `${name} answered ${reply.status} ${JSON.stringify(reply.body)}` + ).toContain(reply.status); + }); + } + + refused(slot: Slot, status: number) { + return this.add(`${slot.nsid} is refused with ${status}`, async (run) => { + await run.refuse(slot, await run.hold(slot), status); + }); + } + + // the button only flips once bobbin has the new state, so a held write changes nothing + changeState(slot: Slot, to: "open" | "closed", clicking: Target, then: Target) { + return this.click(clicking) + .sends(slot, knot, replacing(slot, { state: to }), "put") + .sees(clicking) + .trace(slot, through(knot, hydrant, bobbin)) + .knotStored(slot, to) + .sees(then); + } + + checkpoint(name: string) { + return this.add(`checkpoint: ${name}`, async () => {}, true); + } + + step(name: string, run: (ctx: Ctx) => Promise) { + return this.add(name, run); + } + + use(chain: (flow: Flow) => Flow) { + return chain(this); + } +} + +export type { Flow }; + +// teardown still gets its own time after a test times out, which is why the world closes there +const withWorld = test.extend<{ world: Run }, { browser: Browser; stream: void }>({ + browser: [ + async ({ playwright }, use) => { + const shared = process.env.E2E_BROWSER_WS; + const browser = shared + ? await playwright.chromium.connect(shared) + : await playwright.chromium.launch({ args: browserArgs }); + await use(browser); + await browser.close(); + }, + { scope: "worker" } + ], + // open before the worker's first write so every hop has its frames + stream: [ + async ({}, use) => { + try { + await watcher.start(); + await Promise.all([knotFirehose.start(), pdsFirehose.start()]); + await use(); + } finally { + watcher.stop(); + knotFirehose.stop(); + pdsFirehose.stop(); + } + }, + { scope: "worker", auto: true } + ], + world: async ({ browser }, use) => { + const run = new Run(browser); + await use(run); + await run.close(); + } +}); + +export const flow = (name: string) => { + const steps: Step[] = []; + const asked = askedHere(); + withWorld(name, async ({ world }) => { + const recording = (run: Step["run"]) => (info: TestStepInfo) => + recorded( + info, + () => run(world), + () => world.pageUrl + ); + let reached = "no checkpoint"; + try { + for (const step of steps) { + const { at, via } = placesOf(step.asked); + const { actor, repo } = world.known; + await test.step(step.name, recording(step.run), { + location: at, + params: { + ...(via && { via: `${via.file}:${via.line}` }), + ...(actor && { actor }), + ...(repo && { repo }) + }, + subtitle: repo + }); + if (!step.checkpoint) continue; + reached = step.name; + test.info().annotations.push({ type: "checkpoint", description: step.name }); + } + await test.step( + "nothing else was written", + recording((run) => run.expectQuiet()), + { + location: placesOf(asked).at + } + ); + } catch (cause) { + test.info().annotations.push({ type: "stopped after", description: reached }); + throw cause; + } + }); + return new Flow(steps); +}; diff --git a/e2e/harness/frames.ts b/e2e/harness/frames.ts new file mode 100644 index 000000000..3cb85c24a --- /dev/null +++ b/e2e/harness/frames.ts @@ -0,0 +1,43 @@ +// every worker's writes come through here, and a run is only a few thousand frames +const KEPT = 50_000; + +export class Frames { + private records = new Map(); + private kept = 0; + protected forgotUpTo = -1; + private waiting = new Set<() => void>(); + + seen(uri: string) { + return this.records.get(uri) ?? []; + } + + // wake on the next frame so a check passes the moment it lands + changed(ms: number) { + return new Promise((wake) => { + const done = () => { + clearTimeout(timer); + this.waiting.delete(done); + wake(); + }; + const timer = setTimeout(done, ms); + this.waiting.add(done); + }); + } + + protected wake() { + for (const wake of this.waiting) wake(); + } + + protected append(uri: string, frame: T) { + const seen = this.seen(uri); + seen.push(frame); + this.records.set(uri, seen); + this.kept++; + while (this.kept > KEPT) { + const [oldest, dropped] = this.records.entries().next().value!; + this.records.delete(oldest); + this.kept -= dropped.length; + this.forgotUpTo = Math.max(this.forgotUpTo, dropped.at(-1)!.id); + } + } +} diff --git a/e2e/harness/git.ts b/e2e/harness/git.ts new file mode 100644 index 000000000..054b3041d --- /dev/null +++ b/e2e/harness/git.ts @@ -0,0 +1,228 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { promisify } from "node:util"; + +import { getOk, pdsCall } from "./api"; +import { config, type Account } from "./config"; +import type { PublicKey } from "./lexicons"; +import { sleep, xrpc } from "./net"; +import type { Ctx } from "./vocabulary"; + +const exec = promisify(execFile); +const KEYS = resolve(".auth/keys"); + +const keygen = async (who: Account) => { + const path = join(KEYS, who.name); + await mkdir(KEYS, { recursive: true }); + await exec("ssh-keygen", [ + "-q", + "-t", + "ed25519", + "-N", + "", + "-C", + `e2e-${who.name}`, + "-f", + path + ]); + return path; +}; + +// keyed by the key too, makeKey rewrites the same path with a new one +const registered = new Set(); + +// the knot picks up sh.tangled.publicKey from the pds when it connects +const keyFileOf = async (who: Account) => { + const path = join(KEYS, who.name); + await readFile(path).catch(() => keygen(who)); + const line = (await readFile(`${path}.pub`, "utf8")).trim(); + if (registered.has(`${who.did} ${line}`)) return path; + const listed = await getOk( + xrpc(config.pdsUrl, "com.atproto.repo.listRecords", { + repo: who.did, + collection: "sh.tangled.publicKey" + }) + ); + if (!listed.records.some((r: any) => r.value.key === line)) { + await pdsCall(who, "com.atproto.repo.createRecord", { + collection: "sh.tangled.publicKey", + record: { + $type: "sh.tangled.publicKey", + key: line, + name: `e2e-${who.name}`, + createdAt: new Date().toISOString() + } satisfies PublicKey + }); + } + registered.add(`${who.did} ${line}`); + return path; +}; + +// a key is only worth keeping while its account exists +export const forgetKey = async (who: Account) => { + const path = join(KEYS, who.name); + await rm(path, { force: true }); + await rm(`${path}.pub`, { force: true }); +}; + +// BatchMode and ConnectTimeout make a dead host fail fast instead of prompting +const sshFor = (key: string) => + `ssh -i ${key} -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR`; + +// as the ssh user the knot reads keys on the spot, plain "git" only sees the ones gathered so far +const remoteOf = (who: Account, repoDid: string) => { + const [host, port] = config.knotSsh.split(":"); + return `ssh://${who.handle}@${host}:${port}/${repoDid}`; +}; + +export const sshAs = async (who: Account) => sshFor(await keyFileOf(who)); + +const gitTo = async (who: Account, repoDid: string) => ({ + ssh: await sshAs(who), + remote: remoteOf(who, repoDid) +}); + +// the knot allows four git ops at once per address, and every worker shares one +const retryWhenBusy = async (run: () => Promise): Promise => { + for (let attempt = 1; ; attempt++) { + try { + return await run(); + } catch (cause: any) { + const busy = /too many concurrent operations/.test(String(cause.stderr ?? cause)); + if (!busy || attempt >= 30) throw cause; + await sleep(200 + Math.random() * 400); + } + } +}; + +const GIT_TIMEOUT = 120_000; + +const git = async (cwd: string, ssh: string, ...args: string[]) => + retryWhenBusy(() => + exec("git", ["-c", "user.name=e2e", "-c", "user.email=e2e@example.com", ...args], { + cwd, + env: { ...process.env, GIT_SSH_COMMAND: ssh }, + timeout: GIT_TIMEOUT + }) + ); + +const inScratch = async (run: (dir: string) => Promise) => { + const dir = await mkdtemp(join(tmpdir(), "e2e-git-")); + try { + return await run(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}; + +export const push = async ( + who: Account, + repoDid: string, + files: Record, + { branch = "main", base }: { branch?: string; base?: string } = {} +) => + inScratch(async (dir) => { + const { ssh, remote } = await gitTo(who, repoDid); + const heads = Object.keys(await lsRemote(who, repoDid, "refs/heads/")); + if (heads.length > 0) { + const known = heads.includes(`refs/heads/${branch}`); + const start = known ? branch : base; + await git( + dir, + ssh, + "clone", + "-q", + "--depth=1", + ...(start ? ["--single-branch", "--branch", start] : []), + remote, + "." + ); + if (!known) await git(dir, ssh, "checkout", "-q", "-b", branch); + } else { + // knot repos are sha256, and git refuses a sha1 history pushed into one + await git(dir, ssh, "init", "-q", "--object-format=sha256", "-b", branch); + } + for (const [file, text] of Object.entries(files)) { + await mkdir(dirname(join(dir, file)), { recursive: true }); + await writeFile(join(dir, file), text); + } + await git(dir, ssh, "add", "-A"); + await git(dir, ssh, "commit", "-q", "-m", "e2e"); + const sha = (await git(dir, ssh, "rev-parse", "HEAD")).stdout.trim(); + await git(dir, ssh, "push", remote, `${branch}:${branch}`); + return sha; + }); + +export const lsRemote = async (who: Account, repoDid: string, prefix = "") => { + const { ssh, remote } = await gitTo(who, repoDid); + const { stdout } = await git(tmpdir(), ssh, "ls-remote", remote); + return Object.fromEntries( + stdout + .trim() + .split("\n") + .filter(Boolean) + .map((line) => line.split("\t").reverse()) + .filter(([ref]) => ref.startsWith(prefix)) + ) as Record; +}; + +export const headOf = async (who: Account, repoDid: string) => { + const { ssh, remote } = await gitTo(who, repoDid); + const { stdout } = await git(tmpdir(), ssh, "ls-remote", "--symref", remote, "HEAD"); + return /^ref: refs\/heads\/(\S+)\tHEAD/m.exec(stdout)?.[1]; +}; + +export const deleteBranch = async (who: Account, repoDid: string, branch: string) => + inScratch(async (dir) => { + const { ssh, remote } = await gitTo(who, repoDid); + await git(dir, ssh, "init", "-q", "--object-format=sha256"); + await git(dir, ssh, "push", remote, "--delete", branch); + }); + +export const pushAnnotatedTag = async ( + who: Account, + repoDid: string, + tag: string, + message: string +) => + inScratch(async (dir) => { + const { ssh, remote } = await gitTo(who, repoDid); + await git(dir, ssh, "clone", "-q", "--depth=1", remote, "."); + await git(dir, ssh, "tag", "-a", tag, "-m", message); + await git(dir, ssh, "push", "-q", remote, tag); + }); + +interface Key { + path: string; + pub: string; + fingerprint: string; +} + +const made = new WeakMap(); + +// writes to the path pushes read, so a push uses this key +export const makeKey = async (ctx: Ctx) => { + const path = await keygen(ctx.actor); + const { stdout } = await exec("ssh-keygen", ["-lf", `${path}.pub`]); + const pub = (await readFile(`${path}.pub`, "utf8")).trim(); + made.set(ctx, { path, pub, fingerprint: stdout.split(" ")[1] }); +}; + +export const keyOf = (ctx: Ctx) => made.get(ctx)!; + +// retryWhenBusy keeps a busy knot from reading as a bad key +export const lsRemoteWith = async (ctx: Ctx) => { + try { + await retryWhenBusy(() => + exec("git", ["ls-remote", remoteOf(ctx.actor, ctx.repo.did)], { + env: { ...process.env, GIT_SSH_COMMAND: sshFor(keyOf(ctx).path) }, + timeout: 60_000 + }) + ); + return "listed"; + } catch (cause: any) { + return String(cause.stderr ?? cause); + } +}; diff --git a/e2e/harness/global.ts b/e2e/harness/global.ts new file mode 100644 index 000000000..99d680333 --- /dev/null +++ b/e2e/harness/global.ts @@ -0,0 +1,19 @@ +import { chromium } from "@playwright/test"; + +import { replay } from "./chores"; +import { browserArgs } from "./config"; + +export default async () => { + // a killed run leaves leaks, so the next run reports them and the end fails on them + for (const what of await replay("dead")) console.warn("an earlier run's leftover:", what); + const server = + process.env.E2E_SHARED_BROWSER === "0" + ? undefined + : await chromium.launchServer({ args: browserArgs }); + if (server) process.env.E2E_BROWSER_WS = server.wsEndpoint(); + return async () => { + await server?.close(); + const failed = await replay("dead"); + if (failed.length) throw new Error(`could not tidy up:\n ${failed.join("\n ")}`); + }; +}; diff --git a/e2e/harness/lexicons.ts b/e2e/harness/lexicons.ts new file mode 100644 index 000000000..3acf37477 --- /dev/null +++ b/e2e/harness/lexicons.ts @@ -0,0 +1,17 @@ +// types only, so a run never loads anything from web/ +import type {} from "../../web/src/lib/api/lexicons/index"; +import type { Records } from "@atcute/lexicons/ambient"; + +export type RecordNsid = keyof Records; + +export type { Main as Profile } from "../../web/src/lib/api/lexicons/types/org/tangled/actor/profile"; +export type { Main as Comment } from "../../web/src/lib/api/lexicons/types/org/tangled/feed/comment"; +export type { Main as Follow } from "../../web/src/lib/api/lexicons/types/org/tangled/graph/follow"; +export type { Main as LabelDefinition } from "../../web/src/lib/api/lexicons/types/org/tangled/label/definition"; +export type { Main as Declaration } from "../../web/src/lib/api/lexicons/types/org/tangled/repo/declaration"; +export type { Main as Patch } from "../../web/src/lib/api/lexicons/types/org/tangled/review/patch"; +export type { Main as Ticket } from "../../web/src/lib/api/lexicons/types/org/tangled/track/ticket"; +export type { Main as PublicKey } from "../../web/src/lib/api/lexicons/types/sh/tangled/publicKey"; +export type { Main as LegacyRepo } from "../../web/src/lib/api/lexicons/types/sh/tangled/repo"; +export type { Main as Artifact } from "../../web/src/lib/api/lexicons/types/sh/tangled/repo/artifact"; +export type { $input as PocketPreferences } from "../../web/src/lib/api/lexicons/types/com/bad-example/pocket/putPreferences"; diff --git a/e2e/harness/net.ts b/e2e/harness/net.ts new file mode 100644 index 000000000..768c9346c --- /dev/null +++ b/e2e/harness/net.ts @@ -0,0 +1,145 @@ +import { parseCanonicalResourceUri as partsOf } from "@atcute/lexicons/syntax"; +import { Agent, fetch } from "undici"; + +import { keep } from "./record"; + +// node can't resolve the stack's names or trust its dev ca +export const dispatcher = new Agent({ + connect: { + rejectUnauthorized: false, + lookup: (_host, options, callback) => { + const address = { address: "127.0.0.1", family: 4 }; + if (options.all) callback(null, [address]); + else callback(null, address.address, address.family); + } + } +}); + +export interface Reply { + status: number; + body: any; +} + +interface Options { + method?: "GET" | "POST"; + token?: string; + json?: unknown; + basic?: string; +} + +export const request = async ( + url: string, + { method = "GET", token, json, basic }: Options = {} +): Promise => { + const headers: Record = { + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(basic ? { authorization: `Basic ${Buffer.from(basic).toString("base64")}` } : {}), + ...(json === undefined ? {} : { "content-type": "application/json" }) + }; + const body = json === undefined ? undefined : JSON.stringify(json); + const started = Date.now(); + const sent = { by: "node" as const, method, url, request: { headers, body } }; + // without this a hung service holds the hop for undici's default of minutes + const response = await fetch(url, { + dispatcher, + signal: AbortSignal.timeout(10_000), + method, + headers, + body + }).catch((error) => { + const reason = error.cause?.code ?? error.cause ?? error.message; + keep({ ...sent, ms: Date.now() - started, error: String(reason) }); + // undici's "fetch failed" hides the real reason, which is on error.cause + throw new Error(`${method} ${url}: ${reason}`); + }); + const text = await response.text(); + keep({ + ...sent, + status: response.status, + ms: Date.now() - started, + response: { headers: Object.fromEntries(response.headers), body: text } + }); + return { status: response.status, body: parseBody(text) }; +}; + +export const ok = (what: string, reply: Reply, status = 200) => { + if (reply.status !== status) + throw new Error(`${what} answered ${reply.status} ${JSON.stringify(reply.body)}`); + return reply.body; +}; + +// a failing service may answer with plain text, not json +export const parseBody = (text: string): unknown => { + try { + return JSON.parse(text); + } catch { + return text; + } +}; + +// request() drops the headers, so this makes its own fetch +export const contentTypeOf = async (url: string): Promise => { + const response = await fetch(url, { dispatcher, signal: AbortSignal.timeout(10_000) }); + await response.arrayBuffer(); + return response.headers.get("content-type"); +}; + +export const sleep = (ms: number) => new Promise((wake) => setTimeout(wake, ms)); + +// the knot limits per account and the pds on logins, so each try waits longer +export const retry429 = async ( + go: () => Promise, + tries = 6, + step = 300 +) => { + for (let attempt = 1; ; attempt++) { + const reply = await go(); + if (reply.status !== 429 || attempt === tries) return reply; + await sleep(step * attempt); + } +}; + +export const get = (url: string) => request(url); + +export const xrpc = (base: string, nsid: string, params: Record = {}) => + `${base}/xrpc/${nsid}?${new URLSearchParams(params)}`; + +export interface Written { + uri: string; + cid: string; +} + +export { partsOf }; + +export const selfUri = (did: string, nsid: string) => `at://${did}/${nsid}/self`; + +export const recordUrl = (base: string, uri: string) => { + const { repo, collection, rkey } = partsOf(uri); + return xrpc(base, "com.atproto.repo.getRecord", { repo, collection, rkey }); +}; + +// stampedAt reads the time back out, so a sweep can spot leaked names +export const stamp = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`; +export const stampedAt = (name: string) => { + const time = /-([0-9a-z]{8})[0-9a-z]{3}$/.exec(name)?.[1]; + return time ? parseInt(time, 36) : undefined; +}; + +// most waits end within a few tries, so the pauses start short +// a long poll already waited on the server, so it skips the pause +export const until = async ( + what: string, + budget: number, + check: () => Promise<[done: boolean, saw: string, value?: T]>, + { longPoll = false } = {} +) => { + const deadline = Date.now() + budget; + for (let pause = 25; ; pause = Math.min(pause * 2, 200)) { + const asked = Date.now(); + const [done, saw, value] = await check(); + if (done) return value as T; + if (Date.now() > deadline) + throw new Error(`${what}, gave up after ${budget / 1000}s: ${saw}`); + if (!longPoll || Date.now() - asked < 1_000) await sleep(pause); + } +}; diff --git a/e2e/harness/record.ts b/e2e/harness/record.ts new file mode 100644 index 000000000..d98b33f4d --- /dev/null +++ b/e2e/harness/record.ts @@ -0,0 +1,163 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import type { BrowserContext, Request, TestStepInfo } from "@playwright/test"; + +import { config } from "./config"; + +export interface Exchange { + by: "node" | "browser"; + method: string; + url: string; + status?: number; + ms: number; + request?: { headers: Record; body?: string }; + response?: { headers: Record; body?: string }; + error?: string; + at?: number; +} + +export interface StepRecord { + notes: Record; + exchanges: Exchange[]; + bytes: number; + dropped: number; + pending: Set>; +} + +const BODY_CAP = 64 * 1024; +const STEP_CAP = 512 * 1024; + +const isLocal = (url: string) => { + const host = new URL(url).hostname; + return host === "localhost" || host === "127.0.0.1" || host.endsWith(".tngl.boltless.dev"); +}; + +// any non local config drops bodies and masks secrets in the url +const keepsBodies = Object.values(config) + .filter((value): value is string => typeof value === "string" && /^https?:\/\//.test(value)) + .every(isLocal); + +const serviceHosts = new Set( + [ + config.pdsUrl, + config.knotUrl, + config.hydrantUrl, + config.bobbinUrl, + config.spindleUrl, + config.deliberiUrl + ].map((url) => new URL(url).host) +); + +const masked = (url: string) => + url.replace(/([?&][^=&]*(?:token|password|secret|code)[^=&]*=)[^&]*/gi, "$1…"); + +const clip = (text: string | undefined) => + text === undefined || text.length <= BODY_CAP + ? text + : `${text.slice(0, BODY_CAP)}\n… truncated, ${text.length} chars in all`; + +const clipped = (part: Exchange["request"]) => part && { ...part, body: clip(part.body) }; + +const steps = new AsyncLocalStorage(); +// browser events have no step async context, so they go to the running top step +let topStep: StepRecord | undefined; + +export const fresh = (): StepRecord => ({ + notes: {}, + exchanges: [], + bytes: 0, + dropped: 0, + pending: new Set() +}); + +const current = () => steps.getStore(); + +export const within = (record: StepRecord, run: () => T) => steps.run(record, run); + +// recording must never fail the step it watches +export const keep = (exchange: Exchange, record = current()) => { + try { + if (!record) return; + const sent = { ...exchange, at: Date.now() - exchange.ms }; + const kept: Exchange = keepsBodies + ? { ...sent, request: clipped(exchange.request), response: clipped(exchange.response) } + : { ...sent, url: masked(exchange.url), request: undefined, response: undefined }; + const size = JSON.stringify(kept).length; + if (record.bytes + size > STEP_CAP) { + record.dropped++; + return; + } + record.bytes += size; + record.exchanges.push(kept); + } catch {} +}; + +export const note = (key: string, value: unknown, record = current()) => { + if (record) record.notes[key] = value; +}; + +export const attach = async (info: TestStepInfo, record: StepRecord) => { + try { + await Promise.race([ + Promise.allSettled(record.pending), + new Promise((settled) => setTimeout(settled, 250)) + ]); + if (record.dropped) record.notes["requests not kept"] = record.dropped; + if (!Object.keys(record.notes).length && !record.exchanges.length) return; + const body = JSON.stringify({ notes: record.notes, exchanges: record.exchanges }); + await info.attach("e2e-step", { body, contentType: "application/json" }); + } catch {} +}; + +export const recorded = async ( + info: TestStepInfo, + run: () => Promise, + pageUrl: () => string | undefined +) => { + const record = fresh(); + topStep = record; + try { + return await within(record, run); + } finally { + const url = pageUrl(); + if (url && url !== "about:blank") note("page", url, record); + await attach(info, record); + } +}; + +// asset requests would drown the service calls, so only those are kept +export const watchBrowser = (context: BrowserContext) => { + const finished = (request: Request) => { + const record = topStep; + if (!record || !serviceHosts.has(new URL(request.url()).host)) return; + const ms = request.timing().responseEnd; + const filled = (async () => { + const response = await request.response(); + keep( + { + by: "browser", + method: request.method(), + url: request.url(), + status: response?.status(), + ms: ms >= 0 ? Math.round(ms) : 0, + request: { + headers: await request.allHeaders(), + body: request.postData() ?? undefined + }, + response: response + ? { + headers: await response.allHeaders(), + body: await response.text().catch(() => undefined) + } + : undefined, + error: request.failure()?.errorText + }, + record + ); + })().catch(() => {}); + record.pending.add(filled); + filled.finally(() => record.pending.delete(filled)); + }; + context.on("requestfinished", finished); + context.on("requestfailed", finished); +}; diff --git a/e2e/harness/report/ExchangeRow.svelte b/e2e/harness/report/ExchangeRow.svelte new file mode 100644 index 000000000..4d7355353 --- /dev/null +++ b/e2e/harness/report/ExchangeRow.svelte @@ -0,0 +1,214 @@ + + + +{#snippet body(text: string)} +
+
+ body · {bytes(text)} + +
+
{pretty(text)}
+ + +
+{/snippet} + +{#snippet message(label: string, part: Exchange["request"])} + {@const headers = Object.entries(part?.headers ?? {})} + {#if headers.length || part?.body} +
+ {label} + {#if headers.length} +
{headers
+						.map(([name, value]) => `${name}: ${value}`)
+						.join("\n")}
+ {/if} + {#if part?.body} + {@render body(part.body)} + {/if} +
+ {/if} +{/snippet} + +
  • +
    + + +
    +
    + {exchange.url} + +
    + {@render message("request", exchange.request)} + {@render message("response", exchange.response)} +
    +
    +
  • + + diff --git a/e2e/harness/report/Report.svelte b/e2e/harness/report/Report.svelte new file mode 100644 index 000000000..18370c775 --- /dev/null +++ b/e2e/harness/report/Report.svelte @@ -0,0 +1,755 @@ + + +{#snippet cause(flow: FlowData)} + {#if where(flow)}{where(flow)}{" · "}{/if}{brief(reason(flow))} +{/snippet} + +{#snippet stepLine(flow: FlowData, step: StepData, depth: number, tag = "div")} + {@const note = rowNote(flow, step)} + + + + + 0 && "typography-paragraph-small", + step.failed && "font-medium text-foreground-danger-strong" + ]} + title={step.title}>{step.title} + {#if note} + + {/if} + {#if tag === "summary"} +
    + {/if} + {elapsed(step.duration)} + +{/snippet} + +{#snippet stepRow(flow: FlowData, step: StepData, depth = 0)} + {@const recorded = hasRecord(step)} +
  • + {#if step.children.length || recorded} +
    0)}> + {@render stepLine(flow, step, depth, "summary")} + {#if recorded} + {@render stepRecord(step, depth)} + {/if} + {#if step.children.length} +
      + {#each step.children as child, index (index)} + {@render stepRow(flow, child, depth + 1)} + {/each} +
    + {/if} +
    + {:else} + {@render stepLine(flow, step, depth)} + {/if} +
  • +{/snippet} + +{#snippet stepRecord(step: StepData, depth: number)} + {@const facts = factsOf(step)} +
    + {#if facts.length} +
    + {#each facts as [key, value] (key)} +
    {key}
    +
    + {value} +
    + {/each} +
    + {/if} + {#if step.exchanges?.length} +
    + {step.exchanges.length} {plural(step.exchanges.length, "request", "requests")} + +
    + + {/if} +
    +{/snippet} + +{#snippet flowCard( + spec: SpecData, + flow: FlowData, + open: boolean, + place: "group" | "alone" | "spec" = "alone" +)} + {@const { hidden, shown } = split(flow)} + {@const failingStep = flow.steps.find((step) => step.failed)} + {@const failing = failingStep?.location} +
    + + + {#if flow.steps.length} +
      + {#if hidden.length} +
    1. +
      + + +
        + {#each hidden as step, index (index)} + {@render stepRow(flow, step)} + {/each} +
      +
      +
    2. + {/if} + {#each shown as step, index (index)} + {@render stepRow(flow, step)} + {/each} +
    + {/if} + {#if broken(flow)} +
    + {#if failing} +
    + failed at + {failing.file}:{failing.line} + + {#if failingStep?.via} + through + {failingStep.via.file}:{failingStep.via.line} + {/if} +
    + {/if} + {#if flow.failedStep.length > 1} + + {/if} + {#if flow.error} +
    {flow.error}
    + {/if} + {#if flow.trace} + pnpm exec playwright show-trace {flow.trace} + {/if} +
    + {/if} +
    +{/snippet} + +
    +
    +

    e2e run

    +
    + {#if outcomes.length > 1} + + {/if} + {#each outcomes as outcome (outcome.label)} + {#snippet face()} + + {outcome.count} {outcome.label} + {/snippet} + {#if outcome.show} + + {:else} + + {@render face()} + + {/if} + {/each} +
    + + +
    + + + +
    +
    +
    + + + + + + +
    + + {#each data.errors as message, index (index)} + + {/each} + +
    +

    + {failures.length} + {plural(failures.length, "failure", "failures")} + {#if groups.length < failures.length} + in {groups.length} groups + {/if} +

    + {#each shared as group, index (index)} + +
    + {group.length} flows + failed the same way: + {@render cause(group[0].flow)} +
    +
    + {#each group.slice(0, GROUP_SHOWN) as { spec, flow }, position (anchor(spec.name, flow))} + {@render flowCard(spec, flow, position === 0 && failures.length <= OPEN_UP_TO, "group")} + {/each} + {#if group.length > GROUP_SHOWN} + {@const more = group.length - GROUP_SHOWN} +
    + + +
    + {#each group.slice(GROUP_SHOWN) as { spec, flow } (anchor(spec.name, flow))} + {@render flowCard(spec, flow, false, "group")} + {/each} +
    +
    + {/if} +
    +
    + {/each} + {#if alone.length} + +
    + {#each alone as { spec, flow } (anchor(spec.name, flow))} + {@render flowCard(spec, flow, failures.length <= OPEN_UP_TO)} + {/each} +
    +
    + {/if} + {#if !failures.length} + + {/if} +
    + +
    +

    specs

    + {#each data.specs as spec (spec.name)} + {@const passed = spec.flows.filter((flow) => icons[flow.status] === "success").length} +
    + + + +
    + {#each failedFirst(spec) as flow (flow.title)} + {@render flowCard(spec, flow, false, "spec")} + {/each} +
    +
    +
    + {/each} +
    + +
    +

    slowest flows

    + +
    + {#each slowest as { spec, flow } (anchor(spec.name, flow))} + {@const share = flow.duration / Math.max(slowest[0].flow.duration, 1)} +
    + + + + {spec.name} › + {flow.title} + + {elapsed(flow.duration)} +
    + {/each} +
    +
    +
    +
    + + diff --git a/e2e/harness/report/data.ts b/e2e/harness/report/data.ts new file mode 100644 index 000000000..61a611423 --- /dev/null +++ b/e2e/harness/report/data.ts @@ -0,0 +1,50 @@ +import type { Exchange } from "../record"; + +export type { Exchange }; + +export type FlowStatus = + "passed" | "flaky" | "failed" | "timedOut" | "interrupted" | "skipped" | "did not run"; + +export interface CodeLocation { + file: string; + line: number; +} + +export interface StepData { + title: string; + start: number; + duration: number; + failed: boolean; + location?: CodeLocation; + via?: CodeLocation; + subtitle?: string; + subject?: string; + params?: Record; + notes?: Record; + exchanges?: Exchange[]; + children: StepData[]; +} + +export interface FlowData { + title: string; + status: FlowStatus; + duration: number; + attempts: number; + steps: StepData[]; + failedStep: string[]; + error?: string; + trace?: string; +} + +export interface SpecData { + name: string; + flows: FlowData[]; +} + +export interface ReportData { + root: string; + started: number; + duration: number; + specs: SpecData[]; + errors: string[]; +} diff --git a/e2e/harness/report/render.ts b/e2e/harness/report/render.ts new file mode 100644 index 000000000..38d862e4e --- /dev/null +++ b/e2e/harness/report/render.ts @@ -0,0 +1,148 @@ +import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import type { ReportData } from "./data"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const web = path.resolve(here, "../../../web"); +const modules = path.resolve(process.env.E2E_WEB_MODULES ?? path.join(web, "node_modules")); +const fonts = path.join(web, "static"); + +interface Resolver { + resolve: (id: string, importer: string | undefined, options: object) => Promise; +} + +const bare = /^(?![./])(@[\w.-]+\/)?[\w.-]+(\/|$)/; + +const [input, out] = process.argv.slice(2); +if (!existsSync(path.join(modules, "vite"))) { + console.error( + `${modules} has no vite, run pnpm install in web/ or pass --web-modules=` + ); + process.exit(1); +} + +const require = createRequire(path.join(modules, "noop.js")); +const load = (name: string, from = require) => import(pathToFileURL(from.resolve(name)).href); + +// tailwind only searches up from web/src, so it misses packages installed elsewhere +// take the esm entry, cjs gives tailwind a module object instead of the plugin +const entry = (condition: "style" | "import") => async (id: string) => { + if (!bare.test(id)) return undefined; + const manifest = path.join(modules, id, "package.json"); + const pkg = JSON.parse(await readFile(manifest, "utf8")); + const fallback = condition === "style" ? pkg.style : (pkg.module ?? pkg.main); + return path.join(path.dirname(manifest), pkg.exports?.["."]?.[condition] ?? fallback); +}; + +const renderHtml = async (data: ReportData) => { + const { createServer } = await load("vite"); + const { svelte } = await load("@sveltejs/vite-plugin-svelte"); + const { default: icons } = await load("unplugin-icons/vite"); + const server = await createServer({ + configFile: false, + root: web, + logLevel: "error", + // vite's default cache would land in web/node_modules + cacheDir: path.join(tmpdir(), "e2e-report-vite"), + appType: "custom", + server: { middlewareMode: true, hmr: false, ws: false, watch: null }, + optimizeDeps: { noDiscovery: true, include: [] }, + ssr: { noExternal: true }, + // the tsconfig extends .svelte-kit, which only exists after svelte-kit sync + oxc: { tsconfig: false }, + resolve: { + alias: [ + { find: /^\$lib\//, replacement: `${web}/src/lib/` }, + { find: /^\$icon\//, replacement: "~icons/lucide/" } + ] + }, + plugins: [ + // web/ may have no node_modules, so bare imports fall back to ours + { + name: "web-modules", + enforce: "pre", + async resolveId( + this: Resolver, + id: string, + importer: string | undefined, + options: object + ) { + if (!bare.test(id)) return null; + const own = await this.resolve(id, importer, { ...options, skipSelf: true }); + const fallback = path.join(modules, "noop.js"); + return own ?? this.resolve(id, fallback, { ...options, skipSelf: true }); + } + }, + svelte({ + configFile: false, + compilerOptions: { css: "injected", runes: true, experimental: { async: true } } + }), + icons({ compiler: "svelte", collectionsNodeResolvePath: path.dirname(modules) }) + ] + }); + try { + const { render } = await server.ssrLoadModule("svelte/server"); + const { default: Report } = await server.ssrLoadModule(path.join(here, "Report.svelte")); + return await render(Report, { props: { data } }); + } finally { + await server.close(); + } +}; + +const buildCss = async (html: string) => { + const node = await load( + "@tailwindcss/node", + createRequire(require.resolve("@tailwindcss/vite")) + ); + const compiler = await node.compile(`@import "${web}/src/app.css";`, { + base: path.join(web, "src"), + onDependency: () => {}, + customCssResolver: entry("style"), + customJsResolver: entry("import") + }); + const candidates = new Set(); + for (const [, list] of html.matchAll(/class="([^"]*)"/g)) + for (const name of list.split(/\s+/)) if (name) candidates.add(name); + return inlineFonts(compiler.build([...candidates])); +}; + +// the report opens from disk so /fonts urls miss, and italics are cut for size +const inlineFonts = async (css: string) => { + const faces = css.match(/@font-face\s*{[^}]*}/g) ?? []; + for (const face of faces) { + const url = face.match(/url\("?(\/fonts\/[^")]+)"?\)/)?.[1]; + const file = url && path.join(fonts, url); + if (/font-style:\s*italic/.test(face) || !file || !existsSync(file)) { + css = css.replace(face, ""); + continue; + } + const data = `data:font/woff2;base64,${(await readFile(file)).toString("base64")}`; + css = css.replace(face, face.replace(url, data)); + } + return css; +}; + +const data: ReportData = JSON.parse(await readFile(input, "utf8")); +const { head, body } = await renderHtml(data); +const css = await buildCss(body); +const script = await readFile(path.join(here, "search.js"), "utf8"); +await writeFile( + out, + ` + + + + +e2e report + +${head} + +${body} + +` +); diff --git a/e2e/harness/report/search.js b/e2e/harness/report/search.js new file mode 100644 index 000000000..c994fea5a --- /dev/null +++ b/e2e/harness/report/search.js @@ -0,0 +1,366 @@ +// inlined at the end of the report so the elements exist before it looks them up +const input = document.getElementById("search"); +const count = document.getElementById("search-count"); +const show = document.getElementById("show"); +const flows = [...document.querySelectorAll("[data-search]")]; +const folds = [...document.querySelectorAll("[data-fold], [data-search]")]; +const total = new Set(flows.map((flow) => flow.dataset.key)).size; +for (const fold of folds) fold.dataset.drawn = fold.open ? "open" : ""; + +// search ignores the show filter, which comes back once the box is empty +let filtered; +const apply = () => { + const query = input.value.trim().toLowerCase(); + if (query && filtered === undefined) { + filtered = show.value; + show.value = "all"; + } else if (!query && filtered !== undefined) { + show.value = filtered; + filtered = undefined; + } + pressed(); + for (const flow of flows) flow.hidden = query !== "" && !textOf(flow).includes(query); + const found = flows.filter((flow) => !flow.hidden); + // flows repeat once per failure, so count unique keys + const matches = new Set(found.map((flow) => flow.dataset.key)).size; + for (const fold of folds) { + // only open found flows when there are five or fewer, or the page becomes a wall of cards + const wanted = fold.matches("[data-search]") + ? matches <= 5 + : found.some((flow) => fold.contains(flow)); + fold.open = query ? wanted : fold.dataset.drawn === "open"; + } + for (const section of document.querySelectorAll("[data-section]")) + section.hidden = query !== "" && !section.querySelector("[data-search]:not([hidden])"); + for (const group of document.querySelectorAll("[data-group]")) { + const shown = group.querySelectorAll("[data-search]:not([hidden])").length; + group.hidden = query !== "" && shown === 0; + const label = group.querySelector("[data-count]"); + if (label) + label.textContent = query + ? `${shown} of ${label.dataset.count} flows` + : `${label.dataset.count} flows`; + } + const visible = new Set( + found.filter((flow) => flow.checkVisibility()).map((flow) => flow.dataset.key) + ); + count.textContent = query ? `${visible.size} of ${total} match` : ""; + document.getElementById("search-empty").hidden = !query || visible.size > 0; + reveal(query ? found.filter((flow) => flow.open && flow.checkVisibility()) : [], query); +}; + +// raw bodies can be huge, so this only gets built once a search starts +const index = new Map(); +const textOf = (flow) => { + if (!index.has(flow)) { + const raws = [...flow.querySelectorAll("[data-raw]")].map((pre) => JSON.parse(pre.dataset.raw)); + index.set(flow, [flow.dataset.search, flow.textContent, ...raws].join("\n").toLowerCase()); + } + return index.get(flow); +}; + +const places = [ + ["[data-body]", "body"], + ["[data-request] pre", "header"], + ["pre", "error"], + ["[data-request] > summary, [data-request] .break-all", "url"], + ["dl", "step facts"], + ["[data-search] > summary", "flow title"], + ["summary", "step title"] +]; +let opened = []; + +// the walker only sees text that is drawn, so json bodies get grown first +const reveal = (shown, query) => { + for (const fold of opened) fold.open = false; + opened = []; + const ranges = []; + for (const flow of shown) { + const hits = new Set(); + for (const body of flow.querySelectorAll("[data-body]")) { + if (!body.querySelector("[data-pretty]").textContent.toLowerCase().includes(query)) continue; + drawBody(body); + growAll(body.querySelector("[data-tree]")); + } + const walker = document.createTreeWalker(flow, NodeFilter.SHOW_TEXT); + for (let text = walker.nextNode(); text; text = walker.nextNode()) { + const lower = text.data.toLowerCase(); + const parent = text.parentElement; + if (!lower.includes(query) || parent.closest("[data-hit]")) continue; + const tail = parent.closest("[data-tail]"); + if (tail) { + tail.hidden = false; + tail.nextElementSibling?.remove(); + } + if (parent.closest("[hidden]")) continue; + for (let at = lower.indexOf(query); at >= 0; at = lower.indexOf(query, at + query.length)) { + const range = new Range(); + range.setStart(text, at); + range.setEnd(text, at + query.length); + ranges.push(range); + } + for (let fold = parent.closest("details"); fold && fold !== flow;) { + if (!fold.open) { + fold.open = true; + opened.push(fold); + } + fold = fold.parentElement.closest("details"); + } + const place = places.find(([selector]) => parent.closest(selector)); + hits.add(place && flow.contains(parent.closest(place[0])) ? place[1] : "flow"); + } + const hint = flow.querySelector("[data-hit]"); + hint.textContent = hits.size ? `matched in ${[...hits].join(", ")}` : ""; + hint.hidden = !hits.size; + } + for (const hint of document.querySelectorAll("[data-hit]")) + if (!shown.some((flow) => flow.contains(hint))) hint.hidden = true; + if (window.CSS?.highlights) CSS.highlights.set("search", new Highlight(...ranges)); +}; + +// clipboard api is refused on file:// pages, so fall back to a hidden textarea +const copy = async (text) => { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + const area = document.createElement("textarea"); + area.value = text; + area.style.cssText = "position:fixed;opacity:0"; + document.body.append(area); + area.select(); + document.execCommand("copy"); + area.remove(); + } +}; + +const copied = (element) => { + element.dataset.copied = ""; + clearTimeout(element.copyTimer); + element.copyTimer = setTimeout(() => delete element.dataset.copied, 1200); +}; + +document.addEventListener("click", (event) => { + const target = event.target.closest?.("[data-loc], [data-copy], [data-copy-all]"); + if (!target) return; + event.preventDefault(); + const text = target.hasAttribute("data-copy-all") + ? [...target.parentElement.nextElementSibling.querySelectorAll("[data-copy]")] + .map((button) => button.dataset.copy) + .join("\n\n") + : (target.dataset.copy ?? (event.shiftKey ? target.dataset.abs : target.dataset.loc)); + copy(text).then(() => copied(target)); +}); + +const caret = document.getElementById("json-caret").content.firstElementChild; +const tools = document.getElementById("body-tools").content.firstElementChild; +const STRING_CAP = 300; +const FOLD_FROM = 2; + +const element = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +}; + +const rawOf = (body) => { + const pretty = body.querySelector("[data-pretty]"); + if (pretty.dataset.raw) return JSON.parse(pretty.dataset.raw); + try { + return JSON.stringify(JSON.parse(pretty.textContent)); + } catch { + return pretty.textContent; + } +}; + +const leaf = (value) => { + if (value === null) return element("span", "nul", "null"); + if (typeof value === "number") return element("span", "num", String(value)); + if (typeof value === "boolean") return element("span", "bool", String(value)); + const text = JSON.stringify(value); + const span = element("span", "str", text.slice(0, STRING_CAP)); + if (text.length > STRING_CAP) { + const tail = element("span", "", text.slice(STRING_CAP)); + tail.hidden = true; + tail.dataset.tail = ""; + const more = element("button", "more", `show all, ${text.length - STRING_CAP} more`); + more.type = "button"; + more.dataset.more = ""; + span.append(tail, more); + } + return span; +}; + +const preview = (value) => { + const brief = (item) => + item !== null && typeof item === "object" + ? Array.isArray(item) + ? "[…]" + : "{…}" + : typeof item === "string" + ? JSON.stringify(item.length > 24 ? `${item.slice(0, 24)}…` : item) + : String(item); + const entries = Array.isArray(value) + ? value.slice(0, 4).map(brief) + : Object.entries(value) + .slice(0, 4) + .map(([key, item]) => `${key}: ${brief(item)}`); + const more = Object.keys(value).length > 4 ? ", …" : ""; + return Array.isArray(value) ? `[${entries.join(", ")}${more}]` : `{${entries.join(", ")}${more}}`; +}; + +const row = (key, tag = "div") => { + const line = element(tag, "row"); + if (key !== undefined) line.append(element("span", "key", key)); + return line; +}; + +const node = (key, value, depth) => { + if (value === null || typeof value !== "object") { + const line = row(key); + line.prepend(element("span", "leaf")); + line.append(leaf(value)); + return line; + } + const count = Object.keys(value).length; + const fold = element("details"); + const summary = row(key, "summary"); + summary.prepend(caret.cloneNode(true)); + const peek = element("span", "", preview(value)); + peek.dataset.preview = ""; + summary.append( + element("span", "punct", Array.isArray(value) ? `[ ${count} ]` : `{ ${count} }`), + peek + ); + fold.append(summary); + fold.json = value; + fold.depth = depth; + if (depth < FOLD_FROM) grow(fold); + return fold; +}; + +const grow = (fold) => { + if (fold.grown) return; + fold.grown = true; + fold.open = true; + const kids = element("div", "kids"); + for (const [key, value] of Object.entries(fold.json)) + kids.append(node(key, value, fold.depth + 1)); + fold.append(kids); +}; + +const growAll = (tree) => { + let closed; + while ((closed = [...tree.querySelectorAll("details")].filter((fold) => !fold.grown)).length) + for (const fold of closed) grow(fold); +}; + +const drawBody = (body) => { + if (body.drawn) return; + body.drawn = true; + body.querySelector("[data-body-tools]").replaceWith(tools.cloneNode(true)); + const pretty = body.querySelector("[data-pretty]"); + let value; + try { + value = JSON.parse(pretty.textContent); + } catch { + body.querySelector("[data-json-only]").hidden = true; + return; + } + const tree = body.querySelector("[data-tree]"); + const top = node(undefined, value, 0); + tree.append(top); + body.querySelector("[data-raw-view]").textContent = rawOf(body); + pretty.hidden = true; + tree.hidden = false; +}; + +const drawIn = (scope) => { + for (const body of scope.querySelectorAll("[data-body]")) drawBody(body); +}; + +const view = (body, which) => { + body.querySelector("[data-tree]").hidden = which !== "tree"; + body.querySelector("[data-raw-view]").hidden = which !== "raw"; + for (const button of body.querySelectorAll("[data-view]")) + button.setAttribute("aria-pressed", String(button.dataset.view === which)); +}; + +document.addEventListener( + "toggle", + (event) => { + const target = event.target; + if (!target.open) return; + if (target.matches("[data-request]")) drawIn(target); + else if (target.closest("[data-tree]") && target.json) grow(target); + }, + true +); +for (const request of document.querySelectorAll("[data-request][open]")) drawIn(request); + +document.addEventListener("click", (event) => { + const target = event.target.closest?.( + "[data-view], [data-unfold], [data-fold-all], [data-copy-body], [data-more]" + ); + if (!target) return; + event.preventDefault(); + const body = target.closest("[data-body]"); + if (target.dataset.view) view(body, target.dataset.view); + else if (target.hasAttribute("data-unfold")) { + view(body, "tree"); + growAll(body.querySelector("[data-tree]")); + for (const fold of body.querySelectorAll("[data-tree] details")) fold.open = true; + } else if (target.hasAttribute("data-fold-all")) { + view(body, "tree"); + for (const fold of body.querySelectorAll("[data-tree] details")) fold.open = false; + body.querySelector("[data-tree] details").open = true; + } else if (target.hasAttribute("data-more")) { + target.previousElementSibling.hidden = false; + target.remove(); + } else copy(rawOf(body)).then(() => copied(target)); +}); + +// editors open a dropped file:// uri, text/plain carries the line for the ones that read it +document.addEventListener("dragstart", (event) => { + const target = event.target.closest?.("[data-loc]"); + if (!target) return; + const [file] = target.dataset.abs.split(/:(?=\d+$)/); + event.dataTransfer.setData("text/uri-list", `file://${file}`); + event.dataTransfer.setData("text/plain", target.dataset.abs); +}); + +for (const button of document.querySelectorAll("[data-copy], [data-copy-all]")) + button.hidden = false; + +document.getElementById("search-box").classList.replace("hidden", "flex"); +const pressed = () => { + for (const button of document.querySelectorAll("[data-show]")) + button.setAttribute("aria-pressed", String(button.dataset.show === show.value)); +}; +document.addEventListener("click", (event) => { + const target = event.target.closest?.("[data-show]"); + if (!target) return; + show.value = target.dataset.show; + show.dispatchEvent(new Event("change")); +}); +pressed(); +let typing; +input.addEventListener("input", () => { + clearTimeout(typing); + typing = setTimeout(apply, 120); +}); +show.addEventListener("change", () => { + filtered = undefined; + apply(); +}); +document.addEventListener("keydown", (event) => { + if (event.key === "/" && document.activeElement !== input) { + event.preventDefault(); + input.focus(); + } else if (event.key === "Escape" && document.activeElement === input) { + input.value = ""; + apply(); + input.blur(); + } +}); diff --git a/e2e/harness/report/write.ts b/e2e/harness/report/write.ts new file mode 100644 index 000000000..52b2cd2b8 --- /dev/null +++ b/e2e/harness/report/write.ts @@ -0,0 +1,89 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import type { Exchange, ReportData, StepData } from "./data"; + +// the render loads web's svelte and tailwind, so it runs in a separate node +export const writeHtmlReport = async (out: string, data: ReportData) => { + const dir = await mkdtemp(path.join(tmpdir(), "e2e-report-")); + const input = path.join(dir, "report.json"); + const render = path.join(path.dirname(fileURLToPath(import.meta.url)), "render.ts"); + try { + await writeFile(input, JSON.stringify(data)); + await promisify(execFile)(process.execPath, [render, input, path.resolve(out)], { + timeout: 120_000, + maxBuffer: 16 * 1024 * 1024 + }); + console.log(`html report: ${out}`); + } catch (error) { + const failure = error as { stderr?: string; message: string }; + const why = (failure.stderr || failure.message).trim().split("\n").slice(0, 20).join("\n"); + console.log(`no html report: ${why}`); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}; + +const pairs = (headers: Record = {}) => + Object.entries(headers).map(([name, value]) => ({ name, value })); + +// only what the steps kept is in the har, for devtools or any har viewer +export const writeHar = async (out: string, data: ReportData) => { + const exchanges = (steps: StepData[]): Exchange[] => + steps.flatMap((step) => [...(step.exchanges ?? []), ...exchanges(step.children)]); + const entries = data.specs + .flatMap((spec) => spec.flows.flatMap((flow) => exchanges(flow.steps))) + .sort((a, b) => (a.at ?? 0) - (b.at ?? 0)) + .map((exchange) => { + const url = new URL(exchange.url); + const type = exchange.response?.headers["content-type"] ?? ""; + return { + startedDateTime: new Date(exchange.at ?? 0).toISOString(), + time: exchange.ms, + request: { + method: exchange.method, + url: exchange.url, + httpVersion: "HTTP/1.1", + headers: pairs(exchange.request?.headers), + queryString: [...url.searchParams].map(([name, value]) => ({ name, value })), + cookies: [], + headersSize: -1, + bodySize: exchange.request?.body?.length ?? 0, + ...(exchange.request?.body !== undefined && { + postData: { + mimeType: exchange.request.headers["content-type"] ?? "", + text: exchange.request.body + } + }) + }, + response: { + status: exchange.status ?? 0, + statusText: exchange.error ?? "", + httpVersion: "HTTP/1.1", + headers: pairs(exchange.response?.headers), + cookies: [], + content: { + size: exchange.response?.body?.length ?? 0, + mimeType: type, + text: exchange.response?.body ?? "" + }, + redirectURL: "", + headersSize: -1, + bodySize: -1 + }, + cache: {}, + timings: { send: 0, wait: exchange.ms, receive: 0 } + }; + }); + try { + const log = { version: "1.2", creator: { name: "e2e", version: "1" }, entries }; + await writeFile(out, JSON.stringify({ log })); + console.log(`har: ${out}`); + } catch (error) { + console.log(`no har: ${(error as Error).message}`); + } +}; diff --git a/e2e/harness/reporter.ts b/e2e/harness/reporter.ts new file mode 100644 index 000000000..194b7fafd --- /dev/null +++ b/e2e/harness/reporter.ts @@ -0,0 +1,271 @@ +import type { + Reporter, + Suite, + TestCase, + TestError, + TestResult, + TestStep +} from "@playwright/test/reporter"; + +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { stripVTControlCharacters, styleText } from "node:util"; + +import type { CodeLocation, FlowData, ReportData, SpecData, StepData } from "./report/data"; +import { writeHar, writeHtmlReport } from "./report/write"; + +// the stock list names the project and harness/flow.ts:272:2 on every line, which is where every flow is registered + +const green = (text: string) => styleText("green", text); +const red = (text: string) => styleText("red", text); +const dim = (text: string) => styleText("dim", text); + +const specOf = (test: TestCase) => { + let suite: Suite | undefined = test.parent; + while (suite && suite.type !== "file") suite = suite.parent; + return (suite?.title ?? "").replace(/^.*\//, "").replace(/(\.spec)?\.ts$/, ""); +}; + +const titleOf = (test: TestCase) => test.titlePath().slice(3).join(" › "); + +const traceOf = (result: TestResult) => + result.attachments.find((attachment) => attachment.name === "trace")?.path; + +const seconds = (ms: number) => + ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${(ms / 60_000).toFixed(1)}m`; + +// the innermost step that threw, which is the one the flow was on +const failedStep = (steps: TestStep[]): string[] => { + for (const step of steps) { + if (step.category !== "test.step" || !step.error) continue; + return [step.title, ...failedStep(step.steps)]; + } + return []; +}; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const place = (where: string, line: number): CodeLocation | undefined => { + // esm spec frames give file:// urls, not paths + const file = where.startsWith("file:") ? fileURLToPath(where) : where; + const relative = path.relative(root, file); + // a frame in flow.ts is the loop running every step, not the step + if (relative === "harness/flow.ts") return undefined; + return { file: relative.startsWith("..") ? file : relative, line }; +}; + +// flow.ts sets the via param to the helper's file:line +const via = (step: TestStep) => { + const found = /^(.*):(\d+)$/.exec(String(step.params?.via ?? "")); + return found ? place(found[1], Number(found[2])) : undefined; +}; + +// flow.ts and services.ts write this, see record.ts +const recordOf = (step: TestStep): Pick => { + const kept = step.attachments.find((attachment) => attachment.name === "e2e-step")?.body; + if (!kept) return {}; + try { + return JSON.parse(kept.toString()); + } catch { + return {}; + } +}; + +const subjectOf = (notes: Record = {}) => { + const repo = notes.repo as { name?: string } | undefined; + if (repo?.name) return repo.name; + const records = Object.entries(notes).filter( + (entry): entry is [string, { uri: string }] => + typeof (entry[1] as { uri?: unknown })?.uri === "string" + ); + const last = records.at(-1); + return last && `${last[0].split(".").at(-1)} ${last[1].uri.split("/").at(-1)}`; +}; + +// render.ts reads this json, so one chatty flow gets capped at 2mb +const FLOW_BUDGET = 2 * 1024 * 1024; + +// only top level steps get a location, the nested ones are all services.ts +const stepData = ( + steps: TestStep[], + start: number, + budget: { left: number }, + top = false +): StepData[] => + steps + .filter((step) => step.category === "test.step") + .map((step) => { + const { notes, exchanges } = recordOf(step); + const size = JSON.stringify(exchanges ?? []).length; + const fits = size <= budget.left; + if (fits) budget.left -= size; + const params = Object.entries(step.params ?? {}).filter(([key]) => key !== "via"); + return { + title: step.title, + subtitle: step.subtitle, + subject: subjectOf(notes), + start: step.startTime.getTime() - start, + duration: step.duration, + failed: !!step.error, + ...(top && { + location: step.location && place(step.location.file, step.location.line), + via: via(step) + }), + ...(params.length && { + params: Object.fromEntries(params.map(([k, v]) => [k, String(v)])) + }), + ...(notes && { notes }), + ...(exchanges?.length && + (fits + ? { exchanges } + : { + notes: { + ...notes, + requests: `${exchanges.length} left out, the flow kept 2MB` + } + })), + children: stepData(step.steps, start, budget) + }; + }); + +const flowData = (test: TestCase): FlowData => { + const title = titleOf(test); + const attempt = test.results.at(-1); + if (!attempt) + return { + title, + status: "did not run", + duration: 0, + attempts: 0, + steps: [], + failedStep: [] + }; + const outcome = test.outcome(); + const status = + attempt.status === "passed" ? (outcome === "flaky" ? "flaky" : "passed") : attempt.status; + const steps = stepData(attempt.steps, attempt.startTime.getTime(), { left: FLOW_BUDGET }, true); + const error = attempt.error?.message ?? attempt.error?.value; + return { + title, + status, + duration: attempt.duration, + attempts: test.results.length, + steps, + failedStep: failedStep(attempt.steps), + error: error && stripVTControlCharacters(error).split("\n").slice(0, 200).join("\n"), + trace: traceOf(attempt) + }; +}; + +interface Failure { + test: TestCase; + result: TestResult; +} + +export default class FlowReporter implements Reporter { + private html?: string; + private suite?: Suite; + private errors: string[] = []; + private total = 0; + private done = 0; + private passed = 0; + private flaky = 0; + private interrupted = 0; + private failures: Failure[] = []; + private started = Date.now(); + + constructor(options: { html?: string } = {}) { + this.html = options.html; + } + + printsToStdio() { + return true; + } + + onBegin(_config: unknown, suite: Suite) { + const tests = suite.allTests(); + this.suite = suite; + this.total = tests.length; + this.started = Date.now(); + if (!process.argv.includes("--list")) return; + for (const test of tests) console.log(` ${specOf(test)} › ${titleOf(test)}`); + console.log( + `Total: ${tests.length} tests in ${suite.suites.flatMap((project) => project.suites).length} files` + ); + } + + onStdOut(chunk: string | Buffer) { + process.stdout.write(chunk); + } + + onStdErr(chunk: string | Buffer) { + process.stderr.write(chunk); + } + + onTestEnd(test: TestCase, result: TestResult) { + const retrying = + result.status !== "passed" && + result.status !== "skipped" && + result.retry < test.retries; + if (retrying) return; + this.done++; + const ok = result.status === "passed"; + if (result.status === "interrupted") this.interrupted++; + else if (ok && result.retry > 0) this.flaky++; + else if (ok) this.passed++; + else this.failures.push({ test, result }); + const mark = ok ? green("✓") : result.status === "interrupted" ? dim("-") : red("✗"); + const counter = dim(`${this.done}/${this.total}`); + console.log( + ` ${mark} ${counter} ${specOf(test)} › ${titleOf(test)} ${dim(`(${seconds(result.duration)})`)}` + ); + } + + onError(error: TestError) { + const message = error.message ?? error.value ?? "error"; + this.errors.push(stripVTControlCharacters(message)); + console.log(red(message)); + } + + async onEnd() { + if (process.argv.includes("--list")) return; + for (const [index, { test, result: attempt }] of this.failures.entries()) { + const step = failedStep(attempt.steps); + const trace = traceOf(attempt); + const message = (attempt.error?.message ?? attempt.status) + .split("\n") + .slice(0, 25) + .join("\n"); + console.log(`\n${red(`${index + 1}) ${specOf(test)} › ${titleOf(test)}`)}`); + if (step.length) console.log(` at step: ${step.join(" › ")}`); + console.log(message.replace(/^/gm, " ")); + if (trace) console.log(dim(` pnpm exec playwright show-trace ${trace}`)); + } + const parts = [`${this.passed} passed`]; + if (this.flaky) parts.push(`${this.flaky} flaky`); + if (this.failures.length) parts.push(red(`${this.failures.length} failed`)); + if (this.interrupted) parts.push(`${this.interrupted} interrupted`); + if (this.done < this.total) parts.push(`${this.total - this.done} did not run`); + console.log(`\n${parts.join(", ")} ${dim(`(${seconds(Date.now() - this.started)})`)}`); + const report = this.html || process.env.E2E_REPORT_HAR ? this.report() : undefined; + if (report && this.html) await writeHtmlReport(this.html, report); + if (report && process.env.E2E_REPORT_HAR) + await writeHar(process.env.E2E_REPORT_HAR, report); + } + + private report(): ReportData { + const specs = new Map(); + for (const test of this.suite?.allTests() ?? []) { + const name = specOf(test); + if (!specs.has(name)) specs.set(name, { name, flows: [] }); + specs.get(name)!.flows.push(flowData(test)); + } + return { + root, + started: this.started, + duration: Date.now() - this.started, + specs: [...specs.values()], + errors: this.errors + }; + } +} diff --git a/e2e/harness/run.ts b/e2e/harness/run.ts new file mode 100644 index 000000000..435be46fa --- /dev/null +++ b/e2e/harness/run.ts @@ -0,0 +1,324 @@ +import type { Did } from "@atcute/lexicons/syntax"; +import { expect, type Browser } from "@playwright/test"; + +import { accounts, knotRecordFor, pulls, repos, tickets, type RepoOptions } from "./api"; +import { config, isSeeded, type Account } from "./config"; +import { Tidy } from "./chores"; +import { push } from "./git"; +import { selfUri, stamp, type Written } from "./net"; +import { note } from "./record"; +import { bobbin, lookBefore, refsNaming, settle, type Hop } from "./services"; +import { Tabs, type Held } from "./tabs"; +import { + declarationId, + manifestId, + refsId, + repoAt, + unique, + type Ctx, + type Repo, + type Slot +} from "./vocabulary"; + +class People { + private labelled = new Map(); + private members = new Set(); + private throughThePage = new Set(); + who?: Account; + + constructor(private tidy: Tidy) {} + + get actor() { + if (!this.who) throw new Error("this step needs an .actor() before it"); + return this.who; + } + + async switchTo(who: string | Account, options: { login?: "ui" } = {}) { + if (typeof who !== "string") { + this.who = who; + return; + } + let account = this.labelled.get(who); + if (!account) { + const made = await accounts.create(who); + this.labelled.set(who, made); + this.tidy.add(`account ${made.handle}`, { kind: "account", account: made }); + account = made; + } + if (options.login === "ui") this.throughThePage.add(account.name); + this.who = account; + } + + signsInThroughThePage(account: Account) { + return config.login === "ui" || this.throughThePage.has(account.name); + } + + // the knot only lets its members make repos + async ensureMember(account: Account) { + if (isSeeded(account) || this.members.has(account.did)) return; + await accounts.makeMember(account); + this.oweMembership(account); + } + + // for a membership made outside ensureMember, like an invite redeemed in the browser + oweMembership(account: Account) { + this.members.add(account.did); + this.tidy.add(`knot membership of ${account.handle}`, { kind: "member", account }); + } +} + +export class Run implements Ctx { + readonly tidy = new Tidy(); + private people = new People(this.tidy); + private tabs: Tabs; + private home?: Repo; + private parent?: Repo; + private last?: Ctx["pushed"]; + private slots = new Map(); + private window = ""; + + constructor(browser: Browser) { + this.tabs = new Tabs(browser); + } + + get actor() { + return this.people.actor; + } + + // read before the test runs, so this getter must not throw + get known() { + return { actor: this.people.who?.name, repo: this.home?.name }; + } + + get pageUrl() { + try { + return this.page.url(); + } catch { + return undefined; + } + } + + get page() { + return this.tabs.page(this.actor, this.window); + } + + async openWindow(window: string) { + await this.tabs.openWindow(this.actor, window); + this.window = window; + } + + get repo() { + if (!this.home) throw new Error("this step needs a .repo() before it"); + return this.home; + } + + set repo(repo: Repo) { + this.home = repo; + note("repo", { name: repo.name, did: repo.did }); + } + + get upstream() { + if (!this.parent) throw new Error("this step needs a .fork() before it"); + return this.parent; + } + + set upstream(repo: Repo) { + this.parent = repo; + } + + get pushed() { + if (!this.last) throw new Error("this step needs a .push() before it"); + return this.last; + } + + set pushed(pushed: Ctx["pushed"]) { + this.last = pushed; + } + + switchTo(who: string | Account, options: { login?: "ui" } = {}) { + this.window = ""; + return this.people.switchTo(who, options); + } + + async own(options: RepoOptions = {}) { + const owner = this.actor; + await this.people.ensureMember(owner); + const made = await repos.create(owner, `e2e-${stamp()}`, options); + // registered before the wait, since a stalled hydrant fails the run right there + this.tidy.add(`repo ${made.name}`, { kind: "repo", owner, made }); + // the web only shows a repo after bobbin has indexed it + await bobbin.indexed(made.declaration); + await bobbin.hasRepo(owner.did, made.name); + this.set(declarationId, made.declaration); + await this.learn(manifestId, selfUri(made.did, manifestId.nsid), "read"); + this.repo = repoAt(owner, { did: made.did, name: made.name, defaultBranch: "main" }); + } + + becomeMember() { + return this.people.ensureMember(this.actor); + } + + oweMembership() { + this.people.oweMembership(this.actor); + } + + adoptRepo(made: { did: Did; name: string }) { + const owner = this.actor; + this.tidy.add(`repo ${made.name}`, { kind: "repo", owner, made }); + this.repo = repoAt(owner, { ...made, defaultBranch: "main" }); + } + + expectNoWrite(slot: Slot) { + return this.tabs.expectNoWrite(this.actor, slot); + } + + get = (slot: Slot) => { + const written = this.slots.get(slot); + if (!written) throw new Error(`nothing has filled the ${slot.nsid} slot yet`); + return written; + }; + + set(slot: Slot, written: Written) { + this.slots.set(slot, written); + note(slot.nsid, written); + } + + // nothing else erases this record, so the run has to + adopt(slot: Slot, written: Written) { + this.set(slot, written); + this.tidy.record(slot, this.actor, written); + } + + async learn(slot: Slot, uri: string, doing: string) { + this.set(slot, { uri, cid: (await knotRecordFor(uri, doing)).cid }); + } + + settle() { + return this.tabs.settle(this.actor); + } + + openTab() { + return this.tabs.open(this.actor, this.people.signsInThroughThePage(this.actor)); + } + + hold(slot: Slot) { + return this.tabs.hold(this.actor, slot); + } + + // a refused write is never recorded, so the slot keeps what it had + refuse(slot: Slot, held: Held, status: number) { + return this.tabs.release(this.actor, slot, held, status); + } + + async release(slot: Slot, held: Held) { + const body = await this.tabs.release(this.actor, slot, held); + if (held.method === "delete") return this.get(slot); + const before = this.slots.get(slot); + const written = { uri: body.uri as string, cid: body.cid as string }; + if (held.method === "put" && before?.cid === written.cid) { + throw new Error(`the put of ${slot.nsid} answered with the cid it started from`); + } + this.set(slot, written); + // a put with nothing to replace is a create under a client rkey + const made = held.method === "create" || (held.method === "put" && !before); + if (made || this.tidy.slotHasEraser(slot)) this.tidy.record(slot, this.actor, written); + return written; + } + + expectQuiet() { + return this.tabs.expectQuiet(); + } + + // tabs close first so nothing writes while the world is torn down + async close() { + await this.tabs.close(); + await this.tidy.close(); + } +} + +export const pushFiles = async ( + run: Run, + files: Record, + options: { branch?: string; base?: string } +) => { + const { actor, repo } = run; + const branch = options.branch ?? repo.defaultBranch; + const sha = await push(actor, repo.did, files, { branch, base: options.base }); + run.pushed = { sha, branch }; + repo.heads[branch] = sha; + const uri = selfUri(repo.did, refsId.nsid); + const stored = await refsNaming( + uri, + `the knot should list ${sha} on ${branch} in ${repo.did}`, + (named) => named.some(([commit, ref]) => commit === sha && ref === `refs/heads/${branch}`) + ); + run.set(refsId, { uri, cid: stored.cid }); +}; + +// bobbin's pages read git, so wait for every pushed branch to show up there +export const waitForMirror = async (run: Run) => { + await bobbin.knowsRepo(run.repo.did); + await Promise.all( + Object.entries(run.repo.heads).map(([branch, sha]) => + bobbin.hasBranch(run.repo.did, branch, sha) + ) + ); +}; + +export const openPull = async ( + run: Run, + pull: Slot, + patch: Slot, + options: { title?: string; from: string; into: string } +) => { + const { from, into } = options; + const range = { base: run.repo.heads[into], head: run.repo.heads[from] }; + if (!range.base || !range.head) throw new Error("a pull needs a .push() to both branches"); + const opened = await pulls.open( + run.actor, + run.repo.did, + options.title ?? unique("a pull"), + into, + range + ); + // the patch goes first at teardown, since it names a ticket that has to outlive it + run.adopt(pull, opened.ticket); + run.adopt(patch, opened.patch); + await bobbin.indexed(opened.ticket); + await bobbin.indexed(opened.patch); +}; + +// made through the api, so the flow only clicks through what it tests +export const openTicket = async ( + run: Run, + slot: Slot, + options: { title?: string; state?: "open" | "closed" } +) => { + const opened = await tickets.open(run.actor, run.repo.did, options.title ?? unique("a ticket")); + run.adopt(slot, opened); + if (options.state === "closed") { + run.set(slot, await tickets.setState(run.actor, opened, "closed")); + } + await bobbin.indexed(run.get(slot)); +}; + +// the did sits in the held declaration, so register the repo before letting the write through +export const traceNewRepo = async (run: Run, name: string, hops: Hop[]) => { + const owner = run.actor; + const held = await run.hold(declarationId); + expect(held.host, "the declaration should go to the pds").toBe(new URL(config.pdsUrl).host); + expect(held.method, "the declaration should be a create").toBe("create"); + expect(held.body).toMatchObject({ + repo: owner.did, + collection: declarationId.nsid, + rkey: name, + record: { $type: declarationId.nsid } + }); + const did: Did = held.body.record.repo; + expect(did, "the declaration should name the repo the knot minted").toMatch(/^did:/); + run.adoptRepo({ did, name }); + const before = await lookBefore(hops, held); + const written = await run.release(declarationId, held); + await settle(written, hops, owner, declarationId.nsid, "create", before); + await run.learn(manifestId, selfUri(did, manifestId.nsid), "read"); +}; diff --git a/e2e/harness/services.ts b/e2e/harness/services.ts new file mode 100644 index 000000000..5384f40f0 --- /dev/null +++ b/e2e/harness/services.ts @@ -0,0 +1,489 @@ +import { test } from "@playwright/test"; + +import { serviceDo, getOk, refsOf } from "./api"; +import { attach, fresh, note, within } from "./record"; +import { checkGit } from "./cobgit"; +import { config, type Account } from "./config"; +import { lsRemote } from "./git"; +import { knotFirehose, pdsFirehose, type Firehose } from "./firehose"; +import type { PocketPreferences } from "./lexicons"; +import { get, partsOf, recordUrl, until, xrpc, type Reply, type Written } from "./net"; +import { checkStream, streamCarries, watcher } from "./stream"; +import type { Held } from "./tabs"; +import { checkVersions, isCob, versionsBefore, type Version } from "./versions"; + +// read while the write is still held or a hop cannot tell what it changed +export interface Before { + versions?: Version[]; + mark?: number; + knotMark?: number; + pdsMark?: number; + pdsExisted?: boolean; + released?: number; +} + +export interface Settling { + how: Held["method"] | "settled"; + before?: Before; +} + +export interface Hop { + name: string; + present: (written: Written, who: Account, settling?: Settling) => Promise; + absent: (written: Pick, who?: Account, settling?: Settling) => Promise; + look?: (uri: string | undefined) => Promise; +} + +// creates have no rkey yet, the knot mints it, so there is no uri to read +export const lookBefore = async (hops: Hop[], { body }: Held): Promise => { + const uri = body.rkey ? `at://${body.repo}/${body.collection}/${body.rkey}` : undefined; + return Object.assign({}, ...(await Promise.all(hops.map((hop) => hop.look?.(uri))))); +}; + +// one step per check so the report shows how long after the write each service had it +// the tidy replay runs outside a test, so test.info throws there +const step = (title: string, check: () => Promise): Promise => { + try { + test.info(); + } catch { + return check(); + } + return test.step(title, async (info) => { + const record = fresh(); + try { + return await within(record, check); + } finally { + await attach(info, record); + } + }); +}; + +// one hop is one row, so a failing sub-check is named on it +// the hop's own check keeps its plain message +const alongside = async ( + own: Promise, + checks: Record | undefined> +) => { + const named = Object.entries(checks).filter(([, check]) => check); + const [first, ...rest] = await Promise.allSettled([own, ...named.map(([, check]) => check)]); + const failed = [first, ...rest].flatMap((check, at) => + check.status === "rejected" + ? [`${at ? `${named[at - 1][0]}: ` : ""}${(check.reason as Error).message}`] + : [] + ); + if (failed.length) throw new Error(failed.join("\n ")); + return (first as PromiseFulfilledResult).value; +}; + +const recordHop = (name: string, base: string) => { + const has = ({ uri, cid }: Written) => + until(`${name} should have ${uri} at ${cid}`, 10_000, async () => { + const { status, body } = await get(recordUrl(base, uri)); + const saw = + status === 200 + ? `it has ${body.cid}` + : `it answered ${status} ${JSON.stringify(body)}`; + return [status === 200 && body.cid === cid, saw, body.value]; + }); + const lacks = ({ uri }: Pick) => + until(`${name} should have dropped ${uri}`, 10_000, async () => { + const { status, body } = await get(recordUrl(base, uri)); + // down or erroring is not the same as gone, only a 404 counts + const gone = status === 404 || body?.error === "RecordNotFound"; + return [ + gone, + status === 200 ? "it still has it" : `it answered ${status} ${JSON.stringify(body)}` + ]; + }); + return { name, host: new URL(base).host, has, lacks, present: has, absent: lacks }; +}; + +const firehosed = ( + watcher: Firehose, + written: { uri: string; cid?: string }, + settling?: Settling, + since?: number, + existed = true +) => + step("firehose", async () => { + const how = settling?.how; + const action = + how === "put" ? (existed ? "update" : "create") : how === "settled" ? undefined : how; + const frame = await watcher.check(written, action, since); + const released = settling?.before?.released; + if (released) note("arrived", `${frame.at - released} ms after release`); + }); + +const pdsRecords = recordHop("pds", config.pdsUrl); +export const pds = { + ...pdsRecords, + present: (written: Written, _who?: Account, settling?: Settling) => + alongside(pdsRecords.has(written), { + firehose: firehosed( + pdsFirehose, + written, + settling, + settling?.before?.pdsMark, + settling?.before?.pdsExisted + ) + }), + absent: ({ uri }: Pick, _who?: Account, settling?: Settling) => + alongside(pdsRecords.lacks({ uri }), { + firehose: firehosed(pdsFirehose, { uri }, settling, settling?.before?.pdsMark) + }), + look: async (uri: string | undefined): Promise => { + const pdsMark = pdsFirehose.mark(); + if (!uri) return { pdsMark }; + // a pds put can create too, so its method alone cannot tell the action + const { status, body } = await get(recordUrl(config.pdsUrl, uri)); + if (status !== 200 && status !== 404 && body?.error !== "RecordNotFound") + throw new Error( + `pds should say whether ${uri} exists before the write but answered ${status} ${JSON.stringify(body)}` + ); + return { pdsMark, pdsExisted: status === 200 }; + } +}; + +const hydrantDb = recordHop("hydrant db", config.hydrantUrl); +const streamed = ({ uri, cid }: { uri: string; cid?: string }, settling?: Settling) => + streamCarries(uri) + ? step("stream", async () => { + const { mark, released } = settling?.before ?? {}; + const frame = await checkStream({ uri, cid }, settling?.how ?? "settled", mark); + // checkStream only notices a frame on its next wake, so the arrival time comes from the watcher + if (released && frame) note("arrived", `${frame.at - released} ms after release`); + }) + : undefined; +export const hydrant = { + ...hydrantDb, + name: "hydrant", + present: (written: Written, _who?: Account, settling?: Settling) => + alongside( + step("db", () => hydrantDb.has(written)), + { stream: streamed(written, settling) } + ), + absent: ({ uri }: Pick, _who?: Account, settling?: Settling) => + alongside( + step("db", () => hydrantDb.lacks({ uri })), + { stream: streamed({ uri }, settling) } + ), + look: async () => ({ mark: watcher.mark() }) +}; + +const knotRecords = recordHop("knot", config.knotUrl); +// a cob's uri authority is the repo, so the author lives in the record body +const editorIs = async (written: Written, did: string) => { + const value = await knotRecords.has(written); + if (value["x-tngl-editor"] !== did) { + throw new Error( + `knot should show ${did} as the editor of ${written.uri} but x-tngl-editor is ${value["x-tngl-editor"]} (use knotCopy for a record the knot wrote itself)` + ); + } +}; + +// one listCobVersions read feeds both checks, no cid means the record was deleted +const cobChecks = ( + written: { uri: string; cid?: string }, + editor: string | undefined, + settling?: Settling +) => { + const firehose = + isCob(written.uri) || partsOf(written.uri).collection === "org.tangled.git.refs" + ? firehosed(knotFirehose, written, settling, settling?.before?.knotMark) + : undefined; + if (!isCob(written.uri)) return { firehose }; + const chain = step("versions", () => + checkVersions(written, { editor, had: settling?.before?.versions }) + ); + return { + firehose, + versions: chain, + // a chain that never settles fails as versions already, git would only repeat it + // git's step starts once the chain is in, so its bar is only git's time + git: chain.then( + (versions) => step("git", () => checkGit(written, versions)), + () => undefined + ) + }; +}; +const versionsLook = async (uri: string | undefined) => ({ + knotMark: knotFirehose.mark(), + versions: await versionsBefore(uri) +}); + +const knotHop = (checksEditor: boolean): Hop => ({ + name: "knot", + present: (written, who, settling) => { + const editor = checksEditor ? who.did : undefined; + return alongside( + step("record", () => (editor ? editorIs(written, editor) : knotRecords.has(written))), + cobChecks(written, editor, settling) + ); + }, + absent: ({ uri }, who, settling) => + alongside( + step("record", () => knotRecords.lacks({ uri })), + cobChecks({ uri }, checksEditor ? who?.did : undefined, settling) + ), + look: versionsLook +}); + +// the knot wrote this one itself, so there is no x-tngl-editor to check +export const knotCopy = knotHop(false); + +export const knot = { ...knotRecords, ...knotHop(true) }; + +// the knot rewrites the refs record a moment after a push, so wait for it +export const refsNaming = ( + uri: string, + what: string, + wanted: (named: [sha: string, ref: string][]) => boolean +) => + until(what, 10_000, async () => { + const now = await refsOf(uri); + if (!now) return [false, "it has no refs record"]; + const said = now.named.map(([sha, ref]) => `${ref} ${sha}`).join(", "); + return [wanted(now.named), `it names ${said}`, now]; + }); + +export const git: Hop = { + name: "git ls-remote", + present: (written, who) => + until(`git should list exactly the refs ${written.uri} names`, 10_000, async () => { + const stored = await refsOf(written.uri); + if (stored?.cid !== written.cid) + return [false, `the knot has ${stored?.cid ?? "no record"}`]; + const named = Object.fromEntries(stored.named.map(([sha, ref]) => [ref, sha])); + if (Object.keys(named).length === 0) return [false, "the record names no refs"]; + // git lists HEAD, the refs record never does + const listed = await lsRemote(who, partsOf(written.uri).repo, "refs/"); + return [ + JSON.stringify(Object.entries(named).sort()) === + JSON.stringify(Object.entries(listed).sort()), + `git lists ${JSON.stringify(listed)}, the record names ${JSON.stringify(named)}` + ]; + }), + absent: async () => { + throw new Error("git has no refs record to lose"); + } +}; + +const awaitRecord = async ({ uri, cid }: { uri: string; cid?: string }) => + ( + await get( + xrpc(config.bobbinUrl, "sh.tangled.bobbin.awaitRecord", { + uri, + ...(cid ? { cid } : {}) + }) + ) + ).body; + +const said = (body: { status?: string; reason?: string; detail?: string }) => + body.status + ? `it said ${body.status} ${body.reason ?? ""} ${body.detail ?? ""}` + : JSON.stringify(body); + +// parallel workers can push pds to hydrant to bobbin past 15 s, hence 30 s +const indexed = (written: Written) => + until( + `bobbin should have indexed ${written.uri} at ${written.cid}`, + 30_000, + async () => { + const body = await awaitRecord(written); + // a rejected record never gets indexed, so throw instead of polling + if (body.status === "rejected") + throw new Error(`bobbin should have indexed ${written.uri} but ${said(body)}`); + return [body.status === "indexed" && body.cid === written.cid, said(body)]; + }, + { longPoll: true } + ); + +const deleted = ({ uri }: Pick) => + until( + `bobbin should have deleted ${uri}`, + 15_000, + async () => { + const body = await awaitRecord({ uri }); + return [body.status === "deleted", said(body)]; + }, + { longPoll: true } + ); + +const mirrorList = async (kind: "Branches" | "Tags", repoDid: string) => { + const { status, body } = await get( + xrpc(config.bobbinUrl, `org.tangled.temp.git.list${kind}`, { repo: repoDid, limit: "100" }) + ); + const names: string[] = (body?.[kind.toLowerCase()] ?? []).map((one: any) => one.name); + return { status, names }; +}; + +export const bobbin = { + name: "bobbin", + indexed, + list: mirrorList, + present: indexed, + absent: deleted, + // a stall here means knot2 is rate limiting hydrant's backfill + repoByName: (owner: string, name: string) => + get(xrpc(config.bobbinUrl, "sh.tangled.repo.getRepoByName", { owner, name })), + hasRepo: (ownerDid: string, name: string) => + until( + `bobbin should know the repo ${name} (hydrant may be backing off knot2)`, + 30_000, + async () => { + const { status } = await bobbin.repoByName(ownerDid, name); + return [status === 200, `getRepoByName answered ${status}`]; + } + ), + // the mirror 404s an unsynced repo, even an empty one, and the web reads that as a knot needing an upgrade + knowsRepo: (repoDid: string) => + until(`bobbin should know ${repoDid}`, 60_000, async () => { + const { status } = await mirrorList("Branches", repoDid); + return [status === 200, `listBranches answered ${status}`]; + }), + // the mirror picks a push up a moment after the knot, so pages that read git wait for this first + hasBranch: (repoDid: string, branch: string, sha: string) => + until(`bobbin should have ${branch} at ${sha}`, 60_000, async () => { + const { status, body } = await get( + xrpc(config.bobbinUrl, "org.tangled.temp.git.getRef", { + repo: repoDid, + ref: `refs/heads/${branch}` + }) + ); + return [ + status === 200 && body.oid === sha, + `getRef answered ${status} ${JSON.stringify(body)}` + ]; + }), + countStars: async (subject: string) => { + const body = await getOk(xrpc(config.bobbinUrl, "sh.tangled.feed.countStars", { subject })); + return body.count as number; + } +}; + +// the trigger $type namespace differs, so match on the .event. suffix +export const spindle = { + hasPipeline: (repoDid: string, sha: string, event: "push" | "pullRequest") => + until( + `spindle should have a ${event} pipeline for ${repoDid} at ${sha}`, + 30_000, + async () => { + const { status, body } = await get( + xrpc(config.spindleUrl, "sh.tangled.ci.queryPipelines", { + repo: repoDid, + commits: sha + }) + ); + const pipelines: { commit: string; trigger?: { $type?: string } }[] = + body.pipelines ?? []; + const found = pipelines.find( + (p) => p.commit === sha && p.trigger?.$type?.endsWith(`.event.${event}`) + ); + const saw = + status === 200 + ? `it listed ${pipelines.length} pipelines for the commit` + : `it answered ${status}`; + return [Boolean(found), saw, found]; + } + ) +}; + +export const settle = async ( + written: Written, + hops: Hop[], + who: Account, + nsid: string, + how: Settling["how"], + before?: Before +) => { + const settling = { how, before }; + const checks = await Promise.allSettled( + hops.map((hop) => + step(hop.name, () => + how === "delete" + ? hop.absent(written, who, settling) + : hop.present(written, who, settling) + ) + ) + ); + if (checks.every((check) => check.status === "fulfilled")) return; + const table = checks.map((check, at) => + check.status === "fulfilled" + ? ` ✓ ${hops[at].name}` + : ` ✗ ${hops[at].name} ${(check.reason as Error).message}` + ); + throw new Error([`${nsid} ${written.uri} ${written.cid} (${how})`, ...table].join("\n")); +}; + +// pocket keys the saved lists by the token's aud +const POCKET_AUD = "did:web:tangled.org"; +const KNOT_PREF = "org.tangled.temp.actor.defs#savedKnotPref"; +const SPINDLE_PREF = "org.tangled.temp.actor.defs#savedSpindlePref"; + +export interface Saved { + knots: string[]; + spindles: string[]; +} + +export const savedBy = async (who: Account): Promise => { + const lxm = "com.bad-example.pocket.getPreferences"; + const reply = await serviceDo(who, config.bobbinUrl, POCKET_AUD, lxm); + // a 400 NotFound means the account never saved anything + if (reply.status === 400 && reply.body?.error === "NotFound") + return { knots: [], spindles: [] }; + if (reply.status !== 200) throw new Error(`pocket answered ${JSON.stringify(reply.body)}`); + const values = (type: string): string[] => + reply.body.preferences + .filter((pref: { $type: string }) => pref.$type === type) + .flatMap((pref: { items: { value: string }[] }) => + pref.items.map((item) => item.value) + ); + return { knots: values(KNOT_PREF).sort(), spindles: values(SPINDLE_PREF).sort() }; +}; + +// a single try, saveFor is the one that retries +export const putSaved = async (who: Account, saved: Saved): Promise => { + const lxm = "com.bad-example.pocket.putPreferences"; + const items = (hosts: string[]) => hosts.map((host) => ({ id: host, value: host })); + return serviceDo(who, config.bobbinUrl, POCKET_AUD, lxm, { + json: { + preferences: [ + { $type: KNOT_PREF, items: items(saved.knots) }, + { $type: SPINDLE_PREF, items: items(saved.spindles) } + ] + } satisfies PocketPreferences + }); +}; + +// pocket can't resolve a new account's did doc for a moment +export const saveFor = async (who: Account, saved: Saved) => + until(`pocket should take the save for ${who.handle}`, 15_000, async () => { + const reply = await putSaved(who, saved); + return [reply.status === 200, `it answered ${reply.status} ${JSON.stringify(reply.body)}`]; + }); + +// deliberi only answers the recipient, not other accounts +const deliberiGet = async (who: Account, lxm: string, params?: Record) => { + const aud = `did:web:${new URL(config.deliberiUrl).host}`; + const reply = await serviceDo(who, config.deliberiUrl, aud, lxm, { params }); + if (reply.status !== 200) throw new Error(`deliberi answered ${JSON.stringify(reply.body)}`); + return reply.body; +}; + +export type Toggles = Record; + +export const notificationsOf = (who: Account): Promise => + deliberiGet(who, "org.tangled.temp.notification.getPreferences"); + +export interface Notice { + uri: string; + type: string; + read: boolean; + actorDid: string; + repoDid?: string; + issueAt?: string; +} + +export const noticesOf = async (who: Account): Promise => + (await deliberiGet(who, "org.tangled.temp.notification.listNotifications", { limit: "50" })) + .notifications; diff --git a/e2e/harness/shared.ts b/e2e/harness/shared.ts new file mode 100644 index 000000000..4836d3c0b --- /dev/null +++ b/e2e/harness/shared.ts @@ -0,0 +1,323 @@ +import { expect } from "@playwright/test"; + +import { knotReply, knotRecordFor, labels, ticketRecord, tickets } from "./api"; +import { config, type Account } from "./config"; +import { flow, type Flow } from "./flow"; +import { deleteBranch, lsRemote, pushAnnotatedTag } from "./git"; +import { ok, selfUri, until, xrpc, type Reply, type Written } from "./net"; +import { bobbin, hydrant, knot, pds, refsNaming, spindle } from "./services"; +import { + alerts, + attestationId, + button, + commentId, + commentMenu, + confirmDelete, + dialogButton, + discussion, + editLabels, + labelOption, + manifestId, + menuItem, + mergeButton, + moreActions, + not, + page, + patchId, + pullId, + refsId, + rkeyOf, + replacing, + resolve, + saveButton, + subscriptionId, + text, + through, + ticketId, + triage, + triageChip, + triageToken, + type Ctx, + type Lazy, + type Slot +} from "./vocabulary"; + +const remembered = new WeakMap>(); +export const remember = (label: string) => async (ctx: Ctx) => { + remembered.set(ctx, { ...remembered.get(ctx), [label]: ctx.actor }); +}; +export const recall = (ctx: Ctx, label: string) => { + const who = remembered.get(ctx)?.[label]; + if (!who) throw new Error(`no ${label} was remembered`); + return who; +}; + +// the login lives in cookies and web storage, so both have to go +export const forgetTheLogin = async (ctx: Ctx) => { + await ctx.page.context().clearCookies(); + await ctx.page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); +}; + +export const acceptConfirm = async (ctx: Ctx) => { + ctx.page.once("dialog", (dialog) => void dialog.accept()); +}; + +export const jsonError = (status: number, error: string, message: string) => ({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ error, message }) +}); + +// only this page fails, and page routes run before the held writes, so the knot never sees it +export const answerWith = + (pattern: string | RegExp, status: number, error: string, message: string) => (ctx: Ctx) => + ctx.page.route(pattern, (route) => route.fulfill(jsonError(status, error, message))); + +export const finished = async (ctx: Ctx, what: string, done: Promise) => + done.catch(async (cause) => { + const said = await alerts.locate(ctx.page, ctx).allInnerTexts(); + throw new Error(`${what}, the page said: ${said.join(" | ") || "nothing"}`, { cause }); + }); + +export const saveFinishes = (ctx: Ctx) => + finished( + ctx, + "the save did not finish", + expect(saveButton.locate(ctx.page, ctx)).toBeDisabled() + ); + +export const learnRefs = (ref: string, there: boolean) => async (ctx: Ctx) => { + const uri = selfUri(ctx.repo.did, refsId.nsid); + const stored = await refsNaming( + uri, + `the knot should ${there ? "list" : "drop"} ${ref} in ${uri}`, + (named) => named.some(([, name]) => name === ref) === there + ); + ctx.get(refsId).cid = stored.cid; +}; + +export const deletesBranch = (branch: string) => async (ctx: Ctx) => + deleteBranch(ctx.actor, ctx.repo.did, branch); + +export const pushesTag = (tag: string, message: string) => async (ctx: Ctx) => + pushAnnotatedTag(ctx.actor, ctx.repo.did, tag, message); + +// bobbin lags the knot by a few seconds, so this polls +export const mirrorLists = + (kind: "Branches" | "Tags", name: string, there: boolean) => async (ctx: Ctx) => + until(`bobbin should ${there ? "list" : "drop"} ${name}`, 60_000, async () => { + const { status, names } = await bobbin.list(kind, ctx.repo.did); + return [ + status === 200 && names.includes(name) === there, + `it answered ${status} ${names}` + ]; + }); + +// the sidebar can't make a label on a new repo, so this goes through the api +export const defineLabel = (name: string) => async (ctx: Ctx) => { + const made = await labels.define(ctx.actor, ctx.repo.did, name); + expect(made.status, `creating the label answered ${JSON.stringify(made.body)}`).toBe(200); + await bobbin.indexed({ uri: made.body.uri, cid: made.body.cid }); +}; + +export const labelOn = (slot: Slot, noun: string) => (flow: Flow) => + flow + .sees(not(triageChip)) + .click(editLabels) + .click(labelOption) + .sends(slot, knot, replacing(slot, { tangledLabels: [triageToken] }), "put") + // the chip shows before the knot has the label + .sees(triageChip) + .trace(slot, through(knot, hydrant, bobbin)) + .step(`the knot's ${noun} carries the label`, async (ctx) => + expect(await labelsOnKnot(ctx, slot)).toEqual([triage]) + ) + .reload() + .sees(triageChip); + +export const labelOff = (slot: Slot, noun: string) => (flow: Flow) => + flow + .click(editLabels) + .click(labelOption) + .sends(slot, knot, replacing(slot), "put") + .sees(not(triageChip)) + .trace(slot, through(knot, hydrant, bobbin)) + .step(`the knot's ${noun} has no label left`, async (ctx) => + expect(await labelsOnKnot(ctx, slot)).toEqual([]) + ) + .reload() + .sees(not(triageChip)); + +export const labelsOnKnot = async (ctx: Ctx, on: Slot) => + ((await knot.has(ctx.get(on))).tangledLabels ?? []).map((token: { key: string }) => token.key); + +export const manifestOf = async (ctx: Ctx) => + (await knotRecordFor(selfUri(ctx.repo.did, manifestId.nsid), "read")).value; + +export const topicsOnKnot = async (ctx: Ctx) => (await manifestOf(ctx)).topics ?? []; + +export const picture = { + name: "me.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64" + ) +}; + +export const pdsRecordUrl = (repo: string, collection: string, rkey: string) => + xrpc(config.pdsUrl, "com.atproto.repo.getRecord", { repo, collection, rkey }); + +export const made = (what: string, reply: Reply): Written => { + const { uri, cid } = ok(what, reply); + return { uri, cid }; +}; + +interface IssueOptions { + title: string; + body?: Lazy; + state?: "open" | "closed"; + wait?: boolean; +} + +export const issue = (slot: Slot, options: IssueOptions) => async (ctx: Ctx) => { + const body = options.body === undefined ? undefined : resolve(options.body, ctx); + const reply = await knotReply(ctx.actor, ctx.repo.did, "com.atproto.repo.createRecord", { + collection: ticketId.nsid, + record: ticketRecord( + options.title, + body === undefined ? {} : { body: { $type: "org.tangled.markup.markdown", text: body } } + ) + }); + let written = made(`opening ${options.title}`, reply); + // the knot only creates tickets open, so closing takes a second call + if (options.state === "closed") written = await tickets.setState(ctx.actor, written, "closed"); + ctx.adopt(slot, written); + if (options.wait !== false) await bobbin.indexed(written); +}; + +export const stillStored = + (slot: Slot, said: (value: any) => string, words: string) => async (ctx: Ctx) => { + const stored = await knot.has(ctx.get(slot)); + expect(said(stored)).toBe(words); + await hydrant.has(ctx.get(slot)); + }; + +export const workflow = `engine: dummy +when: + - event: ["push", "pull_request"] + branch: ["main"] +steps: + - name: hello + command: echo "build ok" +`; + +export const workflowPath = ".tangled/workflows/ci.yml"; + +export const pipelineFor = + (event: "push" | "pullRequest", sha: (ctx: Ctx) => string) => (ctx: Ctx) => + spindle.hasPipeline(ctx.repo.did, sha(ctx), event); + +export const base = { "e2e/base.txt": "base\n" }; + +// the knot only takes patches whose commits are already in the repo, so pulls stay inside one repo +export const branches = { from: "feature", into: "main" }; + +// the pull pages read git through the mirror, so the repo has to be mirrored +export const onBranches = (name: string, options: { ci?: boolean } = {}) => + flow(name) + .actor("owner") + .repo(options.ci ? { spindle: config.spindleDid } : {}) + .push(options.ci ? { ...base, [workflowPath]: workflow } : base) + .push({ "e2e/feature.txt": "feature\n" }, { branch: branches.from, base: branches.into }) + .mirrored(); + +export const sendsPatch = + ({ fork = false } = {}) => + (flow: Flow) => + flow + .sends( + patchId, + knot, + (ctx) => ({ + ...(fork ? { repo: ctx.upstream.did } : {}), + record: { + ticket: rkeyOf(ctx, pullId), + base: { oid: (fork ? ctx.upstream : ctx.repo).heads[branches.into] }, + head: { oid: ctx.repo.heads[branches.from] } + } + }), + "create" + ) + .trace(patchId, through(knot, hydrant, bobbin)) + .sends( + attestationId, + pds, + (ctx) => ({ + record: { subject: { uri: ctx.get(patchId).uri, cid: ctx.get(patchId).cid } } + }), + "put" + ) + .trace(attestationId, through(pds)); + +export const subscribesToPull = (flow: Flow) => + flow + .sends( + subscriptionId, + pds, + (ctx) => ({ record: { subject: { uri: ctx.get(pullId).uri } } }), + "create" + ) + .trace(subscriptionId, through(pds)); + +export const merges = (flow: Flow) => + flow + .click(mergeButton) + .sees(text("Merged")) + .learn(pullId) + .follow(pullId, through(knot, hydrant, bobbin)); + +export const targetIsAt = + (branch: string, who = (ctx: Ctx) => ctx.actor) => + async (ctx: Ctx) => { + const heads = await lsRemote(who(ctx), ctx.repo.did); + expect(heads[`refs/heads/${branches.into}`]).toBe(ctx.repo.heads[branch]); + }; + +export const commented = "needs a comment"; +export const note = "same here"; + +export const onCommentedIssue = ( + name: string, + { title = commented, words = note, subscription = through(pds) } = {} +) => + flow(name) + .actor("owner") + .repo() + .ticket(ticketId, { title }) + .open(page.issue(ticketId)) + .fill(discussion, words) + .click(button("Comment")) + .trace(commentId, through(knot, hydrant, bobbin)) + .trace(subscriptionId, subscription) + .sees(text(words)); + +export const deletesComment = (flow: Flow) => + flow + .click(commentMenu) + .click(menuItem("Delete comment")) + .click(confirmDelete) + .sends(commentId, knot, replacing(commentId), "delete") + .trace(commentId, through(knot, hydrant, bobbin)); + +export const deletesIssue = (flow: Flow) => + flow + .click(moreActions) + .click(menuItem("Delete")) + .click(dialogButton("Delete issue")) + .sends(ticketId, knot, replacing(ticketId), "delete") + .trace(ticketId, through(knot, hydrant, bobbin)); diff --git a/e2e/harness/signin.ts b/e2e/harness/signin.ts new file mode 100644 index 000000000..934daa70f --- /dev/null +++ b/e2e/harness/signin.ts @@ -0,0 +1,338 @@ +import { createDpopFetch, generateDpopKey, generatePkce } from "@atcute/oauth-crypto"; +import type { BrowserContextOptions, Page } from "@playwright/test"; +import { randomBytes } from "node:crypto"; +import { parseCookie, request } from "undici"; + +import { config, type Account } from "./config"; +import { dispatcher, ok, parseBody, retry429 } from "./net"; +import { keep } from "./record"; + +type StorageState = Exclude; +type Cookie = StorageState["cookies"][number]; + +interface Reply { + status: number; + headers: Record; + body: any; +} + +const once = (load: () => Promise) => { + let kept: Promise | undefined; + return () => { + kept ??= load().catch((cause) => { + kept = undefined; + throw cause; + }); + return kept; + }; +}; + +const flatHeaders = (headers: Reply["headers"]) => + Object.fromEntries( + Object.entries(headers).map(([name, value]) => [name, [value ?? ""].flat().join(", ")]) + ); + +// undici keeps the set-cookie headers and doesn't follow redirects +const send = ( + url: string, + method: "GET" | "POST", + headers: Record = {}, + json?: unknown +): Promise => + retry429(async () => { + const sentHeaders = + json === undefined ? headers : { ...headers, "content-type": "application/json" }; + const sentBody = json === undefined ? undefined : JSON.stringify(json); + const started = Date.now(); + const response = await request(url, { + dispatcher, + method, + signal: AbortSignal.timeout(10_000), + headers: sentHeaders, + body: sentBody + }).catch((error) => { + throw new Error(`${method} ${url}: ${error.code ?? error.message}`); + }); + const text = await response.body.text(); + keep({ + by: "node", + method, + url, + status: response.statusCode, + ms: Date.now() - started, + request: { headers: sentHeaders, body: sentBody }, + response: { headers: flatHeaders(response.headers), body: text } + }); + return { status: response.statusCode, headers: response.headers, body: parseBody(text) }; + }); + +const clientMetadata = once(async () => + ok( + "the web's client metadata", + await send(`${config.webUrl}/oauth-client-metadata.json`, "GET") + ) +); + +const authServer = once(async () => { + const resource = ok( + "the pds resource metadata", + await send(`${config.pdsUrl}/.well-known/oauth-protected-resource`, "GET") + ); + const issuer = resource.authorization_servers[0]; + return ok( + "the pds authorization server metadata", + await send(`${issuer}/.well-known/oauth-authorization-server`, "GET") + ); +}); + +// the pds wants the newest dpop nonce echoed back, kept per origin +const nonces = new Map(); + +const dpopPost = (dpop: typeof fetch, url: string, json: unknown) => + retry429(async () => { + const response = await dpop(url, { + method: "POST", + signal: AbortSignal.timeout(10_000), + headers: { "content-type": "application/json" }, + body: JSON.stringify(json) + }).catch((error) => { + throw new Error(`POST ${url}: ${error.cause?.code ?? error.message}`); + }); + return { status: response.status, body: parseBody(await response.text()) }; + }); + +class Jar { + private cookies = new Map(); + + constructor(private host: string) {} + + take(reply: Reply) { + for (const line of [reply.headers["set-cookie"] ?? []].flat()) { + const parsed = parseCookie(line); + if (!parsed) continue; + const { name, value, maxAge, expires } = parsed; + const ends = maxAge === undefined ? expires : Date.now() + maxAge * 1000; + this.cookies.set(name, { + name, + value, + domain: this.host, + path: parsed.path ?? "/", + expires: ends === undefined ? -1 : new Date(ends).getTime() / 1000, + httpOnly: Boolean(parsed.httpOnly), + secure: Boolean(parsed.secure), + sameSite: parsed.sameSite ?? "Lax" + }); + } + } + + get header() { + return this.all.map(({ name, value }) => `${name}=${value}`).join("; "); + } + + value(name: string) { + return this.cookies.get(name)?.value ?? ""; + } + + get all() { + return [...this.cookies.values()]; + } +} + +const authorize = async (account: Account, dpop: typeof fetch, challenge: string) => { + const [client, server] = await Promise.all([clientMetadata(), authServer()]); + const pds = new URL(server.issuer); + const state = randomBytes(18).toString("base64url"); + const par = ok( + "the pds par", + await dpopPost(dpop, server.pushed_authorization_request_endpoint, { + redirect_uri: client.redirect_uris[0], + code_challenge: challenge, + code_challenge_method: "S256", + state, + login_hint: account.handle, + response_mode: "fragment", + response_type: "code", + scope: client.scope, + client_id: client.client_id + }), + 201 + ); + + // opening the page is what sets the device and csrf cookies the calls need + const jar = new Jar(pds.host); + const page = `${server.authorization_endpoint}?${new URLSearchParams({ + client_id: client.client_id, + request_uri: par.request_uri + })}`; + const opened = await send(page, "GET", { + "sec-fetch-site": "same-site", + "sec-fetch-mode": "navigate", + "sec-fetch-dest": "document", + referer: `${config.webUrl}/` + }); + if (opened.status !== 200) throw new Error(`the pds authorize page answered ${opened.status}`); + jar.take(opened); + + // the api only answers requests that look like they came from its own page + const asThePage = (headers: Record = {}) => ({ + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "same-origin", + "sec-fetch-dest": "empty", + origin: pds.origin, + referer: page, + "x-csrf-token": jar.value("csrf-token"), + cookie: jar.header, + ...headers + }); + const api = `${pds.origin}/@atproto/oauth-provider/~api`; + const signingIn = await send(`${api}/sign-in`, "POST", asThePage(), { + username: account.handle, + password: config.password, + remember: false, + locale: "en" + }); + const signedIn = ok("the pds sign in", signingIn); + jar.take(signingIn); + if (signedIn.account.sub !== account.did) + throw new Error(`${account.handle} signed in as ${signedIn.account.sub}`); + + const consent = await send( + `${api}/consent`, + "POST", + asThePage({ authorization: `Bearer ${signedIn.ephemeralToken}` }), + { sub: account.did, scope: client.scope } + ); + jar.take(consent); + // consent returns a url that only bounces the code on to the web's callback + const sent = new URL(ok("the pds consent", consent).url).searchParams; + const code = sent.get("code"); + if (!code || sent.get("state") !== state || sent.get("iss") !== server.issuer) + throw new Error(`the pds did not send this request's code back: ${sent}`); + return { client, server, code, cookies: jar.all }; +}; + +export const mintSession = async (account: Account): Promise => { + const key = await generateDpopKey(["ES256"]); + const dpop = createDpopFetch({ + key, + nonces: { + get: (origin) => nonces.get(origin), + set: (origin, nonce) => void nonces.set(origin, nonce) + }, + isAuthServer: true, + // undici's fetch can't read the Request atcute builds, node's fetch can and takes our dispatcher + fetch: (input, init) => fetch(input, { ...init, dispatcher } as RequestInit) + }); + const { verifier, challenge } = await generatePkce(); + const { client, server, code, cookies } = await authorize(account, dpop, challenge); + const token = ok( + "the pds token", + await dpopPost(dpop, server.token_endpoint, { + grant_type: "authorization_code", + redirect_uri: client.redirect_uris[0], + code, + code_verifier: verifier, + client_id: client.client_id + }) + ); + if (token.sub !== account.did || token.token_type !== "DPoP") + throw new Error(`the pds gave a ${token.token_type} token for ${token.sub}`); + const granted = new Set(token.scope.split(" ")); + const missing = client.scope.split(" ").filter((scope: string) => !granted.has(scope)); + if (missing.length) throw new Error(`the pds did not grant ${missing.join(" ")}`); + + const now = Date.now(); + const pds = new URL(server.issuer).origin; + const web = new URL(config.webUrl); + const session = { + value: { + dpopKey: key, + info: { + sub: account.did, + // the web reads aud from the did document, which names this pds + aud: new URL(config.pdsUrl).href, + server: { + issuer: server.issuer, + authorization_endpoint: server.authorization_endpoint, + pushed_authorization_request_endpoint: + server.pushed_authorization_request_endpoint, + revocation_endpoint: server.revocation_endpoint, + token_endpoint: server.token_endpoint + } + }, + token: { + scope: token.scope, + refresh: token.refresh_token, + access: token.access_token, + type: "DPoP", + expires_at: now + token.expires_in * 1000 + } + }, + expiresAt: null + }; + const nonce = nonces.get(pds); + const known = nonce + ? { [pds]: { value: nonce, expiresAt: now + 86_400_000, updatedAt: now } } + : {}; + const webCookie = (name: string, value: string): Cookie => ({ + name, + value, + domain: web.host, + path: "/", + expires: now / 1000 + 365 * 24 * 60 * 60, + httpOnly: false, + secure: true, + sameSite: "Lax" + }); + const storage = { + "tangled.currentHandle": account.handle, + "atcute-oauth:sessions": JSON.stringify({ [account.did]: session }), + "tangled.accounts": JSON.stringify([ + { did: account.did, handle: account.handle, addedAt: Math.floor(now / 1000) } + ]), + "atcute-oauth:states": "{}", + "tangled.currentDid": account.did, + "atcute-oauth:dpopNonces": JSON.stringify(known) + }; + return { + cookies: [ + ...cookies, + webCookie("tangled.currentDid", encodeURIComponent(account.did)), + webCookie("tangled.currentHandle", account.handle) + ], + origins: [ + { + origin: web.origin, + localStorage: Object.entries(storage).map(([name, value]) => ({ name, value })) + } + ] + }; +}; + +export const logIn = async (page: Page, account: Account) => { + await page.goto("/login", { waitUntil: "networkidle" }); + await signInHere(page, account); +}; + +export const signInHere = async (page: Page, account: Account) => { + // the form ignores clicks until it hydrates, so the page has to settle first + await page.waitForLoadState("networkidle"); + // the typeahead popover covers the button, so enter submits instead + await page.getByLabel("Handle", { exact: true }).fill(account.handle); + await page.getByLabel("Handle", { exact: true }).press("Enter"); + await page.locator("input[type=password]").fill(config.password); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await page.getByRole("button", { name: "Authorize", exact: true }).click(); + // an account with no profile lands on the welcome wizard, so only leaving /oauth is waited for + await page.waitForURL( + (url) => url.origin === config.webUrl && !url.pathname.startsWith("/oauth") + ); + // currentDid appears before the tokens are stored, so wait for both + await page.waitForFunction( + (did) => + localStorage.getItem("tangled.currentDid") === did && + Boolean(localStorage.getItem("atcute-oauth:sessions")?.includes(did)), + account.did + ); + await page.waitForLoadState("networkidle"); +}; diff --git a/e2e/harness/stream.ts b/e2e/harness/stream.ts new file mode 100644 index 000000000..ad8d949c0 --- /dev/null +++ b/e2e/harness/stream.ts @@ -0,0 +1,203 @@ +import { WebSocket } from "undici"; + +import { config } from "./config"; +import { Frames } from "./frames"; +import { dispatcher, get, partsOf } from "./net"; +import type { Held } from "./tabs"; + +type Action = "create" | "update" | "delete"; + +interface Seen { + id: number; + action: Action; + cid?: string; + live: boolean; + at: number; +} + +// /stats lags the newest id, so start the replay a bit before it +const REPLAY = 1_000; +// frames go out when the db write commits, so this only covers the db hop +const BUDGET = 10_000; + +const uriOf = (record: { did: string; collection: string; rkey: string }) => + `at://${record.did}/${record.collection}/${record.rkey}`; + +// opened before the first flow so a check can look back at earlier frames +class Watcher extends Frames { + private socket?: WebSocket; + private floor = -1; + private last = -1; + private stopped = true; + private retries = 0; + private downSince?: number; + private lastWords = ""; + + get running() { + return !this.stopped; + } + + async start() { + if (this.running) return; + const { status, body } = await get(`${config.hydrantUrl}/stats`); + const stored = body?.counts?.events; + if (status !== 200 || typeof stored !== "number") { + throw new Error( + `hydrant /stats should count its events but answered ${status} ${JSON.stringify(body)}` + ); + } + this.floor = Math.max(0, stored - REPLAY); + this.stopped = false; + await this.connect().catch((cause) => { + this.stop(); + throw cause; + }); + } + + stop() { + this.stopped = true; + this.socket?.close(); + } + + // take it before the write goes through, the frames it causes all get higher ids + mark() { + return Math.max(this.last, this.floor - 1); + } + + // a dead socket must never look like hydrant being quiet + describe(since: number) { + const parts = [`the watcher is at #${this.last}`]; + if (since < this.forgotUpTo) parts.push(`it forgot frames up to #${this.forgotUpTo}`); + if (this.downSince !== undefined) { + const down = ((Date.now() - this.downSince) / 1000).toFixed(1); + parts.push( + `it has been disconnected for ${down}s (${this.lastWords || "no reason given"})` + ); + } + return parts.join(", "); + } + + // a reconnect resumes at the next id and hydrant replays the record frames missed while down + private connect() { + const cursor = this.last >= 0 ? this.last + 1 : this.floor; + const url = `${config.hydrantUrl.replace(/^http/, "ws")}/stream?cursor=${cursor}`; + const socket = new WebSocket(url, { dispatcher }); + this.socket = socket; + socket.addEventListener("message", (event) => this.take(JSON.parse(String(event.data)))); + socket.addEventListener("open", () => { + this.downSince = undefined; + this.retries = 0; + }); + socket.addEventListener("close", (event) => { + if (this.stopped) return; + this.downSince ??= Date.now(); + this.lastWords ||= `close ${event.code} ${event.reason}`.trim(); + const wait = Math.min(250 * 2 ** this.retries++, 2_000); + setTimeout(() => { + if (!this.stopped) this.connect().catch(() => {}); + }, wait); + }); + return new Promise((opened, failed) => { + socket.addEventListener("open", () => opened(), { once: true }); + socket.addEventListener("error", () => failed(new Error(`${url} would not open`)), { + once: true + }); + }); + } + + private take(frame: any) { + if (frame.type === "error") { + this.lastWords = `hydrant said ${frame.error}: ${frame.message}`; + return; + } + if (typeof frame.id !== "number" || frame.id <= this.last) return; + this.last = frame.id; + this.lastWords = ""; + if (frame.type === "record") this.keep(frame.id, frame.record); + this.wake(); + } + + private keep(id: number, record: any) { + const uri = uriOf(record); + this.append(uri, { + id, + action: record.action, + cid: record.cid, + live: record.live, + at: Date.now() + }); + } +} + +export const watcher = new Watcher(); + +const waitFor = async (what: string, check: () => [done: boolean, saw: string, newest?: Seen]) => { + const deadline = Date.now() + BUDGET; + for (;;) { + const [done, saw, newest] = check(); + if (done) return newest; + const left = deadline - Date.now(); + if (left <= 0) throw new Error(`${what}, gave up after ${BUDGET / 1000}s: ${saw}`); + await watcher.changed(left); + } +}; + +const told = (seen: Seen) => + `${seen.action}${seen.cid ? ` ${seen.cid}` : ""} #${seen.id}${seen.live ? "" : " (backfill)"}`; + +const framesOf = (uri: string, since: number) => { + if (!watcher.running) { + throw new Error( + `hydrant's stream should be watched for ${uri} but this process never opened it` + ); + } + const frames = watcher.seen(uri).filter((seen) => seen.id > since); + const after = since < 0 ? "since the watcher opened" : `after #${since}`; + const said = frames.length + ? `${after} it announced ${frames.slice(-5).map(told).join(", ")}` + : `it announced nothing for that uri ${after}`; + return { frames, said: `${said}, ${watcher.describe(since)}` }; +}; + +const replacedBy = (uri: string, seen: Seen) => { + const before = watcher + .seen(uri) + .filter((other) => other.id < seen.id && other.cid !== seen.cid) + .at(-1); + return before?.action === "delete" ? undefined : before; +}; + +export const checkStream = ( + { uri, cid }: { uri: string; cid?: string }, + how: Held["method"] | "settled", + since = -1 +) => { + if (cid === undefined) + return waitFor(`hydrant's stream should announce the delete of ${uri}`, () => { + const { frames, said } = framesOf(uri, since); + const newest = frames.at(-1); + return [newest?.action === "delete", said, newest]; + }); + const wanted = how === "create" ? "a create" : "a create or update"; + return waitFor(`hydrant's stream should announce ${wanted} of ${uri} at ${cid}`, () => { + const { frames, said } = framesOf(uri, since); + const newest = frames.at(-1); + // only the newest frame counts, so an older cid coming back after it fails too + if (newest?.cid !== cid || newest.action === "delete") return [false, said]; + const ours = frames.filter((seen) => seen.cid === cid); + if (how === "create") return [ours.some((seen) => seen.action === "create"), said, newest]; + // a backfill says create for old records too, so only a live create counts as new + const stillThere = ours.map((seen) => + seen.live && seen.action === "create" ? replacedBy(uri, seen) : undefined + ); + if (stillThere.every(Boolean)) + return [ + false, + `${said}, but ${told(stillThere[0]!)} was still there so it had to be an update` + ]; + return [true, said, newest]; + }); +}; + +// hydrant only indexes org.tangled.*, so other collections never reach the stream +export const streamCarries = (uri: string) => partsOf(uri).collection.startsWith("org.tangled."); diff --git a/e2e/harness/tabs.ts b/e2e/harness/tabs.ts new file mode 100644 index 000000000..882420c73 --- /dev/null +++ b/e2e/harness/tabs.ts @@ -0,0 +1,230 @@ +import { + test, + type Browser, + type BrowserContext, + type Page, + type Request, + type Route +} from "@playwright/test"; + +import { authFile, isSeeded, type Account } from "./config"; +import { parseBody, sleep, type Reply } from "./net"; +import { watchBrowser } from "./record"; +import { logIn, mintSession } from "./signin"; +import type { Slot } from "./vocabulary"; + +interface Write { + nsid: string; + host: string; + body: any; + route: Route; +} + +export interface Held { + method: "create" | "put" | "delete"; + // the knot and the pds take the same xrpc, so the host is what tells them apart + host: string; + body: any; + release: () => Promise; +} + +const SETTLE_MS = 100; + +const holdWrites = async (where: Page | BrowserContext) => { + const parked: Write[] = []; + const waiting: (() => void)[] = []; + // a parked write is the test's to release, so it doesn't count as busy + const inflight = new Map(); + // playwright sometimes never reports a request as finished, so an old one stops counting as busy + const STALE_MS = 10_000; + const busy = () => [...inflight].filter(([, began]) => Date.now() - began < STALE_MS); + // these can't lead to a write, so they don't hold the page busy + const passive = new Set(["image", "font", "media", "stylesheet"]); + // only code under /src writes records, so this traffic can linger + const background = [ + "/_internal/indexnow", + "deliberi", + "searchActorsTypeahead", + "/node_modules/" + ]; + where.on("request", (request) => { + const url = request.url(); + if (passive.has(request.resourceType()) || background.some((part) => url.includes(part))) + return; + inflight.set(request, Date.now()); + }); + where.on("requestfinished", (request) => inflight.delete(request)); + where.on("requestfailed", (request) => inflight.delete(request)); + + await where.route("**/xrpc/com.atproto.repo.{createRecord,putRecord,deleteRecord}", (route) => { + inflight.delete(route.request()); + const url = new URL(route.request().url()); + const nsid = url.pathname.split("/").pop()!; + parked.push({ nsid, host: url.host, body: route.request().postDataJSON(), route }); + waiting.splice(0).forEach((wake) => wake()); + }); + + const describe = (w: Write) => `${w.nsid} ${w.body.collection} to ${w.host}`; + + const next = async (collection: string, timeout = 10_000): Promise => { + const deadline = Date.now() + timeout; + for (;;) { + const at = parked.findIndex((w) => w.body.collection === collection); + if (at >= 0) { + const { nsid, host, body, route } = parked.splice(at, 1)[0]; + return { + method: nsid.split(".").pop()!.replace("Record", "") as Held["method"], + host, + body, + release: async () => { + const response = await route.fetch(); + const text = await response.text(); + // the page always gets the real answer, failing it is the test's job + await route.fulfill({ response }); + return { status: response.status(), body: parseBody(text) }; + } + }; + } + const left = deadline - Date.now(); + if (left <= 0) { + const held = parked.map(describe).join(", ") || "nothing"; + throw new Error(`no write to ${collection} was sent, the page sent: ${held}`); + } + await new Promise((wake) => { + waiting.push(wake); + setTimeout(wake, left); + }); + } + }; + + const expectNoOthers = () => { + if (parked.length) + throw new Error(`the page also sent: ${parked.map(describe).join(", ")}`); + }; + + // the dev server compiles modules as pages ask, so a write can come late under parallel workers + const quiet = async (silence = 500, limit = 20_000) => { + const deadline = Date.now() + limit; + let since = Date.now(); + while (Date.now() < deadline && Date.now() - since < silence) { + await sleep(50); + if (busy().length) since = Date.now(); + } + if (Date.now() - since < silence) { + throw new Error( + `the page still had a request running after ${limit / 1000}s, so nothing can be said about what it sends next: ${busy() + .map(([request]) => request.url()) + .join(", ")}` + ); + } + }; + + const sent = (collection: string) => parked.some((w) => w.body.collection === collection); + + return { next, expectNoOthers, quiet, sent }; +}; + +interface Tab { + context: BrowserContext; + windows: Map; + writes: Awaited>; + held: Map; +} + +export class Tabs { + private tabs = new Map(); + + constructor(private browser: Browser) {} + + private of(account: Account) { + const tab = this.tabs.get(account.name); + if (!tab) throw new Error(`${account.name} has no browser yet, start with an .open()`); + return tab; + } + + page(account: Account, window = "") { + const page = this.of(account).windows.get(window); + if (!page) + throw new Error(`${account.name} has no window ${window}, start with a .window()`); + return page; + } + + async openWindow(account: Account, window: string) { + const tab = this.of(account); + if (!tab.windows.has(window)) tab.windows.set(window, await tab.context.newPage()); + } + + private signedIn(account: Account, throughThePage: boolean) { + if (isSeeded(account)) return authFile(account); + if (throughThePage) return undefined; + return test.step("mint a session", () => mintSession(account)); + } + + async open(account: Account, throughThePage: boolean) { + if (this.tabs.has(account.name)) return; + const storageState = await this.signedIn(account, throughThePage); + const context = await this.browser.newContext({ storageState }); + watchBrowser(context); + const page = await context.newPage(); + const writes = await holdWrites(context); + this.tabs.set(account.name, { + context, + windows: new Map([["", page]]), + writes, + held: new Map() + }); + if (!isSeeded(account) && throughThePage) + await test.step("log in through the login page", () => logIn(page, account)); + } + + async hold(account: Account, slot: Slot) { + const tab = this.of(account); + let held = tab.held.get(slot); + if (!held) { + held = await tab.writes.next(slot.nsid); + tab.held.set(slot, held); + } + return held; + } + + async release(account: Account, slot: Slot, held: Held, expected = 200) { + const reply = await held.release(); + this.of(account).held.delete(slot); + if (reply.status !== expected) { + throw new Error( + `${held.method} ${slot.nsid} answered ${reply.status} ${JSON.stringify(reply.body)}, expected ${expected}` + ); + } + return reply.body; + } + + // playwright's own networkidle always waits 500ms, which every open and reload would pay + async settle(account: Account) { + await this.of(account).writes.quiet(SETTLE_MS); + } + + async expectNoWrite(account: Account, slot: Slot) { + const { writes } = this.of(account); + await writes.quiet(); + if (writes.sent(slot.nsid)) throw new Error(`the page sent a write to ${slot.nsid}`); + } + + async expectQuiet() { + const tabs = [...this.tabs.values()]; + for (const tab of tabs) { + const [unreleased] = tab.held.keys(); + if (unreleased) { + throw new Error( + `the page sent a write to ${unreleased.nsid} that no .trace() let through` + ); + } + } + // wait on every tab before judging any, or a late write from one slips past + await Promise.all(tabs.map((tab) => tab.writes.quiet())); + for (const tab of tabs) tab.writes.expectNoOthers(); + } + + async close() { + for (const tab of this.tabs.values()) await tab.context.close().catch(() => {}); + } +} diff --git a/e2e/harness/versions.ts b/e2e/harness/versions.ts new file mode 100644 index 000000000..8cba632c7 --- /dev/null +++ b/e2e/harness/versions.ts @@ -0,0 +1,81 @@ +import { config } from "./config"; +import { get, partsOf, until, xrpc } from "./net"; + +// listCobVersions 400s on anything outside knot_cobs::social_collections +const COBS = new Set([ + "org.tangled.track.ticket", + "org.tangled.feed.comment", + "org.tangled.feed.reaction", + "org.tangled.label.definition", + "org.tangled.review.patch", + "org.tangled.repo.manifest" +]); + +export const isCob = (uri: string) => COBS.has(partsOf(uri).collection); + +export interface Version { + cid?: string; + changeId: string; + editor: string; +} + +// knot2 only serves this as sh.tangled, org.tangled gets a 404 +export const versionsOf = async (uri: string) => { + const { status, body } = await get( + xrpc(config.knotUrl, "sh.tangled.repo.listCobVersions", { uri, limit: "500" }) + ); + return { status, body, versions: (status === 200 ? body.versions : []) as Version[] }; +}; + +const listed = (versions: Version[]) => + versions.map((version) => `${version.cid ?? "erased"} by ${version.editor}`).join(", ") || + "no versions"; + +// no uri before a create, and a chosen rkey like a label slug 404s +export const versionsBefore = async (uri?: string): Promise => + uri && isCob(uri) + ? until( + `the knot should list the versions of ${uri} before the write`, + 10_000, + async () => { + const { status, body, versions } = await versionsOf(uri); + return [ + status === 200 || status === 404, + `it answered ${status} ${JSON.stringify(body)}`, + versions + ]; + } + ) + : []; + +interface Expected { + // left out for records the knot wrote itself, so the check skips it + editor?: string; + // without a read from before the write, only the newest version gets checked + had?: Version[]; +} + +// resolves with the versions so the git check reads the same chain +export const checkVersions = ( + { uri, cid }: { uri: string; cid?: string }, + { editor, had }: Expected +) => { + const count = had ? `${had.length + 1} versions` : "versions"; + const by = editor ? ` by ${editor}` : ""; + return until( + `the knot should list ${count} of ${uri}, the newest ${cid ?? "erased"}${by}`, + 10_000, + async () => { + const { status, body, versions } = await versionsOf(uri); + if (status !== 200) return [false, `it answered ${status} ${JSON.stringify(body)}`]; + const last = versions.at(-1); + const grew = + !had || + (versions.length === had.length + 1 && + had.every((version, at) => versions[at].changeId === version.changeId)); + const newest = last?.cid === cid && (!editor || last?.editor === editor); + const before = had?.length ? `, before the write it listed ${listed(had)}` : ""; + return [grew && newest, `it lists ${listed(versions)}${before}`, versions]; + } + ); +}; diff --git a/e2e/harness/vocabulary.ts b/e2e/harness/vocabulary.ts new file mode 100644 index 000000000..e65961b09 --- /dev/null +++ b/e2e/harness/vocabulary.ts @@ -0,0 +1,248 @@ +import type { Locator, Page } from "@playwright/test"; + +import type { Account } from "./config"; +import type { Did } from "@atcute/lexicons/syntax"; + +import type { RecordNsid } from "./lexicons"; +import { partsOf, stamp, type Written } from "./net"; +import type { Hop } from "./services"; + +// declared once at module level, each run fills its own copy +export interface Slot { + nsid: N; +} +export const record = (nsid: N): Slot => ({ nsid }); + +export const declarationId = record("org.tangled.repo.declaration"); + +export const manifestId = record("org.tangled.repo.manifest"); + +// a push changes this, the page never writes it +export const refsId = record("org.tangled.git.refs"); + +// a pull is a ticket too, its own slot so one flow can hold an issue and a pull at once +// the page also fires subscribe and attest writes on its own, the flow has to let them through +export const ticketId = record("org.tangled.track.ticket"); +export const pullId = record("org.tangled.track.ticket"); +export const patchId = record("org.tangled.review.patch"); + +// a refused write never fills its slot, so the refusal gets its own +export const refusedTicketId = record("org.tangled.track.ticket"); + +export const commentId = record("org.tangled.feed.comment"); +export const subscriptionId = record("org.tangled.feed.subscription"); +export const attestationId = record("org.tangled.graph.attestation"); + +// written to the account's own pds, not the knot +export const profileId = record("org.tangled.actor.profile"); +export const starId = record("org.tangled.feed.star"); +export const followId = record("org.tangled.graph.follow"); +export const vouchId = record("org.tangled.graph.vouch"); +export const publicKeyId = record("sh.tangled.publicKey"); + +export const labelDefinitionId = record("org.tangled.label.definition"); + +export interface Ctx { + actor: Account; + page: Page; + repo: Repo; + upstream: Repo; + pushed: { sha: string; branch: string }; + get: (slot: Slot) => Written; + set: (slot: Slot, written: Written) => void; + adopt: (slot: Slot, written: Written) => void; + adoptRepo: (made: { did: Did; name: string }) => void; +} + +export const rkeyOf = (ctx: Ctx, slot: Slot) => partsOf(ctx.get(slot).uri).rkey; + +export type Lazy = T | ((ctx: Ctx) => T); + +export const resolve = (value: Lazy, ctx: Ctx): T => + typeof value === "function" ? (value as (ctx: Ctx) => T)(ctx) : value; + +export interface Repo { + did: Did; + name: string; + path: string; + defaultBranch: string; + heads: Record; +} + +export const repoAt = ( + owner: Account, + repo: Pick & Partial> +): Repo => ({ heads: {}, ...repo, path: `/${owner.handle}/${repo.name}` }); + +export interface Where { + describe: string; + path: (ctx: Ctx) => string; +} +export interface Target { + describe: string; + locate: (page: Page, ctx: Ctx) => Locator; + absent?: boolean; +} + +export const where = (describe: string, path: Where["path"]): Where => ({ describe, path }); +export const target = (describe: string, locate: Target["locate"]): Target => ({ + describe, + locate +}); +export const button = (name: string) => + target(`button ${name}`, (page) => page.getByRole("button", { name, exact: true })); +export const link = (name: string) => + target(`link ${name}`, (page) => page.getByRole("link", { name })); +export const field = (label: string) => target(`field ${label}`, (page) => page.getByLabel(label)); +export const placeholder = (hint: string) => + target(`field ${hint}`, (page) => page.getByPlaceholder(hint)); +export const text = (words: string) => target(`text ${words}`, (page) => page.getByText(words)); +export const not = (what: Target): Target => ({ + ...what, + describe: `no ${what.describe}`, + absent: true +}); + +// the cast works because the builders only call getBy methods, which a locator has too +export const within = (scope: Target, inner: Target): Target => + target(`${inner.describe} in ${scope.describe}`, (page, ctx) => + inner.locate(scope.locate(page, ctx) as unknown as Page, ctx) + ); + +export const through = (...hops: Hop[]) => hops; + +export const unique = (what: string) => `${what} ${stamp()}`; + +// the knot only takes a put or a delete that names the version it read +export const replacing = (slot: Slot, record?: Lazy>) => (ctx: Ctx) => ({ + swapRecord: ctx.get(slot).cid, + ...(record ? { record: resolve(record, ctx) } : {}) +}); + +export const mergeButton = button("Rebase & merge"); + +export const moreActionsOn = (what: "issue" | "pull request") => + button(`More actions for this ${what}`); +export const moreActions = moreActionsOn("issue"); + +export const menuItem = (name: string) => + target(`menu item ${name}`, (tab) => tab.getByRole("menuitem", { name, exact: true })); + +export const confirmDelete = target("confirm delete", (tab) => + tab.getByRole("dialog").getByRole("button", { name: "Delete", exact: true }) +); + +export const commentMenu = target("the comment's menu", (tab, ctx) => + tab.getByRole("button", { name: `More actions for ${ctx.actor.handle}'s comment`, exact: true }) +); + +// exact, or a substring also finds the commit messages +export const heading = (name: string) => + target(`heading ${name}`, (tab) => tab.getByRole("heading", { name, exact: true })); + +export const exactLink = (name: string | ((ctx: Ctx) => string)) => + target(`the link ${typeof name === "string" ? name : "to the repo"}`, (tab, ctx) => + tab.getByRole("link", { name: typeof name === "string" ? name : name(ctx), exact: true }) + ); + +// one save bar at the top and one at the bottom, so take the first +export const saveButton = target("the Save button", (tab) => + tab.getByRole("button", { name: "Save", exact: true }).first() +); + +export const unsaved = target("the unsaved changes note", (tab) => + tab.getByText("Unsaved changes").first() +); + +// scoped to the modal, the page says Delete repository twice +export const dialogButton = (name: string) => + target(`dialog button ${name}`, (tab) => + tab.getByRole("dialog").getByRole("button", { name, exact: true }) + ); + +export const nameField = target("the repo name field", (tab, ctx) => + tab.getByPlaceholder(ctx.repo.name) +); + +export const openDelete = target("the delete button on the page", (tab) => + tab.getByRole("main").getByRole("button", { name: "Delete repository", exact: true }) +); + +// sveltekit's route announcer is an alert too, so leave it out +export const alerts = target("the page's alerts", (tab) => + tab.locator("[role=alert]:not(#svelte-announcer)") +); + +export const refusal = (words: string | RegExp) => + target(`the refusal saying ${words}`, (tab) => + tab.getByRole("alert").filter({ hasText: words }) + ); + +export const refusedByPolicy = (policy: string) => + target("the refusal in the form", (tab) => + tab.getByRole("alert").filter({ hasText: `where contribution policy is ${policy}` }) + ); + +// issues and pulls say different words in the comment box +export const discussion = placeholder("Add to the discussion. Markdown is supported."); +export const pullDiscussion = placeholder("Write your comment here"); + +export const triage = "triage"; +export const triageToken = { $type: "org.tangled.label.defs#labelToken", key: triage }; +export const editLabels = button("Edit labels"); +export const labelOption = target("the triage option", (tab) => + tab.getByRole("option", { name: triage, exact: true }) +); +// the open picker lists the same word too, so scope to the sidebar +export const triageChip = target("the triage chip", (tab) => + tab.getByRole("list", { name: "Labels" }).getByRole("link", { name: triage }) +); + +// the count link has no accessible name, only the number +export const starCount = target("star count", (tab, ctx) => + tab.locator(`a[href$="${ctx.repo.path}/stars"]`) +); + +export const topic = "e2e-topic"; +export const topicChip = target("the topic chip", (tab) => tab.getByText(topic, { exact: true })); + +// the page says Vouch twice, so scope to this dialog +export const vouchDialog = target("the vouch dialog", (tab) => tab.getByRole("dialog")); +export const inVouchDialog = (name: string) => within(vouchDialog, button(name)); + +export const page = { + repo: where("the repo page", (ctx) => ctx.repo.path), + settings: where("the repo settings", (ctx) => `${ctx.repo.path}/settings`), + profile: where("the actor's profile", (ctx) => `/${ctx.actor.handle}`), + issues: where("the issues list", (ctx) => `${ctx.repo.path}/issues`), + newIssue: where("the new issue form", (ctx) => `${ctx.repo.path}/issues/new`), + pulls: where("the pulls list", (ctx) => `${ctx.repo.path}/pulls`), + newRepo: where("the new repo form", () => "/repo/new"), + newPull: (branches: { from: string; into: string }) => + where( + "the new pull form", + (ctx) => + `${ctx.repo.path}/pulls/new?sourceBranch=${branches.from}&targetBranch=${branches.into}` + ), + pull: (ticket: Slot) => + where("the pull page", (ctx) => `${ctx.repo.path}/pulls/${rkeyOf(ctx, ticket)}`), + issue: (ticket: Slot) => + where("the issue page", (ctx) => `${ctx.repo.path}/issues/${rkeyOf(ctx, ticket)}`) +}; + +export const knots = where("the knots settings", () => "/settings/knots"); +export const newKnot = where("the new knot form", () => "/settings/knots/new"); +export const spindles = where("the spindles settings", () => "/settings/spindles"); +export const newSpindle = where("the new spindle form", () => "/settings/spindles/new"); +export const profileSettings = where("the profile settings", () => "/settings"); +export const keys = where("the keys settings", () => "/settings/keys"); +export const keysNew = where("the new key form", () => "/settings/keys/new"); + +export const settingsAt = (sub: string) => + where(`the ${sub} settings`, (ctx) => `${ctx.repo.path}/settings/${sub}`); +export const newLabel = settingsAt("labels/new"); +export const renameForm = settingsAt("rename"); +export const branchesTab = where("the branches tab", (ctx) => `${ctx.repo.path}/branches`); + +export const loginThen = (path: (ctx: Ctx) => string) => + where("the login page", (ctx) => `/login?return_url=${encodeURIComponent(path(ctx))}`); diff --git a/e2e/harness/web-hash.ts b/e2e/harness/web-hash.ts new file mode 100644 index 000000000..3eb6a70b4 --- /dev/null +++ b/e2e/harness/web-hash.ts @@ -0,0 +1,33 @@ +import { createHash } from "node:crypto"; +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// what the web container builds from: all of web/ except what it or its tools write there +const generated = new Set([ + "node_modules", + ".svelte-kit", + ".wrangler", + ".git", + "build", + "dist", + "coverage", + "storybook-static" +]); + +const web = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../web"); + +const filesUnder = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }) + .filter((entry) => !generated.has(entry.name)) + .flatMap((entry) => { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) return filesUnder(file); + return entry.isFile() ? [file] : []; + }); + +const hash = createHash("sha1"); +for (const file of filesUnder(web).sort()) { + hash.update(path.relative(web, file)).update("\0").update(readFileSync(file)); +} +console.log(hash.digest("hex").slice(0, 16)); diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000..082432f19 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,26 @@ +{ + "name": "e2e", + "version": "0.0.0", + "private": true, + "type": "module", + "packageManager": "pnpm@11.17.0", + "scripts": { + "e2e": "./run", + "e2e:up": "./up", + "e2e:sweep": "./run --sweep", + "test": "playwright test" + }, + "devDependencies": { + "@atcute/atproto": "^4.0.4", + "@atcute/cbor": "^2.3.8", + "@atcute/cid": "^2.5.0", + "@atcute/firehose": "^1.2.0", + "@atcute/lexicons": "^2.1.1", + "@atcute/oauth-crypto": "^1.0.1", + "@playwright/test": "^1.61.1", + "@types/node": "^24", + "prettier": "^3.9.9", + "typescript": "^5", + "undici": "^8.11.2" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 000000000..00382691a --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "@playwright/test"; + +import { browserArgs, config } from "./harness/config"; + +export default defineConfig({ + globalSetup: "./harness/global.ts", + fullyParallel: true, + workers: 12, + timeout: 90_000, + expect: { timeout: 2_000 }, + reporter: [["./harness/reporter.ts", { html: process.env.E2E_REPORT }]], + use: { + baseURL: config.webUrl, + ignoreHTTPSErrors: true, + // screenshots are most of a trace's cost, the dom snapshots show each step + trace: { mode: "retain-on-failure", screenshots: false }, + launchOptions: { args: browserArgs } + }, + projects: [ + { name: "setup", testMatch: "harness/auth.setup.ts" }, + { name: "flows", testDir: "specs", dependencies: ["setup"] }, + ...(process.env.E2E_SWEEP ? [{ name: "sweep", testDir: "tools" }] : []) + ] +}); diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml new file mode 100644 index 000000000..56f233773 --- /dev/null +++ b/e2e/pnpm-lock.yaml @@ -0,0 +1,346 @@ +lockfileVersion: "9.0" + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + devDependencies: + "@atcute/atproto": + specifier: ^4.0.4 + version: 4.0.4(@atcute/lexicons@2.1.1) + "@atcute/cbor": + specifier: ^2.3.8 + version: 2.3.8(@atcute/cid@2.5.0) + "@atcute/cid": + specifier: ^2.5.0 + version: 2.5.0 + "@atcute/firehose": + specifier: ^1.2.0 + version: 1.2.0(@atcute/cid@2.5.0)(@atcute/lexicons@2.1.1) + "@atcute/lexicons": + specifier: ^2.1.1 + version: 2.1.1 + "@atcute/oauth-crypto": + specifier: ^1.0.1 + version: 1.0.1(typescript@5.9.3) + "@playwright/test": + specifier: ^1.61.1 + version: 1.63.0 + "@types/node": + specifier: ^24 + version: 24.19.1 + prettier: + specifier: ^3.9.9 + version: 3.9.9 + typescript: + specifier: ^5 + version: 5.9.3 + undici: + specifier: ^8.11.2 + version: 8.11.2 + +packages: + "@atcute/atproto@4.0.4": + resolution: + { + integrity: sha512-aEp5wQVPVbZcFJUwoi54tyT+ehRI/xtPe9T0zIvcryHAVXF/zxHtNboM+htbZLOAGsDFsqzgCyxph0Bafw7L8w== + } + peerDependencies: + "@atcute/lexicons": ^2.0.0 + + "@atcute/cbor@2.3.8": + resolution: + { + integrity: sha512-gazXBcNTr2U3cRaCjfRbq2GKHJnWapUUA3mgehL+0tbVXEty2wf/LIwF9z46gjqjX2DYGGwkybF8lTI3dzHaNQ== + } + peerDependencies: + "@atcute/cid": ^2.5.0 + + "@atcute/cid@2.5.0": + resolution: + { + integrity: sha512-CAZyzUdaQhuC+S7tGjoC7vgyDI2X4JU+CPO/7WjEU7HlNsAGXjSkD++5TlfnSWGDqUeqpjMZjUfFBZYHKzPHew== + } + + "@atcute/firehose@1.2.0": + resolution: + { + integrity: sha512-u6Oh137haGOzWNFbJzakUjjDOHvVNORY5r5H4v9OfHqVzFOYwSDptMYsD2TlRf4BbdtmHfjCb7pL1/D0eMfh6Q== + } + peerDependencies: + "@atcute/lexicons": ^2.1.0 + + "@atcute/lexicons@2.1.1": + resolution: + { + integrity: sha512-kHyqUW8g/Fq9LTctGh0TVBx0AmtecV6VSrRprrSbqKqHe9q8C6y+YPL3EMNq2l93XA1iDHkkvYwLdTcKL/lNbA== + } + + "@atcute/multibase@1.2.5": + resolution: + { + integrity: sha512-cReTONgYpQo/VHD3ZmzPNoyBKJgSk1J4h//cvvdVVJBMar+SjlQ/sUXeTjQfuyfmDKv+TLKhmutLcvbMcQ9Rvw== + } + + "@atcute/oauth-crypto@1.0.1": + resolution: + { + integrity: sha512-ghC8ceFx0r6pi5X3dNoET1K6PNg8afeAOZLD4PKLhrpVYdRtRrgY4uqAe3ojtSIjoS6GXmVm063CoJ2xtg/MoQ== + } + + "@atcute/uint8array@1.2.0": + resolution: + { + integrity: sha512-KoBGTbV4lS8zXNu91osM3FecrH3NUnxNPsBAkaNtn6uf/p240245qoLn7keFpRM0npX522X8R+XBBb2rvtE7Rg== + } + + "@atcute/util-text@1.3.4": + resolution: + { + integrity: sha512-u2UAM7iSM09sQaSG9jtxWFSPgB8boVj50/BoyMvYnhVgGBu+nXIuAcdDUQCsZA44YZgTPPhN/b86JX+jH7SPzQ== + } + + "@mary-ext/event-iterator@1.1.0": + resolution: + { + integrity: sha512-uL6XFBsfAq+h6wnmioLTDMKzgbuKKQTo/2k512W1w1M5SgVroS6+D4MOD+j1K0loEYUJD9rIKV0Fo5ETZlgafw== + } + + "@mary-ext/simple-event-emitter@1.0.2": + resolution: + { + integrity: sha512-A644JdOf+KfTGyX/EYf8AI1Y18mM7369KrhtOVkdEwRa2QIC0Botv7XWnK6/XU7HuQfvtFpCufmHJC3hi4YMuw== + } + + "@oomfware/eval@0.1.0": + resolution: + { + integrity: sha512-EIkukTd3zDQEHUjcfFRDq79Jgm4OEyUrEUvp/SwgjfoH2POaE5cvaswIonFwdt8fSzJF5xtAdgcgIoKD8sPpjg== + } + + "@playwright/test@1.63.0": + resolution: + { + integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ== + } + engines: { node: ">=20" } + hasBin: true + + "@standard-schema/spec@1.1.0": + resolution: + { + integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + } + + "@types/node@24.19.1": + resolution: + { + integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ== + } + + esm-env@1.2.2: + resolution: + { + integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA== + } + + event-target-polyfill@0.0.4: + resolution: + { + integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ== + } + + nanoid@5.1.16: + resolution: + { + integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ== + } + engines: { node: ^18 || >=20 } + hasBin: true + + partysocket@1.3.0: + resolution: + { + integrity: sha512-1zToNyolZFK/7nuAw/K2bZrNzFqaZyRoCEkS+9vG6WSC5ikrN6qWRe96q6ImU51uptz2r+dAwSkwhJVdQi4LiA== + } + peerDependencies: + react: ">=17" + peerDependenciesMeta: + react: + optional: true + + playwright-core@1.63.0: + resolution: + { + integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg== + } + engines: { node: ">=20" } + hasBin: true + + playwright@1.63.0: + resolution: + { + integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg== + } + engines: { node: ">=20" } + hasBin: true + + prettier@3.9.9: + resolution: + { + integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg== + } + engines: { node: ">=14" } + hasBin: true + + typescript@5.9.3: + resolution: + { + integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + } + engines: { node: ">=14.17" } + hasBin: true + + undici-types@7.24.6: + resolution: + { + integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg== + } + + undici@8.11.2: + resolution: + { + integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ== + } + engines: { node: ">=22.19.0" } + + unicode-segmenter@0.17.3: + resolution: + { + integrity: sha512-hKZwqBjJDmqNrq1+LjDxck1qLzJFcLLJM2Xq92ORRHOuau6GSHeELmn+uyk6zNH1CK9mogrUJGP9WJCJUQXMAw== + } + + valibot@1.5.0: + resolution: + { + integrity: sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ== + } + peerDependencies: + typescript: ">=5" + peerDependenciesMeta: + typescript: + optional: true + + yocto-queue@1.2.2: + resolution: + { + integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ== + } + engines: { node: ">=12.20" } + +snapshots: + "@atcute/atproto@4.0.4(@atcute/lexicons@2.1.1)": + dependencies: + "@atcute/lexicons": 2.1.1 + + "@atcute/cbor@2.3.8(@atcute/cid@2.5.0)": + dependencies: + "@atcute/cid": 2.5.0 + "@atcute/multibase": 1.2.5 + "@atcute/uint8array": 1.2.0 + + "@atcute/cid@2.5.0": + dependencies: + "@atcute/multibase": 1.2.5 + "@atcute/uint8array": 1.2.0 + + "@atcute/firehose@1.2.0(@atcute/cid@2.5.0)(@atcute/lexicons@2.1.1)": + dependencies: + "@atcute/cbor": 2.3.8(@atcute/cid@2.5.0) + "@atcute/lexicons": 2.1.1 + "@atcute/uint8array": 1.2.0 + "@mary-ext/event-iterator": 1.1.0 + "@mary-ext/simple-event-emitter": 1.0.2 + partysocket: 1.3.0 + transitivePeerDependencies: + - "@atcute/cid" + - react + + "@atcute/lexicons@2.1.1": + dependencies: + "@atcute/uint8array": 1.2.0 + "@atcute/util-text": 1.3.4 + "@oomfware/eval": 0.1.0 + "@standard-schema/spec": 1.1.0 + esm-env: 1.2.2 + + "@atcute/multibase@1.2.5": + dependencies: + "@atcute/uint8array": 1.2.0 + + "@atcute/oauth-crypto@1.0.1(typescript@5.9.3)": + dependencies: + "@atcute/multibase": 1.2.5 + "@atcute/uint8array": 1.2.0 + nanoid: 5.1.16 + valibot: 1.5.0(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + "@atcute/uint8array@1.2.0": {} + + "@atcute/util-text@1.3.4": + dependencies: + unicode-segmenter: 0.17.3 + + "@mary-ext/event-iterator@1.1.0": + dependencies: + yocto-queue: 1.2.2 + + "@mary-ext/simple-event-emitter@1.0.2": {} + + "@oomfware/eval@0.1.0": {} + + "@playwright/test@1.63.0": + dependencies: + playwright: 1.63.0 + + "@standard-schema/spec@1.1.0": {} + + "@types/node@24.19.1": + dependencies: + undici-types: 7.24.6 + + esm-env@1.2.2: {} + + event-target-polyfill@0.0.4: {} + + nanoid@5.1.16: {} + + partysocket@1.3.0: + dependencies: + event-target-polyfill: 0.0.4 + + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + + prettier@3.9.9: {} + + typescript@5.9.3: {} + + undici-types@7.24.6: {} + + undici@8.11.2: {} + + unicode-segmenter@0.17.3: {} + + valibot@1.5.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + yocto-queue@1.2.2: {} diff --git a/e2e/run b/e2e/run new file mode 100755 index 000000000..1b46eb439 --- /dev/null +++ b/e2e/run @@ -0,0 +1,62 @@ +#!/bin/sh +# runs the suite. asks to start the stack if it isn't up, and rebuilds the web when web/ changed +# pnpm e2e the whole suite +# pnpm e2e specs/star.spec.ts one spec +# pnpm e2e --report=report.html also write an html report +# pnpm e2e --har=run.har also write the requests as a har +# pnpm e2e --login=ui every new account logs in through the login page +# pnpm e2e --web-modules=DIR the web node_modules the report loads, default web/node_modules +# pnpm e2e --sweep clean up what a crashed run left +# every other argument goes to playwright +set -eu +here="$(dirname "$0")" + +sweep= +n=$# +while [ "$n" -gt 0 ]; do + arg="$1" + shift + n=$((n - 1)) + case $arg in + --report=*) export E2E_REPORT="${arg#*=}" ;; + --har=*) export E2E_REPORT_HAR="${arg#*=}" ;; + --login=*) export E2E_LOGIN="${arg#*=}" ;; + --web-modules=*) export E2E_WEB_MODULES="${arg#*=}" ;; + --sweep) sweep=1 ;; + *) set -- "$@" "$arg" ;; + esac +done + +status=0 +"$here/up" --check || status=$? +case $status in +0) ;; +2) + # the sweep only removes accounts, it doesn't look at the web + if [ -z "$sweep" ]; then + echo "web/ changed since the web was built, rebuilding it, this takes a few minutes" >&2 + "$here/up" + fi + ;; +*) + if [ ! -t 0 ]; then + echo "the stack isn't up, start it with pnpm e2e:up" >&2 + exit 1 + fi + printf "the e2e stack isn't up, start it? the web build takes a few minutes [y/N] " >&2 + read -r answer + case $answer in + [yY]*) "$here/up" ;; + *) exit 1 ;; + esac + ;; +esac + +cd "$here" +[ -d node_modules ] || pnpm install +pnpm exec playwright install chromium +if [ -n "$sweep" ]; then + export E2E_SWEEP=1 + set -- --project=sweep "$@" +fi +exec pnpm exec playwright test "$@" diff --git a/e2e/specs/access.spec.ts b/e2e/specs/access.spec.ts new file mode 100644 index 000000000..6de456557 --- /dev/null +++ b/e2e/specs/access.spec.ts @@ -0,0 +1,388 @@ +import { expect } from "@playwright/test"; + +import { asKnot, editCob, getOk, knotRecord, knotReply } from "../harness/api"; +import { config, seeded } from "../harness/config"; +import { + bobbin, + button, + type Ctx, + type Flow, + field, + flow, + git, + hydrant, + knot, + knotCopy, + link, + mergeButton, + manifestId, + not, + openDelete, + page, + patchId, + pds, + placeholder, + pullId, + refsId, + refusedByPolicy, + refusedTicketId, + rkeyOf, + subscriptionId, + target, + text, + through, + ticketId, + where +} from "../harness/flow"; +import { lsRemote, push } from "../harness/git"; +import { selfUri, until, xrpc } from "../harness/net"; +import { + base, + branches, + merges, + onBranches, + recall, + remember, + targetIsAt +} from "../harness/shared"; + +const ADD = "sh.tangled.repo.addCollaborator"; +const REMOVE = "sh.tangled.repo.removeCollaborator"; + +const access = where("the access settings", (ctx) => `${ctx.repo.path}/settings/access`); +const addCollaboratorForm = where( + "the add collaborator form", + (ctx) => `${ctx.repo.path}/settings/access/new` +); + +const collaboration = (nsid: string, subject: (ctx: Ctx) => string) => (ctx: Ctx) => + asKnot(ctx.actor, nsid, { repo: ctx.repo.did, subject: subject(ctx) }); + +const collaborate = (nsid: string, label: string) => async (ctx: Ctx) => { + const reply = await collaboration(nsid, (c) => recall(c, label).did)(ctx); + expect(reply.status, `${nsid} answered ${JSON.stringify(reply.body)}`).toBe(200); +}; + +const withHelper = (flow: Flow) => + flow + .actor("helper") + .step("remember the helper", remember("helper")) + .actor("owner") + .step("the owner adds the helper", collaborate(ADD, "helper")); + +const subjectsOf = async (base: string, repoDid: string): Promise => { + const { items } = await getOk( + xrpc(base, "sh.tangled.repo.listCollaborators", { subject: repoDid }) + ); + return items.map((item: { subject: string }) => item.subject); +}; + +// the pages read bobbin, so a collaborator must show in bobbin's list too +const listing = ( + service: string, + url: string, + label: string, + wanted: boolean +): [string, (ctx: Ctx) => Promise] => [ + `${service} ${wanted ? "lists" : "no longer lists"} ${label}`, + async (ctx) => { + const who = recall(ctx, label); + await until( + `${service} should ${wanted ? "list" : "drop"} ${label} on ${ctx.repo.did}`, + 20_000, + async () => { + const now = await subjectsOf(url, ctx.repo.did); + return [now.includes(who.did) === wanted, `it lists ${JSON.stringify(now)}`]; + } + ); + } +]; +const knotListing = (label: string, wanted: boolean) => + listing("the knot", config.knotUrl, label, wanted); +const bobbinListing = (label: string, wanted: boolean) => + listing("bobbin", config.bobbinUrl, label, wanted); + +const helperHandle = target("the helper's handle", (tab, ctx) => + tab.getByText(recall(ctx, "helper").handle, { exact: true }) +); +const suggestion = target("the helper in the suggestions", (tab, ctx) => + tab.getByRole("option").filter({ hasText: recall(ctx, "helper").handle }) +); + +const ownerOnly = "Only the repository owner can change this"; +const noPushAccess = "You need push access to this repository to change its settings"; + +// only the knot's "aren't authorized" stderr counts, any other push failure does not +const pushRefused = (label: string, branch: string) => async (ctx: Ctx) => { + const who = recall(ctx, label); + await expect( + push(who, ctx.repo.did, { [`e2e/${label}.txt`]: `${label}\n` }, { branch, base: "main" }) + ).rejects.toThrow(/you aren't authorized to push to this repository/); +}; + +const branchIsAt = + (branch: string, label = "owner") => + async (ctx: Ctx) => { + const heads = await lsRemote(recall(ctx, label), ctx.repo.did); + expect(heads[`refs/heads/${branch}`]).toBe(ctx.repo.heads[branch]); + }; + +flow("the owner adds a collaborator from the access page") + .actor("owner") + .repo() + .actor("helper") + .step("remember the helper", remember("helper")) + .actor("owner") + + .open(access) + .click(link("Add collaborator")) + .at(addCollaboratorForm) + .fill(placeholder("user.tngl.sh"), (ctx) => recall(ctx, "helper").handle) + .click(suggestion) + .click(button("Add")) + .at(access) + .step(...knotListing("helper", true)) + .step(...bobbinListing("helper", true)) + .reload() + .sees(helperHandle) + .sees(button("Remove collaborator")) + .sees(not(button("Withdraw invite"))); + +flow("the owner removes a collaborator from the access page") + .actor("owner") + .repo() + .use(withHelper) + .step(...knotListing("helper", true)) + + .open(access) + .sees(helperHandle) + .click(button("Remove collaborator")) + .step(...knotListing("helper", false)) + .step(...bobbinListing("helper", false)) + .reload() + .sees(not(helperHandle)) + .sees(not(button("Remove collaborator"))); + +flow("the access pages don't promise an accept step the knot never asks for") + .actor("owner") + .repo() + .open(addCollaboratorForm) + .sees(text("Add collaborator")) + .sees(not(text("once they accept the invite"))) + .open(access) + .sees(text("Collaborators")) + .sees(not(text("until they accept the offer"))); + +flow("a collaborator pushes a branch to the owner's repo") + .actor("owner") + .repo() + .push(base) + .step("remember the owner", remember("owner")) + .use(withHelper) + + .actor("helper") + .push({ "e2e/helper.txt": "from the helper\n" }, { branch: "from-helper", base: "main" }) + .follow(refsId, through(git, knotCopy, hydrant, bobbin)) + .step("the owner sees the helper's branch over git", branchIsAt("from-helper")); + +flow("a stranger's push is refused and the branch stays where it was") + .actor("owner") + .repo() + .push(base) + .step("remember the owner", remember("owner")) + .actor("nobody") + .step("remember the stranger", remember("nobody")) + .step("the stranger's push is refused", pushRefused("nobody", "main")) + .step("the stranger's new branch is refused too", pushRefused("nobody", "mine")) + .step("the owner's branch didn't move", branchIsAt("main")) + .step("and there is no new branch", async (ctx) => { + const heads = await lsRemote(recall(ctx, "owner"), ctx.repo.did, "refs/heads/"); + expect(Object.keys(heads)).toEqual(["refs/heads/main"]); + }) + .follow(refsId, through(git, knotCopy)); + +flow("a collaborator the owner removes can't push any more") + .actor("owner") + .repo() + .push(base) + .step("remember the owner", remember("owner")) + .use(withHelper) + .actor("helper") + .push({ "e2e/first.txt": "while allowed\n" }, { branch: "while-allowed", base: "main" }) + .follow(refsId, through(git, knotCopy, hydrant, bobbin)) + + .actor("owner") + .step("the owner removes the helper", collaborate(REMOVE, "helper")) + .step(...knotListing("helper", false)) + .actor("helper") + .step("the helper's push is refused", pushRefused("helper", "after-removal")) + .step("the owner's repo has no such branch", async (ctx) => { + const heads = await lsRemote(recall(ctx, "owner"), ctx.repo.did, "refs/heads/"); + expect(Object.keys(heads)).not.toContain("refs/heads/after-removal"); + }); + +const issueTitle = "a collaborator's issue"; + +flow("under the collaborators policy a collaborator opens an issue and a stranger is refused") + .actor("owner") + .repo() + .policy("collaborators") + .use(withHelper) + + .actor("helper") + .open(page.newIssue) + .fill(field("Title"), issueTitle) + .click(button("Create issue")) + .sends( + ticketId, + knot, + (ctx) => ({ + repo: ctx.repo.did, + record: { state: "open", title: { text: issueTitle } } + }), + "create" + ) + .trace(ticketId, through(knot, hydrant, bobbin)) + .trace(subscriptionId, through(pds)) + .at(page.issues) + .sees(link(issueTitle)) + + .actor(seeded.charlie) + .open(page.newIssue) + .fill(field("Title"), "let me in") + .click(button("Create issue")) + .refused(refusedTicketId, 403) + .sees(refusedByPolicy("collaborators")) + .open(page.issues) + .sees(link(issueTitle)) + .sees(not(link("let me in"))); + +onBranches("a collaborator closes and reopens the owner's pull request") + .pull(pullId, patchId, branches) + .step("remember the owner", remember("owner")) + .use(withHelper) + .actor("helper") + .open(page.pull(pullId)) + .sees(button("Close")) + .changeState(pullId, "closed", button("Close"), button("Reopen")) + .changeState(pullId, "open", button("Reopen"), button("Close")) + .reload() + .sees(button("Close")); + +onBranches("a collaborator merges the owner's pull request") + .pull(pullId, patchId, branches) + .step("remember the owner", remember("owner")) + .use(withHelper) + .actor("helper") + .open(page.pull(pullId)) + .sees(mergeButton) + .use(merges) + .knotStored(pullId, "completed") + .step( + "the target branch moved to the feature's commit", + targetIsAt(branches.from, (ctx) => recall(ctx, "owner")) + ); + +const strangersPull = "a pull a stranger may only read"; +const closeThroughApi = async (ctx: Ctx) => { + return editCob(ctx.actor, ctx.get(pullId).uri, "close", (stored) => ({ + ...stored, + state: "closed" + })); +}; + +onBranches("a stranger sees no close, merge or label controls on a pull and the knot refuses them") + .pull(pullId, patchId, { ...branches, title: strangersPull }) + .open(page.pull(pullId)) + .sees(button("Close")) + .sees(mergeButton) + .sees(button("Edit labels")) + + .actor(seeded.charlie) + .open(page.pull(pullId)) + .sees(text(strangersPull)) + .sees(not(button("Close"))) + .sees(not(mergeButton)) + .sees(not(button("Edit labels"))) + .rejects( + "charlie merging the pull", + (ctx) => + knotReply(ctx.actor, ctx.repo.did, "sh.tangled.repo.pull.merge", { + rkey: rkeyOf(ctx, patchId) + }), + 403 + ) + .rejects("charlie closing the pull", closeThroughApi, 403) + .knotStored(pullId, "open"); + +flow("only the owner is offered the delete button in the repo settings") + .actor("owner") + .repo() + .use(withHelper) + .open(page.settings) + .sees(openDelete) + + .actor("helper") + .open(page.settings) + .sees(text("Default branch")) + .sees(not(openDelete)) + + .actor(seeded.alice) + .open(page.settings) + .sees(text(noPushAccess)) + .sees(not(openDelete)); + +const aliceDeletes = async (ctx: Ctx) => { + const reply = await asKnot(ctx.actor, "sh.tangled.repo.delete", { repo: ctx.repo.did }); + expect(reply.status, `the delete answered ${JSON.stringify(reply.body)}`).toBe(200); +}; + +flow("a knot admin deletes a repo she does not own through the api") + .actor("owner") + .repo() + .push(base) + .actor(seeded.alice) + .step("alice deletes it through the api", aliceDeletes) + .step("the knot has dropped the repo", async (ctx) => { + await until(`the knot should have dropped ${ctx.repo.did}`, 10_000, async () => [ + (await knotRecord(selfUri(ctx.repo.did, manifestId.nsid))) === undefined, + "it still has the manifest" + ]); + }); + +flow("a repo a knot admin deleted is gone from bobbin too") + .actor("owner") + .repo() + .step("remember the owner", remember("owner")) + .actor(seeded.alice) + .step("alice deletes it through the api", aliceDeletes) + .step("bobbin no longer knows the repo", async (ctx) => { + await until(`bobbin should have dropped ${ctx.repo.name}`, 20_000, async () => { + const { status } = await bobbin.repoByName(recall(ctx, "owner").did, ctx.repo.name); + return [status !== 200, `getRepoByName answered ${status}`]; + }); + }); + +flow("a collaborator can't add or remove collaborators") + .actor("owner") + .repo() + .use(withHelper) + + .actor("helper") + .rejects( + "the helper adding charlie", + collaboration(ADD, () => seeded.charlie.did), + 403 + ) + .rejects( + "the helper removing themself", + collaboration(REMOVE, (ctx) => ctx.actor.did), + 403 + ) + .step("the knot still lists only the helper", async (ctx) => { + expect(await subjectsOf(config.knotUrl, ctx.repo.did)).toEqual([recall(ctx, "helper").did]); + }) + .open(access) + .sees(text(ownerOnly)) + .open(addCollaboratorForm) + .sees(text(ownerOnly)); diff --git a/e2e/specs/account.spec.ts b/e2e/specs/account.spec.ts new file mode 100644 index 000000000..3eb2d3453 --- /dev/null +++ b/e2e/specs/account.spec.ts @@ -0,0 +1,346 @@ +import { expect, type Request, test } from "@playwright/test"; + +import { config, handleOf } from "../harness/config"; +import { + bobbin, + button, + confirmDelete, + type Ctx, + type Flow, + field, + flow, + git, + heading, + hydrant, + keys, + keysNew, + knotCopy, + link, + pds, + profileSettings, + publicKeyId, + refsId, + rkeyOf, + target, + text, + through, + where +} from "../harness/flow"; +import { keyOf, lsRemoteWith, makeKey } from "../harness/git"; +import { get, stamp, until, xrpc } from "../harness/net"; +import { notificationsOf, putSaved, type Toggles } from "../harness/services"; + +const files = { "README.md": "pushed with a key from settings\n" }; + +const addsLaptopKey = (flow: Flow) => + flow + .open(keysNew) + .fill(field("Title"), "laptop") + .fill(field("Key"), (ctx) => keyOf(ctx).pub) + .click(button("Add")) + .sends( + publicKeyId, + pds, + (ctx) => ({ record: { name: "laptop", key: keyOf(ctx).pub } }), + "create" + ) + // hydrant only follows org.tangled.*, so bobbin never sees this key record + .trace(publicKeyId, through(pds)); + +const knotTakesTheKey = (ctx: Ctx) => + until("the knot should take the key", 30_000, async () => { + const saw = await lsRemoteWith(ctx); + return [saw === "listed", saw]; + }); + +flow("a key added in settings is the key the knot takes for a push") + .actor("pusher") + .repo() + .step("the account makes a key the knot has never heard of", makeKey) + .step("the knot refuses that key", async (ctx) => { + expect(await lsRemoteWith(ctx)).not.toBe("listed"); + }) + + .use(addsLaptopKey) + .step("the knot lists the repo for that key", knotTakesTheKey) + .push(files) + .follow(refsId, through(git, knotCopy, hydrant, bobbin)); + +flow("a key added in settings is listed with its fingerprint") + .actor("lister") + .step("the account makes a key", makeKey) + + .use(addsLaptopKey) + .at(keys) + .sees(text("laptop")) + // the page computes the fingerprint, ssh-keygen is the reference + .sees(target("the key's fingerprint", (tab, ctx) => tab.getByText(keyOf(ctx).fingerprint))); + +const notAKey = "hello, this is not a key"; + +flow("a title and some words that are not an ssh key are refused, not saved") + .actor("typist") + .open(keysNew) + .fill(field("Title"), "junk") + .fill(field("Key"), notAKey) + .click(button("Add")) + .sendsNothing(publicKeyId) + + .open(keys) + .sees(text("No keys added yet")); + +test("a visitor opening the verify email page without a token is told so", async ({ browser }) => { + const context = await browser.newContext(); + const tab = await context.newPage(); + await tab.goto("/verify/email", { waitUntil: "networkidle" }); + await expect(tab.getByRole("heading", { name: "Verification failed" })).toBeVisible(); + await expect(tab.getByText("This verification link is missing its token.")).toBeVisible(); + await expect(tab.getByRole("link", { name: "Go to email settings" })).toBeVisible(); + await context.close(); +}); + +test("a visitor opening the verify email page with a token nobody issued is told it is invalid", async ({ + browser +}) => { + const context = await browser.newContext(); + const tab = await context.newPage(); + await tab.goto(`/verify/email?token=not-a-real-token-${stamp()}`, { waitUntil: "networkidle" }); + await expect(tab.getByRole("heading", { name: "Verification failed" })).toBeVisible(); + await expect( + tab.getByText("The verification link is invalid or has already been used.") + ).toBeVisible(); + await context.close(); +}); + +const notifications = where("the notification settings", () => "/settings/notifications"); + +const toggle = (label: string) => + target(`the ${label} switch`, (tab) => tab.getByRole("switch", { name: label, exact: true })); + +const startedWith = new WeakMap(); +const noteNotifications = async (ctx: Ctx) => { + startedWith.set(ctx, await notificationsOf(ctx.actor)); +}; + +// deliberi takes the save, not the pds, so the record holder never sees it +const saves = new WeakMap>(); +const watchTheSave = async (ctx: Ctx) => { + const sent = ctx.page.waitForRequest( + (sent) => sent.url().includes("notification.updatePreferences") && sent.method() === "POST" + ); + sent.catch(() => {}); + saves.set(ctx, sent); +}; + +const flipped = { emailNotifications: true, followed: false }; + +flow( + "notification switches saved in settings are what deliberi keeps and the page shows after a reload" +) + .actor("reader") + .step("deliberi's starting preferences", noteNotifications) + + .open(notifications) + .click(toggle("Email notifications")) + .click(toggle("New followers")) + .step("watch for the save", watchTheSave) + .click(button("Save")) + .step("the page sent only the two switches that changed", async (ctx) => { + const sent = await saves.get(ctx)!; + expect(sent.postDataJSON()).toEqual(flipped); + }) + .step("deliberi keeps the two and leaves the rest", async (ctx) => { + await until(`deliberi should keep ${JSON.stringify(flipped)}`, 10_000, async () => { + const has = await notificationsOf(ctx.actor); + const want = { ...startedWith.get(ctx)!, ...flipped }; + return [ + JSON.stringify(has, Object.keys(want).sort()) === + JSON.stringify(want, Object.keys(want).sort()), + `it keeps ${JSON.stringify(has)}` + ]; + }); + }) + .reload() + .step("the page shows the saved switches", async (ctx) => { + const email = toggle("Email notifications").locate(ctx.page, ctx); + const followed = toggle("New followers").locate(ctx.page, ctx); + await expect(email).toBeChecked(); + await expect(followed).not.toBeChecked(); + await expect(button("Save").locate(ctx.page, ctx)).toBeDisabled(); + }); + +const dangers = [ + { + open: "Change password", + title: "Change password", + label: "Current password", + submit: "Send reset code", + path: "password" + }, + { + open: "Deactivate account", + title: "Deactivate account", + label: "Password", + submit: "Deactivate", + path: "deactivate" + }, + { + open: "Delete account", + title: "Delete account", + label: "Password", + submit: "Send deletion code", + path: "delete" + } +]; + +const settingsPage = (name: string) => where(`the ${name} form`, () => `/settings/profile/${name}`); + +for (const danger of dangers) { + flow( + `the ${danger.open.toLowerCase()} form stays off until a password is typed and cancel goes back` + ) + .actor("owner") + + .open(profileSettings) + .click(link(danger.open)) + .at(settingsPage(danger.path)) + .sees(heading(danger.title)) + .step("the button that acts is off", async (ctx) => { + await expect(button(danger.submit).locate(ctx.page, ctx)).toBeDisabled(); + }) + .fill(field(danger.label), "not the password") + .step("the button that acts is on, and is left alone", async (ctx) => { + await expect(button(danger.submit).locate(ctx.page, ctx)).toBeEnabled(); + }) + .click(link("Cancel")) + .at(profileSettings) + .sees(heading("Profile")); +} + +const knotRefusesTheKey = (ctx: Ctx) => + until("the knot should refuse the removed key", 60_000, async () => { + const saw = await lsRemoteWith(ctx); + return [saw !== "listed", "the knot still lists the repo for it"]; + }); + +flow("removing a key in settings makes the knot refuse a push with it") + .actor("pusher") + .repo() + .step("the account makes a key", makeKey) + .use(addsLaptopKey) + .step("the knot takes the key", knotTakesTheKey) + + .at(keys) + .sees(text("laptop")) + .click(button("Delete")) + .click(confirmDelete) + .sends(publicKeyId, pds, (ctx) => ({ rkey: rkeyOf(ctx, publicKeyId) }), "delete") + .trace(publicKeyId, through(pds)) + .sees(text("No keys added yet")) + .reload() + .sees(text("No keys added yet")) + .step("the knot refuses the removed key", knotRefusesTheKey); + +// the non-GET requests, which the record holder never sees +const sentByPage = new WeakMap(); +const watchRequests = async (ctx: Ctx) => { + const seen: Request[] = []; + ctx.page.on("request", (sent) => { + if (!["GET", "OPTIONS", "HEAD"].includes(sent.method())) seen.push(sent); + }); + sentByPage.set(ctx, seen); +}; +const pageSends = (what: string, url: RegExp) => (ctx: Ctx) => + until(`the page should send ${what}`, 10_000, async () => { + const seen = sentByPage.get(ctx)!; + return [ + seen.some((sent) => url.test(sent.url())), + `it sent ${seen.map((sent) => `${sent.method()} ${new URL(sent.url()).pathname}`).join(", ") || "nothing"}` + ]; + }); + +flow("changing the password sends a reset code") + .actor("pwuser") + .open(settingsPage("password")) + .step("watch what the page sends", watchRequests) + .fill(field("Current password"), config.password) + .click(button("Send reset code")) + .step( + "the page asks for a reset code", + pageSends("a password reset request", /requestPasswordReset/) + ); + +const signedInAs = (ctx: Ctx) => + ctx.page.evaluate(() => localStorage.getItem("tangled.currentDid")); + +const repoStatus = async (did: string) => + (await get(xrpc(config.pdsUrl, "com.atproto.sync.getRepoStatus", { did }))).body; + +flow("deactivating an account deactivates it on the pds and signs the browser out") + .actor("leaver", { login: "ui" }) + .open(settingsPage("deactivate")) + .fill(field("Password"), config.password) + .click(button("Deactivate")) + .step("the pds says the account is not active", (ctx) => + until(`the pds should deactivate ${ctx.actor.handle}`, 10_000, async () => { + const status = await repoStatus(ctx.actor.did); + return [status?.active === false, `it says ${JSON.stringify(status)}`]; + }) + ) + .step("the browser has forgotten the account", (ctx) => + until("the browser should be signed out", 10_000, async () => { + const did = await signedInAs(ctx); + return [did === null, `it is signed in as ${did}`]; + }) + ); + +flow("deleting an account asks the pds to mail a deletion code and asks for it") + .actor("goner") + .open(settingsPage("delete")) + .step("watch what the page sends", watchRequests) + .fill(field("Password"), config.password) + .click(button("Send deletion code")) + .step( + "the page asks the pds for a deletion code", + pageSends("a deletion code request", /requestAccountDelete/) + ) + .sees(field("Deletion code")); + +const newHandles = new WeakMap(); +const pickHandle = async (ctx: Ctx) => { + newHandles.set(ctx, handleOf(`rn-${stamp()}`)); +}; +const newHandle = (ctx: Ctx) => newHandles.get(ctx)!; + +flow("changing the handle gives the account the new handle") + .actor("renmr") + .step("pick a handle", pickHandle) + .open(settingsPage("handle")) + .click( + target("the own domain switch", (tab) => + tab.getByRole("switch", { name: "I have my own domain" }) + ) + ) + .fill(field("Handle"), newHandle) + .click(button("Save")) + .step("the pds knows the account by the new handle", (ctx) => + until(`the pds should call ${ctx.actor.did} ${newHandle(ctx)}`, 15_000, async () => { + const { body } = await get( + xrpc(config.pdsUrl, "com.atproto.repo.describeRepo", { repo: ctx.actor.did }) + ); + return [body?.handle === newHandle(ctx), `it says ${body?.handle}`]; + }) + ) + .open(where("the profile at the new handle", (ctx) => `/${newHandle(ctx)}`)) + .sees( + target("the new handle", (tab, ctx) => + tab.getByText(newHandle(ctx), { exact: true }).first() + ) + ); + +flow("a new account's first preferences save is taken without a retry") + .actor("fresh") + .step("saves a knot at once", async (ctx) => { + const reply = await putSaved(ctx.actor, { knots: ["a.knot.test"], spindles: [] }); + expect(reply.status, `pocket answered ${JSON.stringify(reply.body)}`).toBe(200); + }); diff --git a/e2e/specs/chores.spec.ts b/e2e/specs/chores.spec.ts new file mode 100644 index 000000000..2724c5c44 --- /dev/null +++ b/e2e/specs/chores.spec.ts @@ -0,0 +1,79 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; + +import { accounts, alreadyGone, getOk } from "../harness/api"; +import { type Chore, holdsUp, LEDGER, owe, replay, settled } from "../harness/chores"; +import { config } from "../harness/config"; +import { xrpc } from "../harness/net"; + +const exists = async (did: string) => { + try { + await getOk(xrpc(config.pdsUrl, "com.atproto.repo.describeRepo", { repo: did })); + return true; + } catch { + return false; + } +}; + +// above the pid limit, so no live process can own it +const DEAD = 2 ** 22 + 1; +const asDead = (id: string) => { + const path = resolve(LEDGER, `${id}.json`); + const entry = JSON.parse(readFileSync(path, "utf8")); + writeFileSync(path, JSON.stringify({ ...entry, pid: DEAD })); +}; + +test("a dead run's chores are replayed and a live one's are left alone", async () => { + test.setTimeout(60_000); + const left = await accounts.create("dead"); + const live = await accounts.create("live"); + try { + asDead(owe("account of a run that was killed", { kind: "account", account: left })); + owe("account of this run", { kind: "account", account: live }); + + expect(await replay("dead")).toEqual([]); + expect(await exists(left.did)).toBe(false); + expect(await exists(live.did)).toBe(true); + + expect(await replay("mine")).toEqual([]); + expect(await exists(live.did)).toBe(false); + owe("the same account again", { kind: "account", account: live }); + expect(await replay("mine")).toEqual([]); + } finally { + for (const account of [left, live]) await accounts.remove(account).catch(() => {}); + } +}); + +test("the newest chore is done first, even inside one millisecond", () => { + const ids = Array.from({ length: 100 }, (_, i) => + owe(`chore ${i}`, { kind: "member", account: { name: "x", handle: "x", did: `did:${i}` } }) + ); + try { + expect([...ids].sort().reverse()).toEqual([...ids].reverse()); + } finally { + ids.forEach(settled); + } +}); + +test("an account stays while one of its chores has failed", () => { + const alice = { name: "a", handle: "a", did: "did:a" }; + const bob = { name: "b", handle: "b", did: "did:b" }; + const failed = new Set([alice.did]); + const record: Chore = { kind: "record", by: alice, uri: "at://x/y/z" }; + expect(holdsUp({ kind: "account", account: alice }, failed)).toBe(true); + expect(holdsUp({ kind: "member", account: alice }, failed)).toBe(true); + expect(holdsUp({ kind: "account", account: bob }, failed)).toBe(false); + expect(holdsUp(record, failed)).toBe(false); +}); + +test("only a service saying it is gone counts as gone", () => { + expect(alreadyGone(new Error('knot getRecord answered 400 {"error":"RepoNotFound"}'))).toBe( + true + ); + expect(alreadyGone(new Error("the knot has no at://x/y/z to erase"))).toBe(true); + expect(alreadyGone(new Error("pds refused a's login: 401 AuthenticationRequired"))).toBe(true); + expect(alreadyGone(new Error("pds refused a's login: 429 RateLimitExceeded"))).toBe(false); + expect(alreadyGone(new Error("GET https://pds: ECONNREFUSED"))).toBe(false); +}); diff --git a/e2e/specs/comments.spec.ts b/e2e/specs/comments.spec.ts new file mode 100644 index 000000000..31b6447c5 --- /dev/null +++ b/e2e/specs/comments.spec.ts @@ -0,0 +1,386 @@ +import { expect } from "@playwright/test"; + +import { comments } from "../harness/api"; +import { + attestationId, + bobbin, + button, + commentId, + commentMenu, + discussion, + field, + flow, + hydrant, + knot, + menuItem, + not, + page, + pds, + placeholder, + record, + replacing, + subscriptionId, + target, + text, + through, + ticketId +} from "../harness/flow"; +import { retry429 } from "../harness/net"; +import { + commented, + deletesComment, + made, + note, + onCommentedIssue, + stillStored +} from "../harness/shared"; + +const busy = 101; +const saying = (at: number) => `comment ${String(at).padStart(3, "0")}`; +const exactly = (words: string) => + target(`the comment saying ${words}`, (tab) => tab.getByText(words, { exact: true })); + +flow("more than a hundred comments still show the newest one") + .actor("owner") + .repo() + .ticket(ticketId, { title: "a busy thread" }) + .step(`${busy} comments`, async (ctx) => { + const write = async (at: number) => { + // a burst this big outlasts the usual back off, so it waits longer and tries more often + const reply = await retry429( + () => comments.add(ctx.actor, ctx.repo.did, ctx.get(ticketId), saying(at)), + 10, + 1_000 + ); + const written = made(`comment ${at}`, reply); + ctx.adopt(commentId, written); + return written; + }; + for (let from = 1; from < busy; from += 4) { + const burst = Array.from({ length: Math.min(4, busy - from) }, (_, at) => from + at); + await Promise.all(burst.map(write)); + } + await bobbin.indexed(await write(busy)); + }) + + .open(page.issue(ticketId)) + .sees(exactly(saying(1))) + .sees(exactly(saying(busy))); + +const reply = record("org.tangled.feed.comment"); +const reaction = record("org.tangled.feed.reaction"); + +const replyBox = placeholder("Leave a reply..."); + +const threadTitle = "needs a thread"; +const parentText = "the parent says this"; +const replyText = "and the reply says that"; +const editedParent = "the parent, but better"; + +const issuePicker = target("the issue's react button", (tab) => + tab.getByRole("button", { name: "React", exact: true }).first() +); +const commentPicker = target("the comment's react button", (tab) => + tab.getByRole("button", { name: "React", exact: true }).last() +); +const thumbs = button("React with 👍"); +const chip = target("the 👍 chip", (tab) => tab.getByRole("button", { name: /^👍/ })); +const chipSaying = (count: number) => + target(`the 👍 chip saying ${count}`, (tab) => + tab.getByRole("button", { name: new RegExp(`^👍 ${count}$`) }) + ); + +// replies sit in the ml-2xl column under their parent, top level comments never do +const nested = (words: string) => + target(`${words} indented under its parent`, (tab) => tab.locator(".ml-2xl").getByText(words)); + +const withOneComment = (name: string) => + onCommentedIssue(name, { + title: threadTitle, + words: parentText, + // a reply looks the subscription up in bobbin, and writes a second one if it's missing + subscription: through(pds, hydrant, bobbin) + }); + +withOneComment("replying under a comment nests it and survives a reload") + .sees(not(nested(parentText))) + .fill(replyBox, replyText) + .click(button("Reply")) + .sends( + reply, + knot, + (ctx) => ({ + record: { + subject: { uri: ctx.get(ticketId).uri }, + replyTo: { uri: ctx.get(commentId).uri, cid: ctx.get(commentId).cid }, + body: { text: replyText } + } + }), + "create" + ) + .trace(reply, through(knot, hydrant, bobbin)) + .sees(nested(replyText)) + .reload() + .sees(text(parentText)) + .sees(nested(replyText)) + .sees(not(nested(parentText))) + .step("the knot's copy of the reply names the parent", async (ctx) => { + const stored = await knot.has(ctx.get(reply)); + if (stored.replyTo?.uri !== ctx.get(commentId).uri) + throw new Error(`the reply says it answers ${stored.replyTo?.uri}`); + }); + +flow("reacting to an issue and taking the reaction back") + .actor("owner") + .repo() + .ticket(ticketId, { title: "react to me" }) + + .open(page.issue(ticketId)) + .click(issuePicker) + .click(thumbs) + .sends( + reaction, + knot, + (ctx) => ({ + record: { reaction: "👍", subject: { uri: ctx.get(ticketId).uri } } + }), + "create" + ) + .sees(not(chip)) + .trace(reaction, through(knot, hydrant, bobbin)) + .trace(attestationId, through(pds)) + .sees(chipSaying(1)) + .reload() + .sees(chipSaying(1)) + + .click(chip) + .sends(reaction, knot, replacing(reaction), "delete") + .sees(chipSaying(1)) + .trace(reaction, through(knot, hydrant, bobbin)) + .sees(not(chip)) + .reload() + .sees(not(chip)); + +flow("a reaction from each of two people adds up and one taking theirs back leaves the other") + .actor("owner") + .repo() + .ticket(ticketId, { title: "react to me twice" }) + + .open(page.issue(ticketId)) + .click(issuePicker) + .click(thumbs) + .trace(reaction, through(knot, hydrant, bobbin)) + .trace(attestationId, through(pds)) + .sees(chipSaying(1)) + + .actor("fan") + .open(page.issue(ticketId)) + .sees(chipSaying(1)) + .click(chip) + .sends(reaction, knot, { record: { reaction: "👍" } }, "create") + .trace(reaction, through(knot, hydrant, bobbin)) + .trace(attestationId, through(pds)) + .sees(chipSaying(2)) + .reload() + .sees(chipSaying(2)) + .click(chip) + .sends(reaction, knot, replacing(reaction), "delete") + .trace(reaction, through(knot, hydrant, bobbin)) + .sees(chipSaying(1)) + .reload() + .sees(chipSaying(1)) + + .actor("owner") + .open(page.issue(ticketId)) + .sees(chipSaying(1)); + +withOneComment("reacting to a comment and taking the reaction back") + .click(commentPicker) + .click(thumbs) + .sends( + reaction, + knot, + (ctx) => ({ + record: { + reaction: "👍", + subject: { uri: ctx.get(commentId).uri, cid: ctx.get(commentId).cid } + } + }), + "create" + ) + .trace(reaction, through(knot, hydrant, bobbin)) + .trace(attestationId, through(pds)) + .sees(chipSaying(1)) + .reload() + .sees(chipSaying(1)) + + .click(chip) + .sends(reaction, knot, replacing(reaction), "delete") + .trace(reaction, through(knot, hydrant, bobbin)) + .sees(not(chip)) + .reload() + .sees(not(chip)) + .sees(text(parentText)); + +withOneComment("deleting a comment that has replies keeps the replies under a tombstone") + .fill(replyBox, replyText) + .click(button("Reply")) + .trace(reply, through(knot, hydrant, bobbin)) + .sees(nested(replyText)) + + .use(deletesComment) + .sees(text("This comment was deleted.")) + .sees(not(text(parentText))) + .sees(text(replyText)) + .reload() + .sees(text("This comment was deleted.")) + .sees(not(text(parentText))) + .sees(nested(replyText)) + .step("the reply is still on the knot", async (ctx) => { + await knot.has(ctx.get(reply)); + }); + +// scoped to the edited card, so a chip elsewhere can't stand in for it +const chipOnEdited = target("the 👍 chip on the edited comment", (tab) => + tab + .locator("div.group") + .filter({ hasText: editedParent }) + .getByRole("button", { name: /^👍 1$/ }) +); + +withOneComment("editing a comment keeps its reactions on screen") + .click(commentPicker) + .click(thumbs) + .trace(reaction, through(knot, hydrant, bobbin)) + .trace(attestationId, through(pds)) + .sees(chipSaying(1)) + + .click(commentMenu) + .click(menuItem("Edit comment")) + .fill(placeholder("Edit your comment. Markdown is supported."), editedParent) + .click(button("Save")) + .sends( + commentId, + knot, + replacing(commentId, (ctx) => ({ + subject: { uri: ctx.get(ticketId).uri }, + body: { text: editedParent } + })), + "put" + ) + .trace(commentId, through(knot, hydrant, bobbin)) + .sees(text(editedParent)) + // before the reload, which would hide it + .sees(chipOnEdited) + .reload() + .sees(text(editedParent)) + .sees(chipOnEdited); + +const title = "the build is broken"; + +const edited = "same here, again"; + +flow("commenting on an issue you opened") + .actor("owner") + .repo() + + .open(page.newIssue) + .fill(field("Title"), title) + .click(button("Create issue")) + .trace(ticketId, through(knot, hydrant, bobbin)) + // opening subscribed the author, so the comment must not subscribe again + .trace(subscriptionId, through(pds, hydrant, bobbin)) + + .open(page.issue(ticketId)) + .sees(button("Unsubscribe")) + .fill(discussion, note) + .click(button("Comment")) + .trace(commentId, through(knot, hydrant, bobbin)) + .sees(text(note)) + .reload() + .sees(text(note)) + .sees(button("Unsubscribe")); + +flow("commenting on an issue") + .actor("owner") + .repo() + .ticket(ticketId, { title: commented }) + + .open(page.issue(ticketId)) + .fill(discussion, note) + .click(button("Comment")) + // the knot only takes a comment that names the version of the ticket it saw + .sends( + commentId, + knot, + (ctx) => ({ + record: { subject: { $type: "com.atproto.repo.strongRef", ...ctx.get(ticketId) } } + }), + "create" + ) + .sees(text(commented)) + .sees(not(text(note))) + .trace(commentId, through(knot, hydrant, bobbin)) + .sees(text(note)) + .trace(subscriptionId, through(pds)) + .reload() + .sees(text(note)); + +onCommentedIssue("editing a comment") + .click(commentMenu) + .click(menuItem("Edit comment")) + .fill(placeholder("Edit your comment. Markdown is supported."), edited) + .click(button("Save")) + .sends( + commentId, + knot, + // the knot wants an edit to resend the subject too + replacing(commentId, (ctx) => ({ + subject: { uri: ctx.get(ticketId).uri }, + body: { text: edited } + })), + "put" + ) + .trace(commentId, through(knot, hydrant, bobbin)) + .sees(text(edited)) + .reload() + .sees(text(edited)); + +onCommentedIssue("deleting a comment") + .use(deletesComment) + .sees(text(commented)) + .sees(not(text(note))) + .reload() + .sees(text(commented)) + .sees(not(text(note))); + +const draft = "typed and never sent"; + +const editBox = placeholder("Edit your comment. Markdown is supported."); +const cancel = button("Cancel"); + +onCommentedIssue("cancelling a comment edit leaves the comment as it was") + .click(commentMenu) + .click(menuItem("Edit comment")) + .fill(editBox, edited) + .click(cancel) + .sendsNothing(commentId) + .sees(text(note)) + .sees(not(text(edited))) + .reload() + .sees(text(note)) + .sees(not(text(edited))) + .step( + "the knot and hydrant still have the first version", + stillStored(commentId, (value) => value.body.text, note) + ); + +onCommentedIssue("a comment typed and never sent is not written") + .fill(discussion, draft) + .open(page.issues) + .sendsNothing(commentId) + .open(page.issue(ticketId)) + .sees(not(text(draft))) + .step("the box starts empty", async (ctx) => { + await expect(discussion.locate(ctx.page, ctx)).toHaveValue(""); + }); diff --git a/e2e/specs/faults.spec.ts b/e2e/specs/faults.spec.ts new file mode 100644 index 000000000..ed7d7b8e1 --- /dev/null +++ b/e2e/specs/faults.spec.ts @@ -0,0 +1,375 @@ +import { expect } from "@playwright/test"; + +import { getOk } from "../harness/api"; +import { config } from "../harness/config"; +import { + bobbin, + button, + commentId, + type Ctx, + discussion, + field, + flow, + hydrant, + knot, + link, + menuItem, + moreActions, + not, + page, + pds, + refusedTicketId, + replacing, + starCount, + starId, + subscriptionId, + target, + text, + through, + ticketId +} from "../harness/flow"; +import { sleep, until, type Written, xrpc } from "../harness/net"; +import { answerWith, commented, jsonError, note } from "../harness/shared"; + +const alertSaying = (words: string) => + target(`the alert saying ${words}`, (tab) => tab.getByRole("alert").filter({ hasText: words })); +const statusSaying = (words: string) => + target(`the status saying ${words}`, (tab) => + tab.getByRole("status").filter({ hasText: words }) + ); + +const knotRecords = async (ctx: Ctx, nsid: string) => { + const listed = await getOk( + xrpc(config.knotUrl, "com.atproto.repo.listRecords", { + repo: ctx.repo.did, + collection: nsid, + limit: "100" + }) + ); + return listed.records as { uri: string; cid: string; value: any }[]; +}; + +const bobbinWait = "**/xrpc/sh.tangled.bobbin.awaitRecord*"; +const bobbinFails = answerWith(bobbinWait, 504, "UpstreamTimeout", "bobbin took too long"); +const bobbinRecovers = (ctx: Ctx) => ctx.page.unroute(bobbinWait); +const probeFails = answerWith( + "**/xrpc/org.tangled.knot.describeKnot*", + 503, + "ServiceUnavailable", + "the e2e flow cut this read" +); + +const notCaughtUp = "Saved, but the appview has not caught up yet"; + +// a swapCommit the pds no longer has, so it refuses the star write +const staleCommit = "bafyreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvygrxj2r4gy"; +const racedByAnotherWrite = (ctx: Ctx) => + ctx.page.route("**/xrpc/com.atproto.repo.createRecord", async (route) => { + const body = route.request().postDataJSON(); + if (body?.collection !== starId.nsid) return route.fallback(); + await route.fallback({ postData: JSON.stringify({ ...body, swapCommit: staleCommit }) }); + }); + +const failedFace = target("the star button's error face", (tab) => + tab.locator('[data-face="err"]') +); + +flow("a star the pds refuses rolls back and the button says so") + .actor("owner") + .repo() + .actor("fan") + + .open(page.repo) + .step("the pds will refuse the next star", racedByAnotherWrite) + .click(button("Star")) + .sends( + starId, + pds, + (ctx) => ({ + record: { subject: { $type: "org.tangled.feed.star#repo", identity: ctx.repo.did } } + }), + "create" + ) + .sees(button("Starred")) + .sees(starCount, "1") + .refused(starId, 400) + .sees(button("Star")) + .sees(starCount, "0") + .sees(failedFace) + .step("bobbin counts no stars", async (ctx) => + expect(await bobbin.countStars(ctx.repo.did)).toBe(0) + ) + .reload() + .sees(button("Star")) + .sees(starCount, "0"); + +// a real burst is hard to hit on purpose, so the 429 is faked for one comment +const rateLimited = "too many writes, slow down"; +const limitsTheNextComment = async (ctx: Ctx) => { + let limited = false; + await ctx.page.route("**/xrpc/com.atproto.repo.createRecord", async (route) => { + if (limited || route.request().postDataJSON()?.collection !== commentId.nsid) + return route.fallback(); + limited = true; + await route.fulfill(jsonError(429, "RateLimitExceeded", rateLimited)); + }); +}; + +flow("a comment the knot rate limits keeps its text and can be sent again") + .actor("owner") + .repo() + .ticket(ticketId, { title: commented }) + + .open(page.issue(ticketId)) + .step("the knot will rate limit the next comment", limitsTheNextComment) + .fill(discussion, note) + .click(button("Comment")) + .sees(alertSaying(rateLimited)) + .step("the box still holds the text", async (ctx) => + expect(discussion.locate(ctx.page, ctx)).toHaveValue(note) + ) + .sendsNothing(commentId) + + .click(button("Comment")) + .sends( + commentId, + knot, + (ctx) => ({ + record: { subject: { $type: "com.atproto.repo.strongRef", ...ctx.get(ticketId) } } + }), + "create" + ) + .trace(commentId, through(knot, hydrant, bobbin)) + .trace(subscriptionId, through(pds)) + .sees(text(note)) + .sees(not(alertSaying(rateLimited))) + .reload() + .sees(text(note)); + +flow("two submits of the comment form in one task send one write") + .actor("owner") + .repo() + .ticket(ticketId, { title: commented }) + + .open(page.issue(ticketId)) + .fill(discussion, note) + .step("the form is submitted twice before the page can react", (ctx) => + discussion.locate(ctx.page, ctx).evaluate((box) => { + const form = (box as HTMLTextAreaElement).form!; + form.requestSubmit(); + form.requestSubmit(); + }) + ) + .sends(commentId, knot, { record: { body: { text: note } } }, "create") + .trace(commentId, through(knot, hydrant, bobbin)) + .trace(subscriptionId, through(pds)) + .sendsNothing(commentId) + .sees(text(note)); + +// the knot saves the comment and answers late, so the page reloads mid-send +const slowAnswer = async (ctx: Ctx) => { + await ctx.page.route("**/xrpc/com.atproto.repo.createRecord", async (route) => { + if (route.request().postDataJSON()?.collection !== commentId.nsid) return route.fallback(); + const response = await route.fetch(); + await sleep(4_000); + // the page this answer was for is gone by then + await route.fulfill({ response }).catch(() => undefined); + }); +}; + +const sentComment = new WeakMap(); + +flow("reloading while a comment is still sending leaves it posted once") + .actor("owner") + .repo() + .ticket(ticketId, { title: commented }) + + .open(page.issue(ticketId)) + .step("the knot is slow to answer", slowAnswer) + .fill(discussion, note) + .click(button("Comment")) + .step("the knot has the comment and the page is still waiting", async (ctx) => { + const [stored] = await until("the knot should have the comment", 10_000, async () => { + const records = await knotRecords(ctx, commentId.nsid); + return [records.length === 1, `it lists ${records.length}`, records]; + }); + sentComment.set(ctx, stored); + await expect(ctx.page.getByRole("button", { name: "Comment", exact: true })).toBeDisabled(); + }) + .step("the page is reloaded", async (ctx) => { + await ctx.page.reload({ waitUntil: "networkidle" }); + }) + .step("every service has the comment", async (ctx) => { + const written = sentComment.get(ctx)!; + await Promise.all([ + knot.present(written, ctx.actor), + hydrant.present(written), + bobbin.indexed(written) + ]); + }) + .reload() + .sees(text(note)) + .step("there is one comment", async (ctx) => { + await expect(ctx.page.getByText(note, { exact: true })).toHaveCount(1); + expect(await knotRecords(ctx, commentId.nsid)).toHaveLength(1); + }); + +// 60 family emoji are about 1500 bytes, over the title's 1024 byte max +const familyOf = (graphemes: number) => "👨‍👩‍👧‍👦".repeat(graphemes); +const tooLong = familyOf(60); +const trimmed = familyOf(40); +const titleField = field("Title"); + +flow("a title over the byte limit is refused with a reason and a shorter one saves") + .actor("owner") + .repo() + + .open(page.newIssue) + .fill(titleField, tooLong) + .click(button("Create issue")) + .sends(refusedTicketId, knot, { record: { title: { text: tooLong } } }, "create") + .refused(refusedTicketId, 400) + .sees(target("an alert", (tab) => tab.getByRole("alert"))) + .step("the form still holds the title", async (ctx) => + expect(ctx.page.getByLabel("Title")).toHaveValue(tooLong) + ) + + .fill(titleField, trimmed) + .click(button("Create issue")) + .sends(ticketId, knot, { record: { title: { text: trimmed } } }, "create") + .trace(ticketId, through(knot, hydrant, bobbin)) + .trace(subscriptionId, through(pds)) + .at(page.issues) + .sees(link(trimmed)); + +const slowTitle = "saved while bobbin was slow"; + +flow("creating an issue the knot saved while bobbin was slow does not make a second one") + .actor("owner") + .repo() + + .open(page.newIssue) + .step("bobbin fails the page's wait", bobbinFails) + .fill(titleField, slowTitle) + .click(button("Create issue")) + .sends(ticketId, knot, { record: { title: { text: slowTitle } } }, "create") + .trace(ticketId, through(knot, hydrant, bobbin)) + .sees(alertSaying(notCaughtUp)) + .step("bobbin answers the page again", bobbinRecovers) + + .click(button("Create issue")) + .sendsNothing(ticketId) + .step("the knot has one issue with that title", async (ctx) => { + const stored = await knotRecords(ctx, ticketId.nsid); + expect(stored.filter((record) => record.value.title.text === slowTitle)).toHaveLength(1); + }); + +flow("closing an issue the knot took while bobbin was slow shows it closed") + .actor("owner") + .repo() + .ticket(ticketId, { title: "close this one" }) + + .open(page.issue(ticketId)) + .step("bobbin fails the page's wait", bobbinFails) + .click(button("Close issue")) + .sends(ticketId, knot, replacing(ticketId, { state: "closed" }), "put") + .trace(ticketId, through(knot, hydrant, bobbin)) + .knotStored(ticketId, "closed") + .sees(statusSaying(notCaughtUp)) + .sees(button("Reopen issue")) + .step("bobbin answers the page again", bobbinRecovers) + .reload() + .sees(button("Reopen issue")); + +// guards openTicket and openComment against probing, since a failed probe falls back to the pds +const probedTitle = "written while the probe fails"; + +flow("an issue opened while the knot's capability probe fails still lands on the knot") + .actor("owner") + .repo() + + .open(page.newIssue) + .step("the knot's capability probe fails", probeFails) + .fill(titleField, probedTitle) + .click(button("Create issue")) + .sends( + ticketId, + knot, + (ctx) => ({ + repo: ctx.repo.did, + record: { $type: ticketId.nsid, title: { text: probedTitle } } + }), + "create" + ) + .trace(ticketId, through(knot, hydrant, bobbin)) + .trace(subscriptionId, through(pds)) + .at(page.issues) + .sees(link(probedTitle)); + +flow("a comment sent while the knot's capability probe fails still lands on the knot") + .actor("owner") + .repo() + .ticket(ticketId, { title: commented }) + + .open(page.issue(ticketId)) + .step("the knot's capability probe fails", probeFails) + .fill(discussion, note) + .click(button("Comment")) + .sends( + commentId, + knot, + (ctx) => ({ + repo: ctx.repo.did, + record: { $type: commentId.nsid, body: { text: note } } + }), + "create" + ) + .trace(commentId, through(knot, hydrant, bobbin)) + .trace(subscriptionId, through(pds)) + .sees(text(note)); + +const firstTitle = "a title before anyone edits it"; +const secondTitle = "retitled from the second window"; +const staleTitle = "retitled from the stale window"; +const loaded = new WeakMap(); + +flow("an edit from a page that loaded before another edit is refused") + .actor("owner") + .repo() + .ticket(ticketId, { title: firstTitle }) + + .open(page.issue(ticketId)) + .step("remember the version this page loaded", async (ctx) => { + loaded.set(ctx, ctx.get(ticketId)); + }) + + .window("second") + .open(page.issue(ticketId)) + .click(moreActions) + .click(menuItem("Edit")) + .fill(titleField, secondTitle) + .click(button("Save")) + .sends(ticketId, knot, replacing(ticketId, { title: { text: secondTitle } }), "put") + .trace(ticketId, through(knot, hydrant, bobbin)) + .sees(text(secondTitle)) + + .window() + .click(moreActions) + .click(menuItem("Edit")) + .fill(titleField, staleTitle) + .click(button("Save")) + .sends( + refusedTicketId, + knot, + (ctx) => ({ + swapRecord: loaded.get(ctx)!.cid, + record: { title: { text: staleTitle } } + }), + "put" + ) + .refused(refusedTicketId, 409) + .sees(target("an alert", (tab) => tab.getByRole("alert"))) + .step("the knot still has the second title", async (ctx) => { + const stored = await knot.has(ctx.get(ticketId)); + expect(stored.title.text).toBe(secondTitle); + }); diff --git a/e2e/specs/git.spec.ts b/e2e/specs/git.spec.ts new file mode 100644 index 000000000..f58f77e4b --- /dev/null +++ b/e2e/specs/git.spec.ts @@ -0,0 +1,367 @@ +import { expect } from "@playwright/test"; +import { execFile } from "node:child_process"; +import { readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { gunzipSync } from "node:zlib"; +import { fetch as undiciFetch } from "undici"; + +import { decodeRefs, pdsCall, sessionOf } from "../harness/api"; +import { type Account, config } from "../harness/config"; +import { + bobbin, + branchesTab, + button, + confirmDelete, + type Ctx, + exactLink, + flow, + git, + hydrant, + knot, + knotCopy, + link, + not, + page, + pds, + record, + refsId, + target, + text, + through, + where +} from "../harness/flow"; +import { lsRemote, sshAs } from "../harness/git"; +import { dispatcher, until } from "../harness/net"; +import { base, deletesBranch, learnRefs, mirrorLists, pushesTag } from "../harness/shared"; +import { type Artifact } from "../harness/lexicons"; + +const files = { "README.md": "hello from e2e\n", "src/main.txt": "x\n" }; + +flow("pushing a commit to a new repo") + .actor("owner") + .repo() + .push(files) + .follow(refsId, through(git, knotCopy, hydrant, bobbin)) + .step("the record names the pushed commit and the default branch", async (ctx) => { + const value = await knot.has(ctx.get(refsId)); + expect(decodeRefs(value)).toContainEqual([ctx.pushed.sha, "refs/heads/main"]); + expect(value.defaultBranch).toBe("main"); + }); + +flow("a pushed repo shows its files") + .actor("owner") + .repo() + .push(files) + .mirrored() + .open(page.repo) + .sees(link("README.md")) + .sees(link("src")); + +const exec = promisify(execFile); + +// encoded as one segment, so a slash in a branch name survives +const treeAt = (ref: string, path = "") => + where(`the tree of ${ref}${path && ` at ${path}`}`, (ctx) => + [`${ctx.repo.path}/tree/${encodeURIComponent(ref)}`, path].filter(Boolean).join("/") + ); +const blobOf = (ref: string, path: string) => + where( + `the blob ${path} on ${ref}`, + (ctx) => `${ctx.repo.path}/blob/${encodeURIComponent(ref)}/${path}` + ); +const tagsTab = where("the tags tab", (ctx) => `${ctx.repo.path}/tags`); + +flow("browsing a branch with a slash in its name down to one file") + .actor("owner") + .repo() + .push(base) + .push({ "docs/guide/intro.txt": "browse me\n" }, { branch: "feature/browse", base: "main" }) + .mirrored() + + .open(treeAt("feature/browse")) + .sees(exactLink("docs")) + .click(exactLink("docs")) + .at(treeAt("feature/browse", "docs")) + .click(exactLink("guide")) + .at(treeAt("feature/browse", "docs/guide")) + .click(exactLink("intro.txt")) + .at(blobOf("feature/browse", "docs/guide/intro.txt")) + .sees(text("browse me")) + .step("the knot has the branch at the commit the page read", async (ctx) => { + const heads = await lsRemote(ctx.actor, ctx.repo.did, "refs/heads/"); + expect(heads["refs/heads/feature/browse"]).toBe(ctx.repo.heads["feature/browse"]); + }); + +const readme = "# readme from a branch\n\nsee [the code](src/main.txt) for more\n"; + +flow("a readme on a branch renders and its relative link goes to the file") + .actor("owner") + .repo() + .push(base) + .push( + { "README.md": readme, "src/main.txt": "linked file\n" }, + { branch: "feature/readme", base: "main" } + ) + .mirrored() + + .open(treeAt("feature/readme")) + .sees( + target("the readme's heading", (tab) => + tab.getByRole("heading", { name: "readme from a branch", level: 1 }) + ) + ) + .click(exactLink("the code")) + .sees(text("linked file")); + +const onlyMain = async (ctx: Ctx) => + expect(Object.keys(await lsRemote(ctx.actor, ctx.repo.did, "refs/heads/"))).toEqual([ + "refs/heads/main" + ]); + +flow("deleting a branch over git takes it off the branches tab") + .actor("owner") + .repo() + .push(base) + .push({ "e2e/doomed.txt": "gone soon\n" }, { branch: "doomed", base: "main" }) + .mirrored() + + .open(branchesTab) + .sees(exactLink("doomed")) + .sees(exactLink("main")) + .step("delete the branch over git", deletesBranch("doomed")) + .step("the knot's record drops it", learnRefs("refs/heads/doomed", false)) + .follow(refsId, through(git, knotCopy, hydrant, bobbin)) + .step("git lists no doomed branch", onlyMain) + .step("bobbin has caught up", mirrorLists("Branches", "doomed", false)) + .reload() + .sees(not(exactLink("doomed"))) + .sees(exactLink("main")); + +flow("an annotated tag shows under tags with its message") + .actor("owner") + .repo() + .push(base) + .step("push the tag over git", pushesTag("v1.0", "the first release")) + .step("the knot's record names it", learnRefs("refs/tags/v1.0", true)) + .follow(refsId, through(knotCopy, hydrant, bobbin)) + .step("git lists the tag", async (ctx) => { + const tags = await lsRemote(ctx.actor, ctx.repo.did, "refs/tags/"); + expect(Object.keys(tags)).toContain("refs/tags/v1.0"); + }) + .step("bobbin has caught up", mirrorLists("Tags", "v1.0", true)) + + .open(tagsTab) + .sees(exactLink("v1.0")) + .sees(text("the first release")) + .click(exactLink("v1.0")) + .at(where("the tag's page", (ctx) => `${ctx.repo.path}/tags/v1.0`)) + .sees(text("the first release")); + +const cloneUrl = (label: "SSH" | "HTTPS") => + target(`the ${label} clone url`, (tab) => + tab + .getByText(label, { exact: true }) + .locator("xpath=following-sibling::div[1]//span[contains(@class,'select-all')]") + ); +const codeMenu = button("Clone this repository"); + +flow("the ssh clone url in the code menu is one git accepts") + .actor("owner") + .repo() + .push(base) + .mirrored() + .open(page.repo) + .click(codeMenu) + .sees(cloneUrl("SSH")) + .step("git lists the repo's branches at the url the page shows", async (ctx) => { + const url = await cloneUrl("SSH").locate(ctx.page, ctx).innerText(); + // the page shows the host with no port, so the port is set by hand + const [host, port] = config.knotSsh.split(":"); + const listed = await until(`git should accept ${url}`, 60_000, async () => { + try { + const { stdout } = await exec("git", ["ls-remote", "--heads", url], { + env: { + ...process.env, + GIT_SSH_COMMAND: `${await sshAs(ctx.actor)} -p ${port} -o HostName=${host}` + }, + timeout: 30_000 + }); + return [true, stdout, stdout]; + } catch (cause: any) { + return [false, String(cause.stderr ?? cause).trim()]; + } + }); + expect(listed).toContain(`${ctx.repo.heads["main"]}\trefs/heads/main`); + }); + +const tarFiles = (tgz: Buffer) => { + const tar = gunzipSync(tgz); + const found: Record = {}; + for (let at = 0; at + 512 <= tar.length && tar[at] !== 0;) { + const cstr = (from: number, to: number) => tar.toString("utf8", from, to).split("\0")[0]; + const size = parseInt(cstr(at + 124, at + 136).trim() || "0", 8); + const kind = String.fromCharCode(tar[at + 156]); + const name = [cstr(at + 345, at + 500), cstr(at, at + 100)].filter(Boolean).join("/"); + if (kind === "0" || kind === "\0") + found[name] = tar.toString("utf8", at + 512, at + 512 + size); + at += 512 + Math.ceil(size / 512) * 512; + } + return found; +}; + +const zipNames = (zip: Buffer) => { + const names: string[] = []; + for (let at = zip.indexOf("PK\x03\x04"); at !== -1; at = zip.indexOf("PK\x03\x04", at + 4)) { + const length = zip.readUInt16LE(at + 26); + names.push(zip.toString("utf8", at + 30, at + 30 + length)); + } + return names; +}; + +const downloadOf = async (ctx: Ctx, name: string) => { + const [download] = await Promise.all([ + ctx.page.waitForEvent("download"), + ctx.page.getByRole("link", { name, exact: true }).click() + ]); + const saved = join(tmpdir(), `e2e-archive-${Date.now()}-${download.suggestedFilename()}`); + await download.saveAs(saved); + try { + return await readFile(saved); + } finally { + await rm(saved, { force: true }); + } +}; + +const archiveFiles = { "e2e/base.txt": "in the archive\n", "docs/readme.txt": "second file\n" }; + +flow("the tar.gz in the code menu downloads the default branch") + .actor("owner") + .repo() + .push(archiveFiles) + .mirrored() + .open(page.repo) + .click(codeMenu) + .step("the download unpacks to the pushed files under the repo's name", async (ctx) => { + const files = tarFiles(await downloadOf(ctx, "tar.gz")); + for (const [path, contents] of Object.entries(archiveFiles)) + expect(files[`${ctx.repo.name}-main/${path}`]).toBe(contents); + }); + +flow("the zip in the code menu downloads the default branch") + .actor("owner") + .repo() + .push(archiveFiles) + .mirrored() + .open(page.repo) + .click(codeMenu) + .step("the download lists the pushed files under the repo's name", async (ctx) => { + const names = zipNames(await downloadOf(ctx, ".zip")); + for (const path of Object.keys(archiveFiles)) + expect(names).toContain(`${ctx.repo.name}-main/${path}`); + }); + +const artifact = record("sh.tangled.repo.artifact"); +const tagPage = where("the tag's page", (ctx) => `${ctx.repo.path}/tags/v1.0`); +const releaseName = "release.txt"; + +const releaseFile = target("the release file input", (tab) => tab.getByLabel("Upload artifact")); +const releaseLink = exactLink(releaseName); + +flow("uploading a release file to a tag lists it on the tag page") + .actor("owner") + .repo() + .push(base) + .step("push an annotated tag", pushesTag("v1.0", "the first release")) + .step("bobbin lists the tag", mirrorLists("Tags", "v1.0", true)) + + .open(tagPage) + .sees(releaseFile) + .step("choose the file", async (ctx) => { + await releaseFile.locate(ctx.page, ctx).setInputFiles({ + name: releaseName, + mimeType: "text/plain", + buffer: Buffer.from("the release\n") + }); + }) + .sends(artifact, pds, () => ({ record: { $type: artifact.nsid, name: releaseName } }), "create") + .trace(artifact, through(pds, hydrant, bobbin)) + .sees(releaseLink) + .reload() + .sees(releaseLink); + +// no helper for a binary upload here, so this dials the pds directly +const uploadBlob = async (who: Account, bytes: Buffer, mimeType: string) => { + const sent = await undiciFetch(`${config.pdsUrl}/xrpc/com.atproto.repo.uploadBlob`, { + dispatcher, + method: "POST", + headers: { authorization: `Bearer ${await sessionOf(who)}`, "content-type": mimeType }, + body: bytes + }); + const body: any = await sent.json(); + expect(sent.status, `the pds refused the blob: ${JSON.stringify(body)}`).toBe(200); + return body.blob; +}; + +// a sha256 repo's tags can't give the 20 byte hash the lexicon wants, so this is made up +const makeRelease = async (ctx: Ctx) => { + const blob = await uploadBlob(ctx.actor, Buffer.from("the release\n"), "text/plain"); + const made = await pdsCall(ctx.actor, "com.atproto.repo.createRecord", { + collection: artifact.nsid, + record: { + $type: artifact.nsid, + name: releaseName, + repoDid: ctx.repo.did, + tag: { $bytes: Buffer.alloc(20, 7).toString("base64") }, + createdAt: new Date().toISOString(), + artifact: blob + } satisfies Artifact + }); + ctx.adopt(artifact, { uri: made.uri, cid: made.cid }); +}; +const deleteRelease = target("the delete release button", (tab) => + tab.getByRole("button", { name: `Delete ${releaseName}`, exact: true }) +); + +flow("deleting a release file takes it off the tag page and off every service") + .actor("owner") + .repo() + .push(base) + .step("push an annotated tag", pushesTag("v1.0", "the first release")) + .step("bobbin lists the tag", mirrorLists("Tags", "v1.0", true)) + .step("upload a release file", makeRelease) + + .open(tagPage) + .sees(releaseLink) + .sees(deleteRelease) + .click(deleteRelease) + .click(confirmDelete) + .sends(artifact, pds, () => ({ collection: artifact.nsid }), "delete") + .trace(artifact, through(pds, hydrant, bobbin)) + .sees(not(releaseLink)) + .reload() + .sees(not(releaseLink)); + +const deleteBranchButton = target("the delete branch button", (tab) => + tab.getByRole("button", { name: "Delete branch doomed", exact: true }) +); + +flow("deleting a branch from the branches tab removes it from git") + .actor("owner") + .repo() + .push(base) + .push({ "e2e/doomed.txt": "gone soon\n" }, { branch: "doomed", base: "main" }) + .mirrored() + + .open(branchesTab) + .sees(exactLink("doomed")) + .sees(deleteBranchButton) + .click(deleteBranchButton) + .click(confirmDelete) + .step("the knot's record drops it", learnRefs("refs/heads/doomed", false)) + .follow(refsId, through(git, knotCopy, hydrant, bobbin)) + .step("git lists only main", onlyMain) + .reload() + .sees(not(exactLink("doomed"))) + .sees(exactLink("main")); diff --git a/e2e/specs/issues.spec.ts b/e2e/specs/issues.spec.ts new file mode 100644 index 000000000..e393263d3 --- /dev/null +++ b/e2e/specs/issues.spec.ts @@ -0,0 +1,339 @@ +import { expect } from "@playwright/test"; + +import { + bobbin, + button, + commentId, + type Ctx, + field, + flow, + hydrant, + knot, + link, + menuItem, + moreActions, + not, + page, + patchId, + pds, + placeholder, + pullId, + record, + replacing, + subscriptionId, + target, + text, + through, + ticketId, + unique +} from "../harness/flow"; +import { + branches, + deletesComment, + deletesIssue, + issue, + note, + onBranches, + stillStored +} from "../harness/shared"; + +const closedTab = target("the closed tab", (tab) => tab.getByRole("link", { name: "Closed" })); + +const hostile = [ + "before the html", + ' ', + 'click me bold', + "after the html" +].join(" "); + +// scope to the body, the page's data scripts hold the raw text too +const bodyParagraph = (tab: Ctx["page"]) => tab.locator("p", { hasText: "after the html" }); +const unsafeInBody = target("something unsafe in the body", (tab) => + bodyParagraph(tab).locator("script, [onerror], [onmouseover], a[href^='javascript']") +); + +flow("an issue body with raw html renders inert") + .actor("owner") + .repo() + .step( + "an issue whose body is raw html", + issue(ticketId, { title: "html in the body", body: hostile }) + ) + + .open(page.issue(ticketId)) + .sees(target("the body", bodyParagraph)) + .sees(not(unsafeInBody)) + .sees(target("the bold word", (tab) => bodyParagraph(tab).locator("b", { hasText: "bold" }))) + .step("no handler ran", async (ctx) => + expect(await ctx.page.evaluate("window.__e2e")).toBeUndefined() + ); + +const mention = target("the mention link", (tab, ctx) => + tab.locator(`a.mention[href="/${ctx.actor.handle}"]`, { hasText: `@${ctx.actor.handle}` }) +); + +flow("a mention in an issue body links to that person's profile") + .actor("owner") + .repo() + .step( + "an issue that mentions its author", + issue(ticketId, { + title: "mind the mention", + body: (ctx) => `thanks to @${ctx.actor.handle} for this` + }) + ) + + .open(page.issue(ticketId)) + .sees(mention) + .click(mention) + .at(page.profile); + +flow("a closed tab with nothing closed says so") + .actor("owner") + .repo() + .ticket(ticketId, { title: "still open" }) + + .open(page.issues) + .sees(link("still open")) + .click(closedTab) + .sees(not(link("still open"))) + .sees(text("No closed issues")); + +const pageItemNumbers = async (ctx: Ctx) => { + const names = await ctx.page.getByRole("link", { name: /page item \d+/ }).allInnerTexts(); + return names.map((name) => /page item (\d+)/.exec(name)![1]); +}; +const onFirstPage = new WeakMap(); + +const many = 31; +const pageSize = 30; + +flow("paging past the first thirty issues keeps the closed tab") + .actor("owner") + .repo() + .step(`${many} closed issues`, async (ctx) => { + for (let at = 1; at <= many; at++) { + await issue(ticketId, { + title: `page item ${String(at).padStart(2, "0")}`, + state: "closed", + wait: at === many + })(ctx); + } + }) + + .open(page.issues) + .sees(text("No issues yet.")) + .click(closedTab) + .sees(target("the next page button", (tab) => tab.getByRole("button", { name: "Next page" }))) + .step("the first page lists thirty", async (ctx) => { + const first = await pageItemNumbers(ctx); + expect(first).toHaveLength(pageSize); + onFirstPage.set(ctx, first); + }) + + .click(button("Next page")) + .step("the second page is still the closed tab", async (ctx) => { + await expect(ctx.page).toHaveURL(/state=closed&page=2|page=2&state=closed/); + await expect(ctx.page.getByRole("link", { name: `${many} Closed` })).toBeVisible(); + }) + .step("the second page lists the one that is left", async (ctx) => { + await expect.poll(async () => (await pageItemNumbers(ctx)).length).toBe(many - pageSize); + const everything = [...onFirstPage.get(ctx)!, ...(await pageItemNumbers(ctx))]; + expect(new Set(everything).size, "an issue is on both pages or missing").toBe(many); + }); + +const other = record("org.tangled.track.ticket"); + +const searchIssues = field("Search issues"); + +const needle = unique("search needle"); +const hay = unique("unrelated"); + +flow("searching the issue list finds the issue you named") + .actor("owner") + .repo() + .ticket(ticketId, { title: needle }) + .ticket(other, { title: hay }) + + .open(page.issues) + .sees(link(needle)) + .sees(link(hay)) + .fill(searchIssues, needle) + .press("Enter") + .sees(link(needle)) + .sees(not(link(hay))); + +const pulled = unique("a pull that is not an issue"); +const issued = unique("an issue that is not a pull"); + +onBranches("the issues list leaves out pull requests") + .pull(pullId, patchId, { ...branches, title: pulled }) + .ticket(ticketId, { title: issued }) + + .open(page.issues) + .sees(link(issued)) + .sees(not(link(pulled))) + .open(page.issue(pullId)) + .sees(text("Page not found")); + +const countMe = unique("count me"); + +flow("the comment count on the issue list follows comments and their erase") + .actor("owner") + .repo() + .ticket(ticketId, { title: countMe }) + + .open(page.issues) + .sees(text("0 comments")) + + .open(page.issue(ticketId)) + .fill(placeholder("Add to the discussion. Markdown is supported."), note) + .click(button("Comment")) + .trace(commentId, through(knot, hydrant, bobbin)) + .trace(subscriptionId, through(pds)) + .sees(text(note)) + .open(page.issues) + .sees(text("1 comment")) + + .open(page.issue(ticketId)) + .use(deletesComment) + .sees(not(text(note))) + .open(page.issues) + .sees(text("0 comments")); + +const title = "the build is broken"; +const body = "it says no"; + +const fixedTitle = "a typo in the title"; + +flow("opening an issue") + .actor("owner") + .repo() + + .open(page.newIssue) + .fill(field("Title"), title) + .fill(field("Body"), body) + .click(button("Create issue")) + .sends( + ticketId, + knot, + (ctx) => ({ + repo: ctx.repo.did, + record: { state: "open", title: { text: title }, body: { text: body } } + }), + "create" + ) + .at(page.newIssue) + .trace(ticketId, through(knot, hydrant, bobbin)) + .at(page.issues) + .sees(link(title)) + + .sends( + subscriptionId, + pds, + (ctx) => ({ record: { subject: { uri: ctx.get(ticketId).uri } } }), + "create" + ) + .trace(subscriptionId, through(pds)); + +for (const [verb, from, to, label, settled] of [ + ["closing", "open", "closed", "Close issue", "Reopen issue"], + ["reopening", "closed", "open", "Reopen issue", "Close issue"] +] as const) { + flow(`${verb} an issue`) + .actor("owner") + .repo() + .ticket(ticketId, { title: `${verb} this`, state: from }) + + .open(page.issue(ticketId)) + .changeState(ticketId, to, button(label), button(settled)) + .reload() + .sees(button(settled)); +} + +flow("editing an issue") + .actor("owner") + .repo() + .ticket(ticketId, { title: "a typo in the titel" }) + + .open(page.issue(ticketId)) + .click(moreActions) + .click(menuItem("Edit")) + .fill(field("Title"), fixedTitle) + .click(button("Save")) + .sends(ticketId, knot, replacing(ticketId, { title: { text: fixedTitle } }), "put") + .trace(ticketId, through(knot, hydrant, bobbin)) + .sees(text(fixedTitle)) + .reload() + .sees(text(fixedTitle)); + +// the kept ticket proves the list rendered, or "gone" would pass on an empty list +const kept = record("org.tangled.track.ticket"); + +flow("deleting an issue") + .actor("owner") + .repo() + .ticket(kept, { title: "keep this one" }) + .ticket(ticketId, { title: "delete this one" }) + + .open(page.issue(ticketId)) + .use(deletesIssue) + .at(page.issues) + .sees(link("keep this one")) + .sees(not(link("delete this one"))); + +const oldTitle = "a typo in the titel"; +const newTitle = "a typo in the title"; + +const cancel = button("Cancel"); + +flow("cancelling an issue edit leaves the issue as it was") + .actor("owner") + .repo() + .ticket(ticketId, { title: oldTitle }) + + .open(page.issue(ticketId)) + .click(moreActions) + .click(menuItem("Edit")) + .fill(field("Title"), newTitle) + .click(cancel) + .sendsNothing(ticketId) + .sees(text(oldTitle)) + .sees(not(text(newTitle))) + .reload() + .sees(text(oldTitle)) + .step( + "the knot and hydrant still have the first version", + stillStored(ticketId, (value) => value.title.text, oldTitle) + ); + +flow("cancelling a new issue writes nothing") + .actor("owner") + .repo() + + .open(page.newIssue) + .fill(field("Title"), newTitle) + .fill(field("Body"), "never mind") + .click(cancel) + .at(page.issues) + .sendsNothing(ticketId) + .sees(not(link(newTitle))) + .reload() + .sees(not(link(newTitle))); + +flow("an issue closed in another window shows closed after a reload") + .actor("owner") + .repo() + .ticket(ticketId, { title: "closed from the other window" }) + + .open(page.issue(ticketId)) + .sees(button("Close issue")) + + .window("second") + .open(page.issue(ticketId)) + .changeState(ticketId, "closed", button("Close issue"), button("Reopen issue")) + + .window() + .sees(button("Close issue")) + .reload() + .sees(button("Reopen issue")); diff --git a/e2e/specs/knots.spec.ts b/e2e/specs/knots.spec.ts new file mode 100644 index 000000000..878ed290e --- /dev/null +++ b/e2e/specs/knots.spec.ts @@ -0,0 +1,268 @@ +import { expect } from "@playwright/test"; + +import { getOk } from "../harness/api"; +import { config } from "../harness/config"; +import { + button, + type Ctx, + field, + flow, + heading, + knots, + link, + newKnot, + newSpindle, + not, + page, + spindles, + target, + text, + where +} from "../harness/flow"; +import { request, stamp, until, xrpc } from "../harness/net"; +import { type Saved, savedBy, saveFor } from "../harness/services"; +import { answerWith } from "../harness/shared"; + +const knotHost = new URL(config.knotUrl).host; +const invitePage = (code: (ctx: Ctx) => string) => + where("the invite page", (ctx) => `/invite?knot=${knotHost}&code=${code(ctx)}`); + +const adminSecret = process.env.E2E_KNOT_ADMIN_SECRET ?? "super-strong-devonly-key"; + +const codes = new WeakMap(); + +const mintInviteCode = async (ctx: Ctx) => { + const reply = await request(`${config.knotUrl}/xrpc/org.tangled.temp.server.createInviteCode`, { + method: "POST", + basic: `admin:${adminSecret}` + }); + expect(reply.status, `the knot minted no invite code: ${JSON.stringify(reply.body)}`).toBe(200); + codes.set(ctx, (reply.body as { code: string }).code); +}; +const codeOf = (ctx: Ctx) => codes.get(ctx)!; + +const isMember = async (did: string) => { + for (let cursor = ""; ;) { + const page = await getOk( + xrpc(config.knotUrl, "sh.tangled.knot.listMembers", { + subject: config.knotDid, + limit: "1000", + ...(cursor ? { cursor } : {}) + }) + ); + if (page.items.some((item: { subject: string }) => item.subject === did)) return true; + if (!page.cursor) return false; + cursor = page.cursor; + } +}; + +// the save is a pocket.putPreferences, not a record, and leaving before it's sent loses the knot +const saves = new WeakMap>(); +const watchTheSave = async (ctx: Ctx) => { + saves.set( + ctx, + ctx.page.waitForResponse((reply) => reply.url().includes("pocket.putPreferences")) + ); +}; +const theSaveIsDone = (ctx: Ctx) => saves.get(ctx)!; + +const becomesMember = (ctx: Ctx) => + until(`the knot should list ${ctx.actor.did} as a member`, 15_000, async () => { + const member = await isMember(ctx.actor.did); + return [member, member ? "it does" : "it does not"]; + }); + +flow("joining a knot from an invite link makes you a member and saves the knot") + .actor("guest") + .step("the knot mints an invite code", mintInviteCode) + + .open(invitePage(codeOf)) + .sees(heading(`Join ${knotHost}`)) + .joinedKnot() + .step("watch for the page's save", watchTheSave) + .click(button(`Join ${knotHost}`)) + .step("the knot lists the guest as a member", becomesMember) + .step("the page has saved the knot", theSaveIsDone) + + .open(knots) + .sees(text(knotHost)); + +flow("a joined knot says you are in") + .actor("guest") + .step("the knot mints an invite code", mintInviteCode) + + .open(invitePage(codeOf)) + .joinedKnot() + .click(button(`Join ${knotHost}`)) + .step("the knot lists the guest as a member", becomesMember) + .sees(text("You're in")) + .sees(link("Your knots")); + +const failSavedList = answerWith( + /com\.bad-example\.pocket\.getPreferences/, + 500, + "InternalServerError", + "the e2e flow cut this read" +); +const restoreSavedList = (ctx: Ctx) => ctx.page.unroute(/com\.bad-example\.pocket\.getPreferences/); + +const savedSpindle = "spindle.saved.example.test"; +const addedKnot = "knot.added.example.test"; + +flow("adding a knot keeps the saved spindles when the saved list cannot be read") + .actor("saver") + .open(newSpindle) + .fill(field("Spindle address"), savedSpindle) + .click(button("Add")) + .at(spindles) + .sees(text(savedSpindle)) + + .open(newKnot) + .step("the saved list can't be read", failSavedList) + .fill(field("Knot address"), addedKnot) + .click(button("Add")) + .at(knots) + .step("the saved list can be read again", restoreSavedList) + .reload() + .sees(text(addedKnot)) + + .open(spindles) + .sees(target("the saved spindle", (tab) => tab.getByText(savedSpindle, { exact: true }))); + +const savedBecomes = (saved: (ctx: Ctx) => Saved) => async (ctx: Ctx) => { + const want = saved(ctx); + await until( + `pocket should keep ${JSON.stringify(want)} for ${ctx.actor.handle}`, + 15_000, + async () => { + const has = await savedBy(ctx.actor); + return [ + JSON.stringify(has) === + JSON.stringify({ knots: want.knots.sort(), spindles: want.spindles.sort() }), + `it keeps ${JSON.stringify(has)}` + ]; + } + ); +}; + +// the row is the closest min-h-2xl div above the host text +const removeFor = (pick: Pick) => + target(`Remove on the row of the ${pick} host`, (tab, ctx) => + tab + .getByText(named(pick)(ctx), { exact: true }) + .locator("xpath=ancestor::div[contains(@class, 'min-h-2xl')][1]") + .getByRole("button", { name: "Remove", exact: true }) + ); +const savedHost = (pick: Pick) => + target(`the saved ${pick} host`, (tab, ctx) => + tab.getByText(named(pick)(ctx), { exact: true }) + ); + +const hosts = new WeakMap(); +const pickHosts = async (ctx: Ctx) => { + const tag = stamp(); + hosts.set(ctx, { + a: `a-${tag}.knot.e2e.test`, + b: `b-${tag}.knot.e2e.test`, + s: `s-${tag}.spindle.e2e.test` + }); +}; +type Pick = "a" | "b" | "s"; +const named = (pick: Pick) => (ctx: Ctx) => hosts.get(ctx)![pick]; + +flow("a knot saved in settings is offered when creating a repo") + .actor("owner") + .step("pick hosts", pickHosts) + + .open(newKnot) + .fill(field("Knot address"), named("a")) + .click(button("Add")) + .at(knots) + .sees(savedHost("a")) + .step( + "pocket keeps the knot", + savedBecomes((ctx) => ({ knots: [named("a")(ctx)], spindles: [] })) + ) + + .open(page.newRepo) + .sees( + target("the saved knot among the choices", (tab, ctx) => + tab.getByRole("radio", { name: named("a")(ctx) }) + ) + ); + +flow("removing a saved knot keeps the other knot and the saved spindle") + .actor("owner") + .step("pick hosts", pickHosts) + .step("two knots and a spindle are saved", (ctx) => + saveFor(ctx.actor, { + knots: [named("a")(ctx), named("b")(ctx)], + spindles: [named("s")(ctx)] + }) + ) + + .open(knots) + .click(removeFor("a")) + .sees(not(savedHost("a"))) + .step( + "pocket keeps one knot and the spindle", + savedBecomes((ctx) => ({ knots: [named("b")(ctx)], spindles: [named("s")(ctx)] })) + ) + .reload() + .sees(savedHost("b")) + .sees(not(savedHost("a"))); + +flow("removing a saved spindle keeps the saved knot") + .actor("owner") + .step("pick hosts", pickHosts) + .step("a knot and a spindle are saved", (ctx) => + saveFor(ctx.actor, { knots: [named("a")(ctx)], spindles: [named("s")(ctx)] }) + ) + + .open(spindles) + .click(removeFor("s")) + .sees(text("No spindles saved yet")) + .step( + "pocket keeps the knot and no spindle", + savedBecomes((ctx) => ({ knots: [named("a")(ctx)], spindles: [] })) + ) + .reload() + .sees(text("No spindles saved yet")) + .open(knots) + .sees(savedHost("a")); + +const addKnotButton = button("Add"); + +flow("the add knot form is off until an address is typed and cancel saves nothing") + .actor("owner") + .step("pick hosts", pickHosts) + + .open(newKnot) + .step("Add is off", async (ctx) => { + await expect(addKnotButton.locate(ctx.page, ctx)).toBeDisabled(); + }) + .fill(field("Knot address"), named("a")) + .step("Add is on, and is left alone", async (ctx) => { + await expect(addKnotButton.locate(ctx.page, ctx)).toBeEnabled(); + }) + .click(link("Cancel")) + .at(knots) + .sees(text("No knots saved yet")) + .step( + "pocket keeps nothing", + savedBecomes(() => ({ knots: [], spindles: [] })) + ); + +flow("a knot typed with https and a path is saved as just its host") + .actor("owner") + .step("pick hosts", pickHosts) + + .open(newKnot) + .fill(field("Knot address"), (ctx) => `https://${named("a")(ctx).toUpperCase()}/some/path`) + .click(addKnotButton) + .at(knots) + .sees(savedHost("a")) + .step( + "pocket keeps the bare host", + savedBecomes((ctx) => ({ knots: [named("a")(ctx)], spindles: [] })) + ); diff --git a/e2e/specs/labels.spec.ts b/e2e/specs/labels.spec.ts new file mode 100644 index 000000000..8848b7c8a --- /dev/null +++ b/e2e/specs/labels.spec.ts @@ -0,0 +1,220 @@ +import { expect } from "@playwright/test"; + +import { editCob, knotRecordFor } from "../harness/api"; +import { + bobbin, + button, + confirmDelete, + type Ctx, + editLabels, + field, + flow, + hydrant, + knot, + labelDefinitionId, + labelOption, + link, + newLabel, + not, + page, + placeholder, + record, + target, + text, + through, + ticketId, + triage, + triageChip, + triageToken, + unique, + where, + within +} from "../harness/flow"; +import { defineLabel, labelOff, labelOn, labelsOnKnot } from "../harness/shared"; + +const other = record("org.tangled.track.ticket"); + +const labelled = unique("labelled one"); +const plain = unique("plain one"); + +// the sidebar can't make a label for a new repo's owner, so write it through the api +const labelTicket = async (ctx: Ctx) => { + await defineLabel(triage)(ctx); + const put = await editCob(ctx.actor, ctx.get(ticketId).uri, "label", (stored) => ({ + ...stored, + tangledLabels: [triageToken] + })); + expect(put.status, `labelling the ticket answered ${JSON.stringify(put.body)}`).toBe(200); + await bobbin.indexed({ uri: put.body.uri, cid: put.body.cid }); +}; + +const labelList = where( + "the issues list for one label", + (ctx) => `${ctx.repo.path}/issues?label=${triage}` +); + +flow("a label chip opens the issue list filtered to that label") + .actor("owner") + .repo() + .ticket(ticketId, { title: labelled }) + .ticket(other, { title: plain }) + .step("label the first ticket", labelTicket) + + .open(page.issue(ticketId)) + .sees(link(triage)) + .click(link(triage)) + .at(labelList) + .sees(link(labelled)) + .sees(not(link(plain))); + +flow("the repo owner can make a label from the issue sidebar") + .actor("owner") + .repo() + .ticket(ticketId, { title: labelled }) + + .open(page.issue(ticketId)) + .click(button("Edit labels")) + .fill(placeholder("Filter labels…"), triage) + .sees( + target(`option Create "${triage}"`, (tab) => tab.getByRole("option", { name: /Create/ })) + ); + +flow("a label made with spaces and a chosen colour is stored under its slug") + .actor("owner") + .repo() + + .open(newLabel) + .fill(field("Name"), "Good First Issue") + .click(button("Use colour #3b82f6")) + .click(button("Add")) + .sends( + labelDefinitionId, + knot, + (ctx) => ({ + repo: ctx.repo.did, + rkey: "good-first-issue", + record: { $type: labelDefinitionId.nsid, name: "Good First Issue", color: "#3b82f6" } + }), + "create" + ) + .trace(labelDefinitionId, through(knot, hydrant, bobbin)) + .at(page.settings) + .sees(button("Delete Good First Issue")) + .reload() + .sees(button("Delete Good First Issue")); + +flow("putting a label on an issue and taking it off") + .actor("owner") + .repo() + .ticket(ticketId, { title: "label me" }) + .step("define the label", defineLabel(triage)) + + .open(page.issue(ticketId)) + .use(labelOn(ticketId, "ticket")) + .use(labelOff(ticketId, "ticket")); + +const settings = where("the repo's settings", (ctx) => `${ctx.repo.path}/settings`); +const deleteLabel = button(`Delete ${triage}`); + +flow("deleting a label definition takes it off the issue") + .actor("owner") + .repo() + .ticket(ticketId, { title: "labelled, then not" }) + + .open(newLabel) + .fill(field("Name"), triage) + .click(button("Add")) + .sends( + labelDefinitionId, + knot, + (ctx) => ({ + repo: ctx.repo.did, + rkey: triage, + record: { $type: labelDefinitionId.nsid, name: triage } + }), + "create" + ) + .trace(labelDefinitionId, through(knot, hydrant, bobbin)) + .at(settings) + .sees(deleteLabel) + + .open(page.issue(ticketId)) + .click(editLabels) + .click(labelOption) + .trace(ticketId, through(knot, hydrant, bobbin)) + .sees(triageChip) + .step("the knot's ticket carries the label", async (ctx) => + expect(await labelsOnKnot(ctx, ticketId)).toEqual([triage]) + ) + + .open(settings) + .click(deleteLabel) + .click(confirmDelete) + .sends( + labelDefinitionId, + knot, + (ctx) => ({ repo: ctx.repo.did, collection: labelDefinitionId.nsid, rkey: triage }), + "delete" + ) + .trace(labelDefinitionId, through(knot, hydrant, bobbin)) + .sees(not(deleteLabel)) + .open(page.issue(ticketId)) + .sees(text("labelled, then not")) + .sees(not(triageChip)) + .step("the knot's ticket is off the label", async (ctx) => + expect(await labelsOnKnot(ctx, ticketId)).toEqual([]) + ); + +const taken = target("the refusal in the form", (tab) => + tab.getByRole("alert").filter({ hasText: "a label def already stands at" }) +); + +flow("a label name the repo already has is refused by the form") + .actor("owner") + .repo() + .step("define the label", defineLabel(triage)) + + .open(newLabel) + .fill(field("Name"), triage) + .click(button("Add")) + .refused(labelDefinitionId, 409) + .sees(taken) + .at(newLabel); + +flow("cancelling a new label writes nothing") + .actor("owner") + .repo() + + .open(newLabel) + .fill(field("Name"), triage) + .click(link("Cancel")) + .at(settings) + .sendsNothing(labelDefinitionId) + .sees(text("No labels added yet")) + .reload() + .sees(text("No labels added yet")); + +flow("cancelling a label delete keeps the label") + .actor("owner") + .repo() + .step("define the label", defineLabel(triage)) + + .open(settings) + .click(deleteLabel) + .click( + within( + target("the dialog", (tab) => tab.getByRole("dialog")), + button("Cancel") + ) + ) + .sendsNothing(labelDefinitionId) + .sees(deleteLabel) + .reload() + .sees(deleteLabel) + .step("the knot still has the label", async (ctx) => { + const stored = await knotRecordFor( + `at://${ctx.repo.did}/${labelDefinitionId.nsid}/${triage}`, + "read" + ); + expect(stored.value.name).toBe(triage); + }); diff --git a/e2e/specs/merges.spec.ts b/e2e/specs/merges.spec.ts new file mode 100644 index 000000000..661b490e6 --- /dev/null +++ b/e2e/specs/merges.spec.ts @@ -0,0 +1,305 @@ +import { knotReply, pulls } from "../harness/api"; +import { + bobbin, + button, + type Ctx, + field, + flow, + hydrant, + knot, + link, + mergeButton, + not, + page, + patchId, + pullId, + refusal, + rkeyOf, + target, + text, + through, + unique, + where +} from "../harness/flow"; +import { + base, + branches, + merges, + onBranches, + pipelineFor, + sendsPatch, + subscribesToPull, + targetIsAt +} from "../harness/shared"; + +const title = unique("a pull to merge"); +onBranches("a merged pull moves from the open list to the merged list") + .pull(pullId, patchId, { ...branches, title }) + .open(page.pull(pullId)) + .use(merges) + .open(page.pulls) + .sees(not(link(title))) + .open(where("the merged pulls", (ctx) => `${ctx.repo.path}/pulls?state=completed`)) + .sees(link(title)); + +onBranches("the merged branch can be deleted after a merge") + .pull(pullId, patchId, branches) + .open(page.pull(pullId)) + .click(mergeButton) + .sees(text("Merged")) + .sees(button("Delete branch")); + +onBranches("merging a pull request", { ci: true }) + .pull(pullId, patchId, branches) + .open(page.pull(pullId)) + .use(merges) + .knotStored(pullId, "completed") + .step("the target branch moved to the feature's commit", targetIsAt(branches.from)) + .checkpoint("the merge moved the target branch") + .step( + "spindle has a pipeline for the push the merge made", + pipelineFor("push", (ctx) => ctx.repo.heads[branches.from]) + ); + +// the patch names the merge base, so if the target moved after the feature was cut, open from the fork point +const marks = new Map(); +const mark = (name: string) => async (ctx: Ctx) => { + marks.set(`${ctx.repo.did} ${name}`, ctx.pushed.sha); +}; +const marked = (ctx: Ctx, name: string) => { + const sha = marks.get(`${ctx.repo.did} ${name}`); + if (!sha) throw new Error(`no commit was marked ${name} for ${ctx.repo.did}`); + return sha; +}; +const markForkPoint = mark("fork point"); +const openFromForkPoint = (title?: string) => async (ctx: Ctx) => { + const opened = await pulls.open( + ctx.actor, + ctx.repo.did, + title ?? unique("a pull"), + branches.into, + { base: marked(ctx, "fork point"), head: ctx.repo.heads[branches.from] } + ); + ctx.adopt(pullId, opened.ticket); + ctx.adopt(patchId, opened.patch); + await bobbin.indexed(opened.ticket); + await bobbin.indexed(opened.patch); +}; + +// the pull page checks the target's tip through bobbin, so the mirror needs it first +const mirroredTip = async (ctx: Ctx) => { + await bobbin.hasBranch(ctx.repo.did, branches.into, ctx.repo.heads[branches.into]); +}; + +const pullAt = (ctx: Ctx, tail: string) => `${ctx.repo.path}/pulls/${rkeyOf(ctx, pullId)}${tail}`; + +const forkTitle = unique("a fix from my fork"); +const composeFromFork = where( + "the new pull form in fork mode", + (ctx) => + `${ctx.upstream.path}/pulls/new?source=fork&fork=${ctx.repo.did}&sourceBranch=${branches.from}&targetBranch=${branches.into}` +); +const upstreamPulls = where("the upstream's pulls list", (ctx) => `${ctx.upstream.path}/pulls`); +const upstreamPull = where( + "the pull on the upstream", + (ctx) => `${ctx.upstream.path}/pulls/${rkeyOf(ctx, pullId)}` +); + +flow("opening a pull request from a fork to the upstream") + .actor("owner") + .repo() + .push(base) + .actor("forker") + .fork() + .push({ "e2e/fork.txt": "from the fork\n" }, { branch: branches.from, base: branches.into }) + .step("the mirror has the upstream's main", async (ctx) => { + await bobbin.hasBranch(ctx.upstream.did, branches.into, ctx.upstream.heads[branches.into]); + }) + .mirrored() + + .open(composeFromFork) + .fill(field("Title"), forkTitle) + .click(button("Create pull request")) + .sends( + pullId, + knot, + (ctx) => ({ + repo: ctx.upstream.did, + record: { state: "open", title: { text: forkTitle }, targetBranch: branches.into } + }), + "create" + ) + .trace(pullId, through(knot, hydrant, bobbin)) + .use(sendsPatch({ fork: true })) + .use(subscribesToPull) + .at(upstreamPulls) + .sees(link(forkTitle)) + + .actor("owner") + .open(upstreamPull) + .sees(text(forkTitle)) + .sees(target("the fork's file in the diff", (tab) => tab.getByText("/fork.txt").first())); + +const mergeDisabled = target("the disabled merge button", (tab) => + tab.getByRole("button", { name: "Rebase & merge", exact: true, disabled: true }) +); +const conflictsSummary = target("the conflicts summary", (tab) => + tab.getByText("Conflicts", { exact: true }) +); +const conflictedFile = target("the conflicted file", (tab) => tab.getByTitle("e2e/conflict.txt")); + +const rejectMerge = (ctx: Ctx) => + knotReply(ctx.actor, ctx.repo.did, "sh.tangled.repo.pull.merge", { + rkey: rkeyOf(ctx, patchId) + }); + +flow("a pull whose branches conflict can't be merged and the page says which file") + .actor("owner") + .repo() + .push({ "e2e/conflict.txt": "base\n" }) + .step("mark the fork point", markForkPoint) + .push( + { "e2e/conflict.txt": "the feature's line\n" }, + { branch: branches.from, base: branches.into } + ) + .push({ "e2e/conflict.txt": "the target's line\n" }) + .step("the mirror has the target's new tip", mirroredTip) + .mirrored() + .step("open the pull", openFromForkPoint()) + + .open(page.pull(pullId)) + .sees(conflictsSummary) + .sees(mergeDisabled) + .click(conflictsSummary) + .sees(conflictedFile) + .rejects("merging the conflicting pull", rejectMerge, 409) + .knotStored(pullId, "open") + .step("the target branch didn't move", targetIsAt(branches.into)); + +flow("a pull merges when the target gained another file in the same folder") + .actor("owner") + .repo() + .push(base) + .step("mark the fork point", markForkPoint) + .push({ "e2e/feature.txt": "feature\n" }, { branch: branches.from, base: branches.into }) + .push({ "e2e/target.txt": "target\n" }) + .step("the mirror has the target's new tip", mirroredTip) + .mirrored() + .step("open the pull", openFromForkPoint()) + + .open(page.pull(pullId)) + .sees(not(mergeDisabled)) + .use(merges) + .knotStored(pullId, "completed"); + +const short = (sha: string) => sha.slice(0, 8); + +const commitRow = (nth: "first" | "last") => + target(`the ${nth} commit's button`, (tab) => + tab.getByRole("button", { name: /^Show changes in / })[nth]() + ); +// the diff writes the count twice, the phone layout's copy is hidden +const changedFiles = (count: string) => + target(count, (tab) => tab.getByText(count).filter({ visible: true })); +const inDiff = (file: string) => target(`${file} in the diff`, (tab) => tab.getByText(`/${file}`)); + +const twoCommits = (name: string) => + flow(name) + .actor("owner") + .repo() + .push(base) + .step("mark the fork point", markForkPoint) + .push({ "e2e/first.txt": "first\n" }, { branch: branches.from, base: branches.into }) + .step("mark the first commit", mark("first")) + .push({ "e2e/second.txt": "second\n" }, { branch: branches.from }) + .mirrored() + .step("open the pull", openFromForkPoint()); + +twoCommits("narrowing a pull's diff to one commit and back to all of them") + .open(page.pull(pullId)) + .sees(changedFiles("2 changed files")) + .click(commitRow("first")) + .at( + where("the newest commit's range", (ctx) => + pullAt( + ctx, + `/0/${short(marked(ctx, "first"))}..${short(ctx.repo.heads[branches.from])}` + ) + ) + ) + .sees(changedFiles("1 changed file")) + .sees(inDiff("second.txt")) + .sees(not(inDiff("first.txt"))) + .reload() + .sees(changedFiles("1 changed file")) + .sees(inDiff("second.txt")) + .sees(not(inDiff("first.txt"))) + .click(button("Show all changes")) + .at(where("the whole version", (ctx) => pullAt(ctx, "/0"))) + .sees(changedFiles("2 changed files")); + +twoCommits("the oldest commit's button narrows the diff to the first file") + .open(page.pull(pullId)) + .click(commitRow("last")) + .sees(changedFiles("1 changed file")) + .sees(inDiff("first.txt")) + .sees(not(inDiff("second.txt"))); + +twoCommits("a range that names no commit in the url goes back to the whole version") + .open( + where("a range naming no commit", (ctx) => + pullAt(ctx, `/0/${"0".repeat(8)}..${"1".repeat(8)}`) + ) + ) + .sees(changedFiles("2 changed files")) + .at(where("the version without the range", (ctx) => pullAt(ctx, "/0"))); + +const mergedInAnotherWindow = (name: string) => + onBranches(name) + .pull(pullId, patchId, branches) + .open(page.pull(pullId)) + .sees(mergeButton) + .window("second") + .open(page.pull(pullId)) + .use(merges) + .window() + .click(mergeButton); + +const closedInAnotherWindow = (name: string) => + onBranches(name) + .pull(pullId, patchId, branches) + .open(page.pull(pullId)) + .sees(mergeButton) + .window("second") + .open(page.pull(pullId)) + .changeState(pullId, "closed", button("Close"), button("Reopen")) + .window() + .click(mergeButton); + +const alreadyMerged = refusal("is already merged"); +const closedPull = refusal("a closed pull isn't merged"); + +mergedInAnotherWindow("a second merge from a stale window is refused and the page says why") + .sees(alreadyMerged) + .knotStored(pullId, "completed") + .step("the target branch moved once, to the feature's commit", targetIsAt(branches.from)); + +mergedInAnotherWindow("a stale window stops offering merge once the knot said it was merged") + .sees(alreadyMerged) + .sees(not(mergeButton)) + .sees(text("Merged")); + +closedInAnotherWindow( + "merging a pull that was closed in another window is refused and the page says why" +) + .sees(closedPull) + .knotStored(pullId, "closed") + .step("the target branch didn't move", targetIsAt(branches.into)) + .reload() + .sees(button("Reopen")) + .sees(not(mergeButton)); + +closedInAnotherWindow("a stale window shows the pull as closed once the knot refused the merge") + .sees(closedPull) + .sees(button("Reopen")) + .sees(not(mergeButton)); diff --git a/e2e/specs/permissions.spec.ts b/e2e/specs/permissions.spec.ts new file mode 100644 index 000000000..6bb2c5719 --- /dev/null +++ b/e2e/specs/permissions.spec.ts @@ -0,0 +1,201 @@ +import { knotReply, ticketRecord, tickets } from "../harness/api"; +import { seeded } from "../harness/config"; +import { + bobbin, + button, + type Ctx, + field, + flow, + hydrant, + knot, + link, + loginThen, + menuItem, + moreActions, + not, + page, + pds, + refusedByPolicy, + refusedTicketId, + subscriptionId, + target, + text, + through, + ticketId +} from "../harness/flow"; +import { stamp } from "../harness/net"; +import { deletesIssue, forgetTheLogin } from "../harness/shared"; + +const policyIs = (policy: string) => `where contribution policy is ${policy}`; +const shownByButton = (policy: string) => + target("the refusal beside the button", (tab) => + tab.getByRole("status").filter({ hasText: policyIs(policy) }) + ); + +const title = "the build is broken"; +const fixedTitle = "a typo in the title"; + +flow("an outsider keeps erase but loses edit and close when the policy tightens") + .actor("owner") + .repo() + .actor(seeded.charlie) + .open(page.newIssue) + .fill(field("Title"), title) + .click(button("Create issue")) + .trace(ticketId, through(knot, hydrant, bobbin)) + .trace(subscriptionId, through(pds)) + + .actor("owner") + .policy("collaborators") + + .actor(seeded.charlie) + .open(page.issue(ticketId)) + .click(button("Close issue")) + .refused(refusedTicketId, 403) + .sees(shownByButton("collaborators")) + .knotStored(ticketId, "open") + .click(moreActions) + .click(menuItem("Edit")) + .fill(field("Title"), fixedTitle) + .click(button("Save")) + .refused(refusedTicketId, 403) + .sees(refusedByPolicy("collaborators")) + .knotStored(ticketId, "open") + .click(button("Cancel")) + + .use(deletesIssue) + .at(page.issues) + .sees(not(link(title))); + +flow( + "the issue page stops offering close and edit to an author the policy no longer lets change it" +) + .actor("owner") + .repo() + .actor(seeded.charlie) + .ticket(ticketId, { title }) + .actor("owner") + .policy("collaborators") + .actor(seeded.charlie) + .open(page.issue(ticketId)) + .sees(not(button("Close issue"))); + +flow("a members only repo takes a knot member's issue and refuses a stranger's") + .actor("owner") + .repo() + .policy("members") + + .actor(seeded.bob) + .open(page.newIssue) + .fill(field("Title"), title) + .click(button("Create issue")) + .trace(ticketId, through(knot, hydrant, bobbin)) + .trace(subscriptionId, through(pds)) + .at(page.issues) + .sees(link(title)) + + .actor(seeded.charlie) + .open(page.newIssue) + .fill(field("Title"), "let me in") + .click(button("Create issue")) + .refused(refusedTicketId, 403) + .sees(refusedByPolicy("members")) + .at(page.newIssue) + .open(page.issues) + .sees(link(title)) + .sees(not(link("let me in"))); + +const alicesClose = (ctx: Ctx) => tickets.setState(ctx.actor, ctx.get(ticketId), "closed"); + +flow("a knot admin closes a stranger's issue through the api") + .actor("owner") + .repo() + .actor(seeded.charlie) + .ticket(ticketId, { title }) + + .actor(seeded.alice) + .open(page.issue(ticketId)) + .sees(text(title)) + .sees(not(button("Close issue"))) + .step("alice closes it through the api", alicesClose) + .learn(ticketId) + .follow(ticketId, through(knot, hydrant, bobbin)) + .knotStored(ticketId, "closed"); + +flow("a closed issue still has its author after a knot admin closes it") + .actor("owner") + .repo() + .actor(seeded.charlie) + .ticket(ticketId, { title }) + .actor(seeded.alice) + .step("alice closes it through the api", alicesClose) + .learn(ticketId) + .follow(ticketId, through(knot, hydrant, bobbin)) + .actor(seeded.charlie) + .open(page.issue(ticketId)) + .sees(button("Reopen issue")); + +flow("a knot admin can close an issue on a collaborators only repo but not open one") + .actor("owner") + .repo() + .actor(seeded.charlie) + .ticket(ticketId, { title }) + .actor("owner") + .policy("collaborators") + + .actor(seeded.alice) + .rejects( + "alice opening an issue", + (ctx) => + knotReply(ctx.actor, ctx.repo.did, "com.atproto.repo.createRecord", { + collection: ticketId.nsid, + record: ticketRecord("let the admin in") + }), + 403 + ) + .step("alice closes it through the api", alicesClose) + .learn(ticketId) + .follow(ticketId, through(knot, hydrant, bobbin)) + .knotStored(ticketId, "closed"); + +flow("a signed out visitor is sent to log in by the pages that need an account") + .actor("owner") + .repo() + .actor(seeded.charlie) + .open(page.repo) + .step("signs out", forgetTheLogin) + .open(page.newIssue) + .at(loginThen((ctx) => `${ctx.repo.path}/issues/new`)) + .open(page.settings) + .at(loginThen((ctx) => `${ctx.repo.path}/settings`)) + .open(page.newRepo) + .at(loginThen(() => "/repo/new")); + +flow("a stranger opening a repo's settings is told they need push access") + .actor("owner") + .repo() + .open(page.settings) + .sees(text("Danger zone")) + .actor(seeded.charlie) + .open(page.settings) + .sees(text("You need push access to this repository to change its settings")); + +flow("a stranger can't create a repo on the closed knot and the form says why") + .actor(seeded.charlie) + .open(page.newRepo) + .fill(field("Repository name"), () => `e2e-denied-${stamp()}`) + .click(button("Create repository")) + .sees(text("only knot admin or member")); + +flow("a stranger sees no close, edit or delete on someone else's issue") + .actor("owner") + .repo() + .ticket(ticketId, { title }) + .actor(seeded.charlie) + .open(page.issue(ticketId)) + .sees(text(title)) + .sees(not(button("Close issue"))) + .click(moreActions) + .sees(menuItem("Copy AT URI")) + .sees(not(menuItem("Edit"))) + .sees(not(menuItem("Delete"))); diff --git a/e2e/specs/pipelines.spec.ts b/e2e/specs/pipelines.spec.ts new file mode 100644 index 000000000..6d0d0519a --- /dev/null +++ b/e2e/specs/pipelines.spec.ts @@ -0,0 +1,314 @@ +import { expect } from "@playwright/test"; + +import { serviceDo } from "../harness/api"; +import { type Account, config } from "../harness/config"; +import { + button, + type Ctx, + dialogButton, + flow, + link, + not, + placeholder, + saveButton, + settingsAt, + target, + text +} from "../harness/flow"; +import { stamp, until } from "../harness/net"; +import { saveFor } from "../harness/services"; +import { + acceptConfirm, + finished, + manifestOf, + pipelineFor, + saveFinishes, + workflow, + workflowPath +} from "../harness/shared"; + +flow("pushing a workflow makes a pipeline for the push") + .actor("owner") + .repo({ spindle: config.spindleDid }) + .push({ [workflowPath]: workflow }) + .step( + "spindle has a pipeline for the push", + pipelineFor("push", (ctx) => ctx.pushed.sha) + ); + +const hooks = settingsAt("hooks"); +const newHook = settingsAt("hooks/new"); +const pipelines = settingsAt("pipelines"); +const newSecret = settingsAt("pipelines/secrets/new"); + +const landsOn = (what: string, at: (ctx: Ctx) => string) => (ctx: Ctx) => + finished( + ctx, + what, + expect(ctx.page).toHaveURL(`${config.webUrl}${at(ctx)}`, { timeout: 8000 }) + ); + +const spindleGet = (who: Account, nsid: string, params: Record) => + serviceDo(who, config.spindleUrl, config.spindleDid, nsid, { params }); +const spindlePost = (who: Account, nsid: string, json: object) => + serviceDo(who, config.spindleUrl, config.spindleDid, nsid, { json }); + +const WEBHOOKS = "org.tangled.temp.webhook"; +const SECRETS = "org.tangled.secret"; + +let hookUrl = ""; +let hookId = 0; +const pickHook = async () => { + hookUrl = `https://example.com/e2e/${stamp()}`; +}; +const deleteHook = button("Delete"); +const newHookLink = link("New webhook"); +const hookRow = target("the webhook's url", (tab) => tab.getByText(hookUrl, { exact: true })); +const hookSwitch = target("the webhook's switch", (tab) => + tab.getByRole("switch", { name: `Enable ${hookUrl}`, exact: true }) +); + +interface Hook { + id: number; + url: string; + events: string[]; + active: boolean; +} +const listHooks = (ctx: Ctx) => + spindleGet(ctx.actor, `${WEBHOOKS}.listWebhooks`, { repoDid: ctx.repo.did }); +const hooksOnSpindle = async (ctx: Ctx) => { + const reply = await listHooks(ctx); + expect(reply.status, `spindle answered ${JSON.stringify(reply.body)}`).toBe(200); + return (reply.body.webhooks ?? []) as Hook[]; +}; +// spindle takes a while to learn a new repo, so the list is polled +const spindleHooks = (what: string, ok: (hooks: Hook[]) => boolean) => async (ctx: Ctx) => { + await until(`spindle should list ${what}`, 10_000, async () => { + const reply = await listHooks(ctx); + return [ + reply.status === 200 && ok(reply.body?.webhooks ?? []), + `it answered ${reply.status} ${JSON.stringify(reply.body)}` + ]; + }); +}; +const makeHook = async (ctx: Ctx) => { + const made = await spindlePost(ctx.actor, `${WEBHOOKS}.createWebhook`, { + repoDid: ctx.repo.did, + url: hookUrl, + events: ["push"], + active: true + }); + expect(made.status, `spindle refused the webhook: ${JSON.stringify(made.body)}`).toBe(200); + hookId = made.body.id; +}; +const dropHook = async (ctx: Ctx) => { + await spindlePost(ctx.actor, `${WEBHOOKS}.deleteWebhook`, { + repoDid: ctx.repo.did, + id: hookId + }); +}; + +flow("adding a webhook lists it under hooks") + .actor("owner") + .repo({ spindle: config.spindleDid }) + .step("pick the payload url", pickHook) + + .open(hooks) + .sees(newHookLink) + .click(newHookLink) + .at(newHook) + .fill(placeholder("https://example.com/webhook"), () => hookUrl) + .click(button("Add")) + .step( + "the form finishes on the hooks list", + landsOn("the webhook was not added", (ctx) => `${ctx.repo.path}/settings/hooks`) + ) + .sees(hookRow) + .step( + "spindle lists it for push events, switched on", + spindleHooks("the new webhook", (listed) => + listed.some((one) => one.url === hookUrl && one.active && one.events.join() === "push") + ) + ) + .reload() + .sees(hookRow) + .step("take the webhook away", async (ctx) => { + hookId = (await hooksOnSpindle(ctx)).find((one) => one.url === hookUrl)!.id; + await dropHook(ctx); + }); + +flow("deleting a webhook takes it off the list and off the spindle") + .actor("owner") + .repo({ spindle: config.spindleDid }) + .step("pick the payload url", pickHook) + .step("make a webhook on the spindle", makeHook) + + .open(hooks) + .sees(hookRow) + .step("accept the confirm", acceptConfirm) + .sees(deleteHook) + .click(deleteHook) + .sees(not(hookRow)) + .step( + "spindle lists no webhook", + spindleHooks("no webhook", (listed) => listed.length === 0) + ) + .reload() + .sees(not(hookRow)) + .sees(text("No webhooks yet")); + +flow("switching a webhook off keeps it listed and inactive") + .actor("owner") + .repo({ spindle: config.spindleDid }) + .step("pick the payload url", pickHook) + .step("make a webhook on the spindle", makeHook) + + .open(hooks) + .sees(hookRow) + .step("it starts switched on", async (ctx) => { + await expect(hookSwitch.locate(ctx.page, ctx)).toBeChecked(); + }) + .sees(hookSwitch) + .click(hookSwitch) + .step("the switch goes off", async (ctx) => { + await expect(hookSwitch.locate(ctx.page, ctx)).not.toBeChecked(); + }) + .step( + "spindle keeps it, inactive", + spindleHooks("the webhook switched off", (listed) => + listed.some((one) => one.url === hookUrl && !one.active) + ) + ) + .reload() + .sees(hookRow) + .step("it is still off after a reload", async (ctx) => { + await expect(hookSwitch.locate(ctx.page, ctx)).not.toBeChecked(); + }) + .step("take the webhook away", dropHook); + +let secretKey = ""; +const pickSecret = async () => { + secretKey = `E2E_${stamp().toUpperCase()}`; +}; +const deleteSecret = button("Delete"); +const addSecretLink = link("Add secret"); +const secretRow = target("the secret's name", (tab) => tab.getByText(secretKey, { exact: true })); +const secretsOnSpindle = + (what: string, ok: (keys: string[], who: string[]) => boolean) => async (ctx: Ctx) => { + await until(`spindle should list ${what}`, 10_000, async () => { + const reply = await spindleGet(ctx.actor, `${SECRETS}.listSecrets`, { + repo: ctx.repo.did + }); + const secrets = (reply.body?.secrets ?? []) as { key: string; createdBy: string }[]; + return [ + reply.status === 200 && + ok( + secrets.map((one) => one.key), + secrets.map((one) => one.createdBy) + ), + `it answered ${reply.status} ${JSON.stringify(reply.body)}` + ]; + }); + }; +const makeSecret = async (ctx: Ctx) => { + const made = await spindlePost(ctx.actor, `${SECRETS}.addSecret`, { + repo: ctx.repo.did, + key: secretKey, + value: "hunter2" + }); + expect(made.status, `spindle refused the secret: ${JSON.stringify(made.body)}`).toBe(200); +}; +const dropSecret = async (ctx: Ctx) => { + await spindlePost(ctx.actor, `${SECRETS}.removeSecret`, { repo: ctx.repo.did, key: secretKey }); +}; + +flow("adding a pipeline secret lists it under pipelines") + .actor("owner") + .repo({ spindle: config.spindleDid }) + .step("pick the secret's name", pickSecret) + + .open(pipelines) + .sees(addSecretLink) + .click(addSecretLink) + .at(newSecret) + .fill(placeholder("SECRET_NAME"), () => secretKey) + .fill(placeholder("very important secret"), "hunter2") + .click(button("Add")) + .step( + "the form finishes on the pipelines page", + landsOn("the secret was not added", (ctx) => `${ctx.repo.path}/settings/pipelines`) + ) + .sees(secretRow) + .step("spindle lists it, made by the owner", async (ctx) => + secretsOnSpindle( + "the new secret", + (keys, who) => keys.includes(secretKey) && who.every((did) => did === ctx.actor.did) + )(ctx) + ) + .reload() + .sees(secretRow) + .step("take the secret away", dropSecret); + +flow("deleting a pipeline secret takes it off the list and off the spindle") + .actor("owner") + .repo({ spindle: config.spindleDid }) + .step("pick the secret's name", pickSecret) + .step("make a secret on the spindle", makeSecret) + + .open(pipelines) + .sees(secretRow) + .sees(deleteSecret) + .click(deleteSecret) + .sees(not(secretRow)) + .step( + "spindle lists no secret", + secretsOnSpindle("no secret", (keys) => keys.length === 0) + ) + .reload() + .sees(not(secretRow)) + .sees(text("No secrets yet")); + +const spindleHost = new URL(config.spindleUrl).host; + +const saveSpindle = (ctx: Ctx) => saveFor(ctx.actor, { knots: [], spindles: [spindleHost] }); + +const spindlePicker = target("the spindle picker", (tab) => + tab.getByRole("button", { name: "Spindle", exact: true }) +); +const spindleOption = (name: string) => + target(`the spindle ${name}`, (tab) => tab.getByRole("menuitem", { name })); + +const manifestSpindle = async (ctx: Ctx) => (await manifestOf(ctx)).spindle as string | undefined; + +flow("choosing a spindle in the pipeline settings keeps it after a reload") + .actor("owner") + .repo() + .step("save a spindle for the account", saveSpindle) + + .open(pipelines) + .sees(spindlePicker, "No spindle") + .click(spindlePicker) + .click(spindleOption(spindleHost)) + .click(saveButton) + .step("the save finishes, which leaves nothing to save", saveFinishes) + .step("the manifest names the spindle", async (ctx) => { + expect(await manifestSpindle(ctx)).toContain(spindleHost); + }) + .reload() + .sees(text(spindleHost)); + +flow("removing a repo's spindle in the pipeline settings keeps it removed after a reload") + .actor("owner") + .repo({ spindle: config.spindleDid }) + + .open(pipelines) + .click(spindlePicker) + .click(spindleOption("No spindle")) + .click(saveButton) + .click(dialogButton("Remove spindle")) + .step("the save finishes, which leaves nothing to save", saveFinishes) + .step("the manifest names no spindle", async (ctx) => { + expect(await manifestSpindle(ctx)).toBeUndefined(); + }) + .reload() + .sees(spindlePicker, "No spindle"); diff --git a/e2e/specs/profile.spec.ts b/e2e/specs/profile.spec.ts new file mode 100644 index 000000000..adaf906ac --- /dev/null +++ b/e2e/specs/profile.spec.ts @@ -0,0 +1,407 @@ +import { expect } from "@playwright/test"; + +import { getOk, pdsCall } from "../harness/api"; +import { config } from "../harness/config"; +import { + bobbin, + button, + type Ctx, + field, + flow, + followId, + heading, + hydrant, + inVouchDialog, + not, + page, + pds, + placeholder, + profileId, + profileSettings, + publicKeyId, + target, + text, + through, + vouchDialog, + vouchId, + where +} from "../harness/flow"; +import { keyOf, makeKey } from "../harness/git"; +import { contentTypeOf, get, stamp, until, xrpc } from "../harness/net"; +import { forgetTheLogin, pdsRecordUrl, picture, recall, remember } from "../harness/shared"; +import { type Profile } from "../harness/lexicons"; + +const avatarSettings = where("the avatar settings", () => "/settings/profile/avatar"); + +const richProfile = (ctx: Ctx) => + ({ + $type: "org.tangled.actor.profile", + description: "before", + pronouns: "she/her", + location: "the knot", + links: ["https://example.com/me"], + pinnedRepositories: [ctx.repo.did] + }) satisfies Profile; + +// the ui can't pin a repo, so the pin has to be written from node +const writeRichProfile = async (ctx: Ctx) => { + const written = await pdsCall(ctx.actor, "com.atproto.repo.putRecord", { + collection: profileId.nsid, + rkey: "self", + record: richProfile(ctx) + }); + await bobbin.indexed(written); +}; + +const pinnedRepo = target("the pinned repo", (tab, ctx) => + tab.getByRole("link", { name: ctx.repo.name, exact: true }).first() +); + +const profileOnPds = async (ctx: Ctx) => + (await getOk(pdsRecordUrl(ctx.actor.did, profileId.nsid, "self"))).value; + +const bioAfter = (ctx: Ctx) => ({ record: { ...richProfile(ctx), description: "after" } }); + +const keepsTheRest = async (ctx: Ctx) => { + const { description, ...rest } = richProfile(ctx); + expect(await profileOnPds(ctx)).toMatchObject({ ...rest, description: "after" }); +}; + +flow("editing the bio on the profile page keeps the pinned repo and the other fields") + .actor("owner") + .repo() + .step("a profile with a pin, pronouns, a location and a link", writeRichProfile) + + .open(page.profile) + .sees(pinnedRepo) + .click(button("Edit")) + .fill(field("Bio"), "after") + .click(button("Save")) + .sends(profileId, pds, bioAfter, "put") + .trace(profileId, through(pds, hydrant, bobbin)) + .step("the pds keeps the pin, the pronouns, the location and the link", keepsTheRest) + .sees(text("after")) + .reload() + .sees(text("after")) + .sees(text("she/her")) + .sees(pinnedRepo); + +const firstSave = target("the first Save", (tab) => + tab.getByRole("button", { name: "Save", exact: true }).first() +); + +flow("editing the bio in profile settings keeps the pinned repo and the other fields") + .actor("owner") + .repo() + .step("a profile with a pin, pronouns, a location and a link", writeRichProfile) + + .open(profileSettings) + .fill(field("Bio"), "after") + .click(firstSave) + .sends(profileId, pds, bioAfter, "put") + .trace(profileId, through(pds, hydrant, bobbin)) + .step("the pds keeps the pin, the pronouns, the location and the link", keepsTheRest) + .reload() + .step("the settings form shows the saved bio", async (ctx) => { + await expect(field("Bio").locate(ctx.page, ctx)).toHaveValue("after"); + }) + .open(page.profile) + .sees(text("after")) + .sees(pinnedRepo); + +// the pages never ask for size=52&format=png, so no cached placeholder can answer it +const servedAvatar = (ctx: Ctx) => + contentTypeOf( + `${config.webUrl}/avatar/${encodeURIComponent(ctx.actor.did)}?size=52&format=png` + ); + +flow("an avatar uploaded in the settings is the picture the avatar service serves") + .actor("owner") + + .open(avatarSettings) + .step("choose the picture", (ctx) => + ctx.page.locator("input[type=file]").setInputFiles(picture) + ) + .click(button("Upload")) + .sends(profileId, pds, { record: { avatar: { mimeType: "image/png" } } }, "put") + .trace(profileId, through(pds, hydrant, bobbin)) + .step("the avatar service serves the picture and not a placeholder", async (ctx) => { + expect(await servedAvatar(ctx)).toBe("image/png"); + }); + +const javascriptLink = target("a link that runs javascript", (tab) => + tab.locator('a[href^="javascript:" i]') +); +const scripted = "javascript:alert(1)"; + +flow("a javascript: link in a profile is not shown as a link") + .actor("owner") + + .open(page.profile) + .click(button("Edit")) + .fill(placeholder("Social link 1"), scripted) + .click(button("Save")) + .sends(profileId, pds, { record: { links: [scripted] } }, "put") + .trace(profileId, through(pds, hydrant, bobbin)) + .sees(not(javascriptLink)) + .reload() + .sees(not(javascriptLink)); + +flow("the profile settings refuse a link that is not http or https and write nothing") + .actor("owner") + + .open(profileSettings) + .fill(field("Link 1"), scripted) + .click(firstSave) + .sees(text("Links must use http or https.")) + .sendsNothing(profileId) + .step("the pds profile has no link", async (ctx) => { + expect((await profileOnPds(ctx)).links).toBeUndefined(); + }); + +const welcome = where("the welcome wizard", () => "/welcome"); + +const next = button("Next"); +const unfollowed = target("the Unfollow that replaced it", (tab) => + tab.getByRole("button", { name: "Unfollow", exact: true }).first() +); +const firstFollow = target("the first Follow on the discover step", (tab) => + tab.getByRole("button", { name: "Follow", exact: true }).first() +); + +const throughTheProfileStep = (name: string) => + flow(name) + .actor("newbie", { login: "ui" }) + .open(welcome) + .sees(heading("Welcome!")) + .step("picks a picture", (ctx) => + ctx.page.locator("input[type=file]").setInputFiles(picture) + ) + .fill(field("Bio"), "i write small tools") + .fill(field("Pronouns"), "she/her") + .fill(field("Website"), "example.com") + .click(next) + .sends( + profileId, + pds, + { + record: { + description: "i write small tools", + pronouns: "she/her", + links: ["https://example.com/"] + } + }, + "put" + ) + .trace(profileId, through(pds, hydrant, bobbin)) + .sees(heading("Verify your emails")); + +throughTheProfileStep("the welcome wizard saves a new account's profile and goes on") + .step("the pds keeps the picture, the words and the link", async (ctx) => { + const value = await profileOnPds(ctx); + expect(value).toMatchObject({ + description: "i write small tools", + pronouns: "she/her", + links: ["https://example.com/"] + }); + expect(value.avatar?.ref?.$link, "the profile has no avatar").toBeTruthy(); + }) + .open(page.profile) + .sees(text("i write small tools")) + .sees(text("she/her")); + +// advance() ignores nextReady, so discover can be skipped on purpose +throughTheProfileStep("the discover step can be skipped without following anyone") + .click(next) + .sees(heading("Discover")) + .click(next) + .sees(heading("Add SSH keys")); + +throughTheProfileStep("following someone on the discover step lets the wizard go on") + .click(next) + .sees(heading("Discover")) + .click(firstFollow) + .sends(followId, pds, { collection: followId.nsid }, "put") + .trace(followId, through(pds, hydrant, bobbin)) + .sees(unfollowed) + .click(next) + .sees(heading("Add SSH keys")); + +const lastStep = button("Finish"); +const wizardKey = { name: "wizard-laptop" }; + +flow("a new account goes through the whole welcome wizard") + .actor("newbie", { login: "ui" }) + .step("the account makes a key to add", makeKey) + .open(welcome) + .sees(heading("Welcome!")) + .step("picks a picture", (ctx) => ctx.page.locator("input[type=file]").setInputFiles(picture)) + .fill(field("Bio"), "i write small tools") + .fill(field("Pronouns"), "she/her") + .fill(field("Website"), "example.com") + .click(next) + .trace(profileId, through(pds, hydrant, bobbin)) + .click(next) + .click(firstFollow) + .trace(followId, through(pds, hydrant, bobbin)) + .sees(unfollowed) + .click(next) + .sees(heading("Add SSH keys")) + .fill(field("Key name"), wizardKey.name) + .fill(field("Public key"), (ctx) => keyOf(ctx).pub) + .click(button("Add key")) + .sends( + publicKeyId, + pds, + (ctx) => ({ record: { name: wizardKey.name, key: keyOf(ctx).pub } }), + "create" + ) + .trace(publicKeyId, through(pds)) + .click(next) + .sees(heading("You're all set!")) + .click(lastStep) + .at(where("the home page", () => "/")); + +// accounts.create already wrote an empty profile, so this takes it back out +const withoutProfile = async (ctx: Ctx) => { + await pdsCall(ctx.actor, "com.atproto.repo.deleteRecord", { + collection: profileId.nsid, + rkey: "self" + }); + // the home page asks bobbin, so wait for bobbin to say the profile is gone + await until("bobbin should have no profile", 15_000, async () => { + const { status } = await get( + xrpc(config.bobbinUrl, "sh.tangled.actor.getProfile", { + actor: `at://${ctx.actor.did}/${profileId.nsid}/self` + }) + ); + return [status === 404, `getProfile answered ${status}`]; + }); +}; + +flow("an account with no profile record is sent to the welcome wizard from the home page") + .actor("blank", { login: "ui" }) + .step("the profile record is taken away", withoutProfile) + .open(where("the home page", () => "/")) + .at(welcome) + .sees(heading("Welcome!")); + +const ownHandle = target("the account's handle", (tab, ctx) => + tab.getByText(ctx.actor.handle, { exact: true }).first() +); + +flow("an account with no profile record can open its own profile page") + .actor("blank", { login: "ui" }) + .step("the profile record is taken away", withoutProfile) + .open(page.profile) + .sees(ownHandle); + +const vouchedBy = (expected: Record | undefined) => async (ctx: Ctx) => { + const { status, body } = await get( + pdsRecordUrl(ctx.actor.did, vouchId.nsid, recall(ctx, "friend").did) + ); + if (expected === undefined) + expect(status, `the pds still has it: ${JSON.stringify(body)}`).toBe(400); + else expect(body.value).toMatchObject(expected); +}; + +const friendsProfile = where("the friend's profile", (ctx) => `/${recall(ctx, "friend").handle}`); +const reasonBox = placeholder("Write a reason..."); + +flow("changing a vouch to a denounce keeps the new reason and the same record") + .actor("friend") + .step("remember the friend", remember("friend")) + .actor("fan") + + .open(friendsProfile) + .click(button("Vouch")) + .click(inVouchDialog("Vouch")) + .fill(reasonBox, "knows their stuff") + .click(inVouchDialog("Save")) + .trace(vouchId, through(pds, hydrant, bobbin)) + .sees(button("Vouched")) + + .click(button("Vouched")) + .click(inVouchDialog("Denounce")) + .fill(reasonBox, "changed my mind") + .click(inVouchDialog("Update")) + .sends( + vouchId, + pds, + (ctx) => ({ + rkey: recall(ctx, "friend").did, + record: { kind: "denounce", reason: "changed my mind" } + }), + "put" + ) + .trace(vouchId, through(pds, hydrant, bobbin)) + .sees(not(vouchDialog)) + .sees(button("Denounced")) + .sees(not(button("Vouched"))) + .step( + "the pds has one vouch record and it is a denounce", + vouchedBy({ kind: "denounce", reason: "changed my mind" }) + ) + .reload() + .sees(button("Denounced")); + +const signup = where("the sign up page", () => "/signup"); +const signupMail = `signup-${stamp()}@e2e.test`; + +flow("signing up with an email says to check the mail") + .actor("guest", { login: "ui" }) + .open(page.profile) + .step("signs out", forgetTheLogin) + .open(signup) + .sees(not(text("Signup is temporarily unavailable"))) + .fill(field("Email"), signupMail) + .step("the sign up button is on", async (ctx) => { + await expect(button("Sign up").locate(ctx.page, ctx)).toBeEnabled(); + }) + .click(button("Sign up")) + .sees(heading("Check your email")) + .sees(text(signupMail)); + +flow("vouching for someone and withdrawing it") + .actor("friend") + .step("remember who is being vouched for", remember("friend")) + .actor("fan") + + .open(friendsProfile) + .click(button("Vouch")) + .click(inVouchDialog("Vouch")) + .fill(placeholder("Write a reason..."), "knows their stuff") + .click(inVouchDialog("Save")) + .sends( + vouchId, + pds, + (ctx) => ({ + rkey: recall(ctx, "friend").did, + record: { kind: "vouch", reason: "knows their stuff" } + }), + "put" + ) + // the dialog waits on bobbin before it closes, so the trigger changes late + .sees(not(button("Vouched"))) + .trace(vouchId, through(pds, hydrant, bobbin)) + .step( + "the pds has the vouch and its reason", + vouchedBy({ kind: "vouch", reason: "knows their stuff" }) + ) + .sees(button("Vouched")) + .reload() + .sees(button("Vouched")) + + .click(button("Vouched")) + .click(inVouchDialog("None")) + .click(inVouchDialog("Update")) + .sends(vouchId, pds, (ctx) => ({ rkey: recall(ctx, "friend").did }), "delete") + .sees(button("Vouched")) + .trace(vouchId, through(pds, hydrant, bobbin)) + // the open dialog has a second Vouch button, so wait for it to close first + .sees(not(vouchDialog)) + .sees(button("Vouch")) + .step("the pds has dropped the vouch", vouchedBy(undefined)) + .reload() + .sees(button("Vouch")) + .sees(not(button("Vouched"))); diff --git a/e2e/specs/pulls.spec.ts b/e2e/specs/pulls.spec.ts new file mode 100644 index 000000000..3848ff437 --- /dev/null +++ b/e2e/specs/pulls.spec.ts @@ -0,0 +1,388 @@ +import { expect } from "@playwright/test"; + +import { comments, editCob } from "../harness/api"; +import { seeded } from "../harness/config"; +import { + bobbin, + button, + commentId, + type Ctx, + field, + hydrant, + knot, + link, + menuItem, + moreActionsOn, + not, + page, + patchId, + pds, + pullDiscussion, + pullId, + record, + refusal, + replacing, + rkeyOf, + subscriptionId, + target, + text, + through, + triage, + unique +} from "../harness/flow"; +import { lsRemote } from "../harness/git"; +import { + branches, + defineLabel, + labelOff, + labelOn, + merges, + onBranches, + pipelineFor, + recall, + remember, + sendsPatch, + stillStored, + subscribesToPull +} from "../harness/shared"; + +const title = unique("add a feature"); + +onBranches("opening a pull request", { ci: true }) + .open(page.newPull(branches)) + .fill(field("Title"), title) + .click(button("Create pull request")) + .sends( + pullId, + knot, + (ctx) => ({ + repo: ctx.repo.did, + record: { + state: "open", + title: { text: title }, + targetBranch: branches.into + } + }), + "create" + ) + .trace(pullId, through(knot, hydrant, bobbin)) + .use(sendsPatch()) + .use(subscribesToPull) + .at(page.pulls) + .sees(link(title)) + .checkpoint("the pull is listed") + .step( + "spindle has a pipeline for the pull", + pipelineFor("pullRequest", (ctx) => ctx.repo.heads[branches.from]) + ) + .open(page.pull(pullId)) + .sees(text("ci.yml")); + +onBranches("closing and reopening a pull request") + .pull(pullId, patchId, branches) + .open(page.pull(pullId)) + .changeState(pullId, "closed", button("Close"), button("Reopen")) + .changeState(pullId, "open", button("Reopen"), button("Close")) + .reload() + .sees(button("Close")); + +// the diff lists a file only once the mirror can read the commits between the branches +const diffedFile = target("the new file in the diff", (tab) => + tab.getByText("/feature.txt").first() +); + +onBranches("the new pull form lists the branch's file in its diff") + .open(page.newPull(branches)) + .sees(diffedFile) + .sees(button("Create pull request")); + +const viewedTitle = unique("a pull to view"); + +onBranches("the pull page shows the pull's title") + .pull(pullId, patchId, { ...branches, title: viewedTitle }) + .open(page.pull(pullId)) + .sees(not(text("Internal Error"))) + .sees(text(viewedTitle)); + +const otherPull = record("org.tangled.track.ticket"); +const otherPatch = record("org.tangled.review.patch"); + +const searchPulls = field("Search pull requests"); + +const pullNeedle = unique("search needle"); +const pullHay = unique("unrelated"); + +const onTwoPulls = (name: string) => + onBranches(name) + .pull(pullId, patchId, { ...branches, title: pullNeedle }) + .pull(otherPull, otherPatch, { ...branches, title: pullHay }); + +onTwoPulls("searching the pull list finds the pull you named") + .open(page.pulls) + .sees(link(pullNeedle)) + .sees(link(pullHay)) + .fill(searchPulls, pullNeedle) + .press("Enter") + .sees(link(pullNeedle)) + .sees(not(link(pullHay))); + +const note = "first thoughts"; +const secondThoughts = "second thoughts"; +const editComment = target("edit comment", (tab) => + tab.getByRole("button", { name: "Edit comment", exact: true }) +); +const editor = target("the comment editor", (tab) => tab.locator('textarea[name="body"]')); + +const commented = (name: string) => + onBranches(name) + .pull(pullId, patchId, branches) + .open(page.pull(pullId)) + .fill(pullDiscussion, note) + .click(button("Send comment")) + .sends( + commentId, + knot, + (ctx) => ({ record: { subject: { uri: ctx.get(pullId).uri } } }), + "create" + ) + .trace(commentId, through(knot, hydrant, bobbin)) + .trace(subscriptionId, through(pds)) + .sees(text(note)); + +commented("commenting on a pull request").reload().sees(text(note)); + +const startEdit = >(flowSoFar: T) => + flowSoFar + .click(editComment) + .fill(editor, secondThoughts) + .click(button("Save")) + .sends( + commentId, + knot, + replacing(commentId, (ctx) => ({ + subject: { uri: ctx.get(pullId).uri }, + body: { text: secondThoughts } + })), + "put" + ) + .trace(commentId, through(knot, hydrant, bobbin)); + +startEdit(commented("editing a comment on a pull request")) + .reload() + .sees(text(secondThoughts)) + .sees(not(text(note))); + +startEdit(commented("an edited pull comment shows without a reload")).sees(text(secondThoughts)); + +const bugTitle = unique("a pull to resubmit"); +const pushedFile = target("the file pushed after the pull was made", (tab) => + tab.getByText("/more.txt").first() +); +const version = (label: string) => + target(`the version ${label}`, (tab) => tab.getByText(label, { exact: true }).first()); +onBranches("resubmitting a pull request after pushing to its source branch", { ci: true }) + .pull(pullId, patchId, { ...branches, title: bugTitle }) + .push({ "e2e/more.txt": "more\n" }, { branch: branches.from }) + .mirrored() + .open(page.pull(pullId)) + .sees(not(pushedFile)) + .sees(button("Resubmit")) + .click(button("Resubmit")) + .sends(patchId, knot, (ctx) => ({ record: { ticket: rkeyOf(ctx, pullId) } }), "create") + .trace(patchId, through(knot, hydrant, bobbin)) + .reload() + .sees(pushedFile) + .sees(version("v1")) + .sees(version("v0")) + .checkpoint("the resubmitted round shows in the diff") + .step( + "spindle has a pipeline for the new commit", + pipelineFor("pullRequest", (ctx) => ctx.repo.heads[branches.from]) + ); + +const moreActions = moreActionsOn("pull request"); +const editPull = menuItem("Edit"); + +const strangerTitle = "a pull from someone who can't contribute"; + +onBranches( + "a stranger's pull is refused when only collaborators may contribute and the page says so" +) + .policy("collaborators") + + .actor(seeded.charlie) + .open(page.newPull(branches)) + .fill(field("Title"), strangerTitle) + .click(button("Create pull request")) + .refused(pullId, 403) + .sees(refusal("where contribution policy is collaborators")) + .open(page.pulls) + .sees(not(link(strangerTitle))); + +const otherBranch = "other"; + +// keep it in its own folder, a shared file makes the knot refuse the merge +const withOtherBranch = (name: string) => + onBranches(name) + .push({ "other/other.txt": "other\n" }, { branch: otherBranch, base: branches.into }) + .mirrored() + .pull(pullId, patchId, branches); + +const targetBranchPicker = target("the target branch picker", (tab) => + tab.getByRole("button", { name: "Target branch", exact: true }) +); +const otherOption = target("the other branch in the picker", (tab) => + tab.getByRole("option", { name: otherBranch, exact: true }) +); +const targeting = (branch: string) => + target(`the target branch ${branch}`, (tab) => + tab.getByText(branch, { exact: true }).filter({ visible: true }).first() + ); + +withOtherBranch("retargeting a pull request to another branch") + .open(page.pull(pullId)) + .sees(targeting(branches.into)) + .click(moreActions) + .click(editPull) + .click(targetBranchPicker) + .click(otherOption) + .click(button("Save")) + .sends(pullId, knot, replacing(pullId, { targetBranch: otherBranch }), "put") + .trace(pullId, through(knot, hydrant, bobbin)) + .sees(targeting(otherBranch)) + .step("the knot's copy targets the other branch", async (ctx) => + expect((await knot.has(ctx.get(pullId))).targetBranch).toBe(otherBranch) + ) + .reload() + .sees(targeting(otherBranch)) + .sees(not(targeting(branches.into))); + +// the page won't see this, so the knot's copy is edited directly +const retargetOnKnot = async (ctx: Ctx) => { + const reply = await editCob(ctx.actor, ctx.get(pullId).uri, "retarget", (stored) => ({ + ...stored, + targetBranch: otherBranch + })); + expect(reply.status, `the retarget answered ${JSON.stringify(reply.body)}`).toBe(200); + ctx.set(pullId, { uri: reply.body.uri, cid: reply.body.cid }); +}; + +withOtherBranch("a retargeted pull merges into its new branch and leaves the old one alone") + .step("the pull is retargeted", retargetOnKnot) + .open(page.pull(pullId)) + .sees(targeting(otherBranch)) + .use(merges) + .knotStored(pullId, "completed") + .step("only the other branch moved", async (ctx) => { + const heads = await lsRemote(ctx.actor, ctx.repo.did, "refs/heads/"); + expect(heads[`refs/heads/${branches.into}`]).toBe(ctx.repo.heads[branches.into]); + expect(heads[`refs/heads/${otherBranch}`]).not.toBe(ctx.repo.heads[otherBranch]); + }); + +onBranches("putting a label on a pull request and taking it off") + .pull(pullId, patchId, branches) + .step("define the label", defineLabel(triage)) + + .open(page.pull(pullId)) + .use(labelOn(pullId, "pull")) + .sees(targeting(branches.into)) + .use(labelOff(pullId, "pull")); + +const reviewNote = "looks fine to me"; +const peerSays = async (ctx: Ctx) => { + const reply = await comments.add(ctx.actor, ctx.repo.did, ctx.get(pullId), reviewNote); + expect(reply.status, `the review comment answered ${JSON.stringify(reply.body)}`).toBe(200); + await bobbin.indexed({ uri: reply.body.uri, cid: reply.body.cid }); +}; + +const assigneePicker = target("the assignee picker", (tab) => + tab.getByRole("button", { name: "Assignee", exact: true }) +); +const peerOption = target("the peer in the picker", (tab, ctx) => + tab.getByRole("option").filter({ hasText: recall(ctx, "peer").handle }) +); +const assignedTo = target("the assignee in the pull's header", (tab, ctx) => + tab.getByText("assigned to").locator("..").getByText(recall(ctx, "peer").handle) +); + +onBranches("assigning a pull request to a peer keeps the assignee after a reload") + .pull(pullId, patchId, branches) + .actor("peer") + .step("remember the peer", remember("peer")) + .member() + .step("the peer comments on the pull", peerSays) + + .actor("owner") + .open(page.pull(pullId)) + .click(moreActions) + .click(editPull) + .click(assigneePicker) + .click(peerOption) + .click(button("Save")) + .sends(pullId, knot, replacing(pullId), "put") + .trace(pullId, through(knot, hydrant, bobbin)) + .sees(assignedTo) + .reload() + .sees(assignedTo); + +const afterWords = "a note on the second round"; +const inRound = (round: string, words: string) => + target(`${words} under ${round}`, (tab) => + tab + .locator("aside div.space-y-xs") + .filter({ has: tab.getByText(round, { exact: true }) }) + .getByText(words) + ); + +onBranches("a comment written after a resubmit files under the new round") + .pull(pullId, patchId, branches) + .push({ "e2e/more.txt": "more\n" }, { branch: branches.from }) + .mirrored() + .open(page.pull(pullId)) + .sees(button("Resubmit")) + .click(button("Resubmit")) + .use(sendsPatch()) + .sees(text("v1")) + + .fill(pullDiscussion, afterWords) + .click(button("Send comment")) + .sends( + commentId, + knot, + (ctx) => ({ record: { subject: { uri: ctx.get(pullId).uri } } }), + "create" + ) + .trace(commentId, through(knot, hydrant, bobbin)) + .trace(subscriptionId, through(pds)) + .sees(inRound("v1", afterWords)) + .sees(not(inRound("v0", afterWords))) + .reload() + .sees(inRound("v1", afterWords)) + .sees(not(inRound("v0", afterWords))); + +const edited = "same here, again"; + +const cancel = button("Cancel"); + +onBranches("cancelling a pull comment edit leaves the comment as it was") + .pull(pullId, patchId, branches) + .open(page.pull(pullId)) + .fill(pullDiscussion, note) + .click(button("Send comment")) + .trace(commentId, through(knot, hydrant, bobbin)) + .trace(subscriptionId, through(pds)) + .sees(text(note)) + + .click(editComment) + .fill(editor, edited) + .click(cancel) + .sendsNothing(commentId) + .sees(text(note)) + .sees(not(text(edited))) + .reload() + .sees(text(note)) + .sees(not(text(edited))) + .step( + "the knot and hydrant still have the first version", + stillStored(commentId, (value) => value.body.text, note) + ); diff --git a/e2e/specs/repo-settings.spec.ts b/e2e/specs/repo-settings.spec.ts new file mode 100644 index 000000000..bde55b806 --- /dev/null +++ b/e2e/specs/repo-settings.spec.ts @@ -0,0 +1,328 @@ +import { expect, test } from "@playwright/test"; + +import { pdsCall } from "../harness/api"; +import { type Account, config } from "../harness/config"; +import { + alerts, + bobbin, + button, + type Ctx, + declarationId, + exactLink, + flow, + hydrant, + nameField, + not, + page, + pds, + placeholder, + record, + refusal, + renameForm, + saveButton, + settingsAt, + target, + text, + topic, + topicChip, + unique, + unsaved, + where +} from "../harness/flow"; +import { headOf } from "../harness/git"; +import { get, until, xrpc } from "../harness/net"; +import { base, finished, manifestOf, saveFinishes, topicsOnKnot } from "../harness/shared"; +import { type LegacyRepo } from "../harness/lexicons"; + +let renamedTo = ""; +const renamedPath = (ctx: Ctx) => `/${ctx.actor.handle}/${renamedTo}`; +const repoName = (name: () => string) => + target("the repo's name in its header", (tab) => + tab.getByRole("link", { name: name(), exact: true }).first() + ); + +flow("renaming a repo made today moves it to the new name") + .actor("owner") + .repo() + .step("pick the new name", async (ctx) => { + renamedTo = `${ctx.repo.name}-renamed`; + }) + + .open(renameForm) + .fill(nameField, () => renamedTo) + .click(button("Rename")) + .step("the rename finishes on the new settings page", async (ctx) => + finished( + ctx, + "the rename did not finish", + ctx.page.waitForURL(`${config.webUrl}${renamedPath(ctx)}/settings`, { timeout: 5000 }) + ) + ) + .open(where("the renamed repo", renamedPath)) + .sees(repoName(() => renamedTo)) + .step("the old name is gone", async (ctx) => { + const old = await ctx.page.goto(`${config.webUrl}${ctx.repo.path}`); + expect(old?.status()).toBe(404); + }) + .step("the knot's manifest names the new repo", async (ctx) => { + expect((await manifestOf(ctx)).declaration).toMatchObject({ slug: renamedTo }); + }) + .step("no service keeps the old declaration", async (ctx) => { + const old = ctx.get(declarationId); + await Promise.all([pds, hydrant, bobbin].map((hop) => hop.absent(old))); + }); + +const blurb = unique("does one thing well"); + +flow("saving a description in the repo settings shows it on the repo page") + .actor("owner") + .repo() + + .open(page.settings) + .fill(placeholder("What this project is about"), blurb) + .click(saveButton) + .step("the save finishes, which leaves nothing to save", saveFinishes) + .step("the knot's manifest carries it", async (ctx) => { + expect((await manifestOf(ctx)).description).toBe(blurb); + }) + .open(page.repo) + .reload() + .sees(text(blurb)); + +const sites = settingsAt("sites"); + +const branchPicker = target("the default branch picker", (tab) => + tab.getByRole("button", { name: "Default branch", exact: true }) +); +const branchOption = (name: string) => + target(`the option ${name}`, (tab) => tab.getByRole("option", { name, exact: true })); + +flow("a branch pushed over git is offered in the default branch picker") + .actor("owner") + .repo() + .push(base) + .push({ "dev-only.txt": "only here\n" }, { branch: "feature/picker", base: "main" }) + .mirrored() + + .open(page.settings) + .sees(branchPicker, "main") + .click(branchPicker) + .sees(branchOption("main")) + .sees(branchOption("feature/picker")); + +// the page makes the knot call itself, so the request is watched, not made +let defaultBranchCall: Promise<{ postDataJSON: () => any }> | undefined; +const watchDefaultBranchCall = async (ctx: Ctx) => { + defaultBranchCall = ctx.page.waitForRequest( + (request) => + request.method() === "POST" && + request.url().includes("sh.tangled.repo.setDefaultBranch") + ); +}; + +flow("changing the default branch in the repo settings moves the knot's head") + .actor("owner") + .repo() + .push(base) + .push({ "dev-only.txt": "only here\n" }, { branch: "develop", base: "main" }) + .mirrored() + + .open(page.settings) + .click(branchPicker) + .click(branchOption("develop")) + .step("listen for the knot call", watchDefaultBranchCall) + .click(saveButton) + .step("the page asked the knot to make develop the default", async (ctx) => { + expect((await defaultBranchCall!).postDataJSON()).toMatchObject({ + repo: ctx.repo.did, + defaultBranch: "develop" + }); + }) + // save stays lit until the mirror catches up, so only check for an error + .step("the page shows no error", async (ctx) => { + await expect(alerts.locate(ctx.page, ctx)).toHaveCount(0); + }) + .step("the knot's head is develop", async (ctx) => { + await until(`the knot should point HEAD at develop`, 20_000, async () => { + const head = await headOf(ctx.actor, ctx.repo.did); + return [head === "develop", `HEAD is ${head}`]; + }); + }) + .step("bobbin reports develop as the default", async (ctx) => { + await until( + `bobbin should report develop as the default of ${ctx.repo.did}`, + 60_000, + async () => { + const { status, body } = await get( + xrpc(config.bobbinUrl, "org.tangled.temp.git.listBranches", { + repo: ctx.repo.did, + limit: "1" + }) + ); + return [ + status === 200 && body.defaultBranch === "develop", + `it said ${status} ${body?.defaultBranch}` + ]; + } + ); + }) + .reload() + .sees(branchPicker, "develop") + .open(page.repo) + .sees(exactLink("dev-only.txt")); + +const liveTag = text("Live at"); +const siteHeading = target("the Sites heading", (tab) => + tab.getByRole("heading", { name: "Sites", exact: true }) +); +const directory = placeholder("/"); +const directoryIs = (want: string) => async (ctx: Ctx) => { + await expect(directory.locate(ctx.page, ctx)).toHaveValue(want); +}; +const disableSwitch = target("the disable site switch", (tab) => + tab.getByRole("switch", { name: "Disable site", exact: true }) +); + +flow("a repo that never published a site doesn't say it is live") + .actor("owner") + .repo() + .open(sites) + .sees(siteHeading) + .sees(not(liveTag)) + .sees(text("No deploys yet.")); + +flow("publishing a site from a directory keeps the directory after a reload") + .actor("owner") + .repo() + .push(base) + .open(sites) + .fill(directory, "/docs") + .click(saveButton) + .step("the save bar has nothing left to save", saveFinishes) + .reload() + .step("the directory is still /docs", directoryIs("/docs")); + +flow("disabling a site takes it off the air and stays off after a reload") + .actor("owner") + .repo() + .push(base) + .open(sites) + .sees(disableSwitch) + .click(disableSwitch) + .click(saveButton) + .step("the save bar has nothing left to save", saveFinishes) + .reload() + .step("the switch is still on", async (ctx) => { + await expect(disableSwitch.locate(ctx.page, ctx)).toBeChecked(); + }) + .sees(not(liveTag)); + +let firstName = ""; + +flow("renaming a repo to a name the owner already has is refused") + .actor("owner") + .repo() + .step("remember the first repo", async (ctx) => { + firstName = ctx.repo.name; + }) + .repo() + + .open(renameForm) + .fill(nameField, () => firstName) + .click(button("Rename")) + .step("the form says the name is taken", (ctx) => + finished( + ctx, + "the form did not refuse the name", + expect(refusal(/already/i).locate(ctx.page, ctx)).toBeVisible() + ) + ) + .at(renameForm); + +// the knot won't delete a repo while this record exists, so afterEach clears it +const legacyRecords: { who: Account; name: string }[] = []; +test.afterEach(async () => { + for (const { who, name } of legacyRecords.splice(0)) { + await pdsCall(who, "com.atproto.repo.deleteRecord", { + collection: "sh.tangled.repo", + rkey: name + }).catch(() => {}); + } +}); + +const noTopics = async (ctx: Ctx) => expect(await topicsOnKnot(ctx)).toEqual([]); + +const savedTopic = async (ctx: Ctx) => { + legacyRecords.push({ who: ctx.actor, name: ctx.repo.name }); + await pdsCall(ctx.actor, "com.atproto.repo.putRecord", { + collection: "sh.tangled.repo", + rkey: ctx.repo.name, + record: { + $type: "sh.tangled.repo", + name: ctx.repo.name, + knot: new URL(config.knotUrl).host, + topics: [topic], + createdAt: new Date().toISOString() + } satisfies LegacyRepo + }); +}; + +flow("removing a topic that was already saved") + .actor("owner") + .repo() + .step("the account has the repo saved with a topic", savedTopic) + + .open(page.settings) + .sees(topicChip) + .click(button(`Remove ${topic}`)) + .click(saveButton) + .sees(not(unsaved)) + .step("the knot's manifest has no topics", noTopics) + .reload() + .sees(not(topicChip)); + +const resetSettings = target("the reset button", (tab) => + tab.getByRole("button", { name: "Reset", exact: true }).first() +); + +flow("adding a topic in the settings and removing it again") + .actor("owner") + .repo() + + .open(page.settings) + .fill(placeholder("Add a topic"), topic) + .press("Enter") + .sees(topicChip) + .click(saveButton) + .sees(not(unsaved)) + .step("the knot's manifest lists the topic", async (ctx) => + expect(await topicsOnKnot(ctx)).toEqual([topic]) + ) + .reload() + .sees(topicChip) + + .click(button(`Remove ${topic}`)) + .click(saveButton) + .sees(not(unsaved)) + .step("the knot's manifest has no topics", noTopics) + .reload() + .sees(not(topicChip)); + +const legacyRepo = record("sh.tangled.repo"); + +flow("resetting an unsaved topic in the settings writes nothing") + .actor("owner") + .repo() + + .open(page.settings) + .fill(placeholder("Add a topic"), topic) + .press("Enter") + .sees(topicChip) + .sees(unsaved) + .click(resetSettings) + .sendsNothing(legacyRepo) + .sees(not(topicChip)) + .sees(not(unsaved)) + .reload() + .sees(not(topicChip)) + .step("the knot's manifest has no topics", noTopics); diff --git a/e2e/specs/repos.spec.ts b/e2e/specs/repos.spec.ts new file mode 100644 index 000000000..834cfdf63 --- /dev/null +++ b/e2e/specs/repos.spec.ts @@ -0,0 +1,393 @@ +import { expect, test } from "@playwright/test"; + +import { knotRecord } from "../harness/api"; +import { config } from "../harness/config"; +import { + bobbin, + button, + type Ctx, + declarationId, + dialogButton, + exactLink, + field, + flow, + git, + hydrant, + knot, + knotCopy, + manifestId, + nameField, + not, + openDelete, + page, + pds, + placeholder, + refsId, + refusal, + target, + text, + through, + where +} from "../harness/flow"; +import { lsRemote, push } from "../harness/git"; +import { partsOf, selfUri, stamp, until } from "../harness/net"; +import { base, manifestOf, mirrorLists, recall, remember } from "../harness/shared"; + +const files = { "README.md": "hello from e2e\n" }; + +flow("forking a repo") + .actor("owner") + .repo() + .push(files) + + .actor("forker") + .fork() + .follow(manifestId, through(knotCopy, hydrant, bobbin)) + .step("the manifest names the upstream", async (ctx) => { + const value = await knot.has(ctx.get(manifestId)); + expect(value.source).toMatchObject({ did: ctx.upstream.did }); + }) + .step("the fork starts from the upstream's commit", async (ctx) => { + expect(await lsRemote(ctx.actor, ctx.repo.did)).toMatchObject({ + "refs/heads/main": ctx.pushed.sha + }); + }); + +flow("pushing to a fork") + .actor("owner") + .repo() + .push(files) + + .actor("forker") + .fork() + .push({ "fork.txt": "only in the fork\n" }) + .follow(refsId, through(git, knotCopy, hydrant, bobbin)) + .step("the fork moved and the upstream didn't", async (ctx) => { + expect((await lsRemote(ctx.actor, ctx.repo.did))["refs/heads/main"]).toBe(ctx.pushed.sha); + expect((await lsRemote(ctx.actor, ctx.upstream.did))["refs/heads/main"]).toBe( + ctx.upstream.heads.main + ); + }); + +// one flow at a time per worker, so a module-level let is safe +let made = ""; + +flow("creating a repo from the new repository form") + .actor("owner") + .member() + .step("pick a name", async () => { + made = `e2e-${stamp()}`; + }) + + .open(page.newRepo) + .fill(field("Repository name"), () => made) + .fill(placeholder("A brief description of your project..."), "made by the form") + .click(button("Create repository")) + .traceNewRepo(() => made, through(pds, hydrant, bobbin)) + .follow(manifestId, through(knotCopy, hydrant, bobbin)) + .step("the manifest names the declaration and carries the description", async (ctx) => { + expect(await manifestOf(ctx)).toMatchObject({ + declaration: { owner: ctx.actor.did, slug: made }, + description: "made by the form" + }); + }) + .at(page.repo) + .reload() + .sees( + target("the repo's name", (tab, ctx) => + tab.getByRole("link", { name: ctx.repo.name, exact: true }).first() + ) + ); + +flow("a name the owner already has is refused by the form") + .actor("owner") + .repo() + + .open(page.newRepo) + .fill(field("Repository name"), (ctx) => ctx.repo.name) + .click(button("Create repository")) + .sees( + target("the duplicate name error", (tab, ctx) => + tab.getByText( + `A repository named "${ctx.repo.name}" already exists in your account. Choose another name.` + ) + ) + ) + .at(page.newRepo); + +const typedName = target("the name field in the delete dialog", (tab, ctx) => + tab.getByRole("dialog").getByPlaceholder(ctx.repo.name) +); + +flow("the delete dialog forgets a name you typed and cancelled") + .actor("owner") + .repo() + + .open(page.settings) + .click(openDelete) + .fill(typedName, (ctx) => ctx.repo.name) + .click(dialogButton("Cancel")) + .click(openDelete) + .step("the name field is empty again", async (ctx) => { + await expect(typedName.locate(ctx.page, ctx)).toHaveValue(""); + }) + .step("the delete button is disabled again", async (ctx) => { + await expect(dialogButton("Delete repository").locate(ctx.page, ctx)).toBeDisabled(); + }); + +flow("a repo nobody pushed to says it is empty") + .actor("owner") + .repo() + .mirrored() + .open(page.repo) + .sees(text("This is an empty repository.")); + +const confirm = target("confirm delete", (tab) => + tab.getByRole("dialog").getByRole("button", { name: "Delete repository" }) +); + +flow("deleting a repo") + .actor("owner") + .repo() + + .open(page.settings) + .click(button("Delete repository")) + .fill(nameField, (ctx) => ctx.repo.name) + .click(confirm) + .sends(declarationId, pds, (ctx) => ({ rkey: ctx.repo.name }), "delete") + .trace(declarationId, through(pds, hydrant, bobbin)) + .step("the knot has dropped the repo", async (ctx) => + expect(await knotRecord(selfUri(ctx.repo.did, manifestId.nsid))).toBeUndefined() + ) + .at(page.profile); + +const forkForm = where("the fork form", (ctx) => `${ctx.repo.path}/fork`); +const forkLink = exactLink("Fork"); +let upstreamOfFork = { did: "", handle: "", name: "" }; +const noteUpstream = async (ctx: Ctx) => { + upstreamOfFork = { did: ctx.repo.did, handle: ctx.actor.handle, name: ctx.repo.name }; +}; + +flow("forking a repo from its Fork button") + .actor("owner") + .repo() + .push(base) + .step("note the upstream", noteUpstream) + .step("remember the owner", remember("owner")) + + .actor("forker") + .member() + .open(page.repo) + .click(forkLink) + .at(forkForm) + .click(button("Fork repository")) + .traceNewRepo((ctx) => ctx.repo.name, through(pds, hydrant, bobbin)) + .follow(manifestId, through(knotCopy, hydrant, bobbin)) + .step("the manifest names the upstream", async (ctx) => { + expect((await manifestOf(ctx)).source).toMatchObject({ did: upstreamOfFork.did }); + }) + .step("the fork starts from the upstream's commit", async (ctx) => { + expect(await lsRemote(ctx.actor, ctx.repo.did)).toMatchObject({ + "refs/heads/main": (await lsRemote(recall(ctx, "owner"), upstreamOfFork.did))[ + "refs/heads/main" + ] + }); + }) + .at(page.repo) + .reload() + .sees(text("forked from")) + .sees(exactLink(() => `${upstreamOfFork.handle}/${upstreamOfFork.name}`)); + +const syncFork = button("Sync fork"); +let upstreamHead = ""; + +flow("syncing a fork brings in what the upstream pushed since") + .actor("owner") + .repo() + .push(base) + .step("note the upstream", noteUpstream) + .step("remember the owner", remember("owner")) + + .actor("forker") + .fork() + .step("the upstream moves on", async (ctx) => { + upstreamHead = await push( + recall(ctx, "owner"), + upstreamOfFork.did, + { "later.txt": "pushed after the fork\n" }, + { branch: "main" } + ); + }) + .open(page.repo) + .sees(syncFork) + .click(syncFork) + .step("the fork's main is the upstream's new commit", async (ctx) => { + await until(`the fork should be at ${upstreamHead}`, 20_000, async () => { + const heads = await lsRemote(ctx.actor, ctx.repo.did, "refs/heads/"); + return [ + heads["refs/heads/main"] === upstreamHead, + `it is at ${heads["refs/heads/main"]}` + ]; + }); + }) + .reload() + .sees(not(syncFork)); + +const migrate = where("the migrate page", () => "/repo/migrate"); +const stubRepo = "https://github.tngl.boltless.dev/octostub/fresh.git"; +const gitUrlMethod = target("the Git URL method", (tab) => + tab.getByRole("button", { name: /Git URL/ }) +); +const gitUrlField = target("the git repository url field", (tab) => + tab.getByLabel("Git repository URL") +); +const importName = target("the name field of the import", (tab) => + tab.getByLabel("Name", { exact: true }) +); +const fetchDetails = button("Fetch repository details"); +const startMigration = button("Migrate repository"); + +// this page polls forever, so .open() would never go idle +const goToMigrate = async (ctx: Ctx) => { + await ctx.page.goto(migrate.path(ctx), { waitUntil: "load" }); +}; + +let imported = ""; +const pickImportName = async () => { + imported = `e2e-${stamp()}`; +}; + +for (const [what, url] of [ + ["a plain http git url", "http://example.com/some/repository.git"], + ["a local address", "https://127.0.0.1/some/repository.git"] +]) { + flow(`the import form refuses ${what}`) + .actor("owner") + .member() + .open(page.profile) + .step("open the migrate page", goToMigrate) + .click(gitUrlMethod) + .fill(gitUrlField, url) + .click(fetchDetails) + .sees( + refusal( + "Use a public HTTPS URL without credentials, query, fragment, or local address." + ) + ); +} + +// the first migration's grant goes through the pds login page +const grantTheMigrator = async (ctx: Ctx) => { + await ctx.page.waitForURL((url) => url.host.startsWith("pds.")); + await ctx.page.locator("input[type=password]").fill(config.password); + await ctx.page.getByRole("button", { name: "Sign in", exact: true }).click(); + await ctx.page.getByRole("button", { name: "Authorize", exact: true }).click(); + await ctx.page.waitForURL((url) => url.origin === config.webUrl); +}; + +const fetched = text("Default branch on the remote"); + +flow("coming back from the migrator grant shows the git url form again") + .actor("owner") + .member() + .open(page.profile) + .step("open the migrate page", goToMigrate) + .click(gitUrlMethod) + .fill(gitUrlField, stubRepo) + .click(fetchDetails) + .sees(fetched) + .click(startMigration) + .step("grant the migrator access", grantTheMigrator) + .sees(gitUrlField); + +const mirrorHas = (branch: string) => mirrorLists("Branches", branch, true); + +const repoOnBobbin = (name: string) => async (ctx: Ctx) => { + const found = await until(`bobbin should know the imported repo ${name}`, 60_000, async () => { + const { status, body } = await bobbin.repoByName(ctx.actor.did, name); + return [status === 200, `getRepoByName answered ${status}`, body]; + }); + // the uri authority of a manifest is the repo did + const did = partsOf(found.uri).repo; + ctx.adoptRepo({ did, name }); +}; + +const stubBranchOnKnot = async (ctx: Ctx) => + until(`the knot should list main on ${ctx.repo.did}`, 60_000, async () => { + const heads = await lsRemote(ctx.actor, ctx.repo.did, "refs/heads/").catch(() => ({})); + return [Object.keys(heads).includes("refs/heads/main"), `it lists ${Object.keys(heads)}`]; + }); + +// the page is the only place a failed migration says why +const migrationFinishes = async (ctx: Ctx) => { + test.setTimeout(240_000); + await until(`the migration of ${imported} should finish`, 120_000, async () => { + const said = (await ctx.page.locator("[aria-live=polite]").allInnerTexts()) + .join(" | ") + .replace(/\s+/g, " "); + if (/Failed/.test(said)) + throw new Error(`the migration of ${imported} failed, the page said: ${said}`); + return [ + (await exactLink(imported).locate(ctx.page, ctx).count()) > 0, + `the page said: ${said}` + ]; + }); +}; + +flow("importing a repo from a git url brings its files over") + .actor("owner") + .member() + .open(page.profile) + .step("pick a name", pickImportName) + .step("open the migrate page", goToMigrate) + .click(gitUrlMethod) + .fill(gitUrlField, stubRepo) + .click(fetchDetails) + .sees(fetched) + .fill(importName, () => imported) + .click(startMigration) + .step("grant the migrator access", grantTheMigrator) + .click(gitUrlMethod) + .sees(startMigration) + .click(startMigration) + .sees(text("Repository migration has successfully started")) + .step("the migration finishes", migrationFinishes) + .step("bobbin knows the new repo", async (ctx) => repoOnBobbin(imported)(ctx)) + .step("the knot has the stub's branch", stubBranchOnKnot) + .step("bobbin's mirror lists it too", mirrorHas("main")) + .open(page.repo) + .sees(exactLink("README.md")) + .sees(exactLink("src")); + +const connectGitHub = target("the connect to github link", (tab) => + tab.getByRole("link", { name: "Connect to your GitHub account" }) +); +const authorizeMigrator = button("Authorize the migrator"); +const pickStubRepo = target("the checkbox of the stub's hello repo", (tab) => + tab.getByRole("checkbox", { name: "Select octostub/hello" }) +); +const migrateOne = button("Migrate 1 repo"); + +flow("importing a repo from github brings its files over") + .actor("owner") + .member() + .open(page.profile) + .step("the repo to bring over", async () => { + imported = "hello"; + }) + .step("open the migrate page", goToMigrate) + .click(connectGitHub) + .sees(text("Connected")) + .click(authorizeMigrator) + .step("grant the migrator access", grantTheMigrator) + .click(pickStubRepo) + .click(migrateOne) + .sees(text("Repository migration has successfully started")) + .checkpoint("the migration started") + .step("the migration finishes", migrationFinishes) + .step("bobbin knows the new repo", async (ctx) => repoOnBobbin(imported)(ctx)) + .step("the knot has the stub's branch", stubBranchOnKnot) + .step("bobbin's mirror lists it too", mirrorHas("main")) + .open(page.repo) + .sees(exactLink("README.md")) + .sees(exactLink("hello.txt")); diff --git a/e2e/specs/search.spec.ts b/e2e/specs/search.spec.ts new file mode 100644 index 000000000..0f2a575dc --- /dev/null +++ b/e2e/specs/search.spec.ts @@ -0,0 +1,107 @@ +import { + bobbin, + type Ctx, + exactLink, + flow, + git, + hydrant, + knotCopy, + link, + manifestId, + not, + refsId, + target, + text, + through, + where +} from "../harness/flow"; +import { stamp, until } from "../harness/net"; + +const reposOf = (query: string) => + where( + `the actor's repos tab${query && ` searched for ${query}`}`, + (ctx) => `/${ctx.actor.handle}?tab=repos${query && `&q=${encodeURIComponent(query)}`}` + ); + +let firstRepo = ""; + +const searchBox = target("the repo search", (tab) => tab.getByLabel("Search repositories")); +const searchedFor = (name: (ctx: Ctx) => string) => + where("the repos tab searched for the new repo", (ctx) => reposOf(name(ctx)).path(ctx)); + +const twoRepos = (name: string) => + flow(name) + .actor("owner") + .repo() + .step("remember the first repo", async (ctx) => { + firstRepo = ctx.repo.name; + }) + .repo() + .follow(manifestId, through(knotCopy, hydrant, bobbin)); + +twoRepos("a profile's repo search finds a repo made today") + .open(searchedFor((ctx) => ctx.repo.name)) + .sees(exactLink((ctx) => ctx.repo.name)) + .sees(not(exactLink(() => firstRepo))) + .open(reposOf("no-such-repo")) + .sees(text('No repositories match "no-such-repo".')); + +twoRepos("typing in the profile's repo search narrows the list") + .open(reposOf("")) + .sees(exactLink((ctx) => ctx.repo.name)) + .sees(exactLink(() => firstRepo)) + .fill(searchBox, (ctx) => ctx.repo.name) + .press("Enter") + .at(searchedFor((ctx) => ctx.repo.name)) + .sees(exactLink((ctx) => ctx.repo.name)) + .sees(not(exactLink(() => firstRepo))); + +twoRepos("clearing the profile's repo search brings the whole list back") + .open(searchedFor((ctx) => ctx.repo.name)) + .sees(not(exactLink(() => firstRepo))) + .click(link("Clear search")) + .at(reposOf("")) + .sees(exactLink((ctx) => ctx.repo.name)) + .sees(exactLink(() => firstRepo)); + +const topbarSearch = target("the top bar search", (tab) => + tab.getByRole("searchbox", { name: "Search", exact: true }) +); +const repoResult = target("the repo in the results", (tab, ctx) => + tab.getByRole("link", { name: ctx.repo.path.slice(1), exact: true }) +); + +flow("searching the top bar for a repo by name finds the repo") + .actor("owner") + .repo() + + .open(where("the home page", () => "/")) + .fill(topbarSearch, (ctx) => ctx.repo.name) + .press("Enter") + .at(where("the search page", (ctx) => `/search?q=${ctx.repo.name}`)) + .sees(repoResult); + +// one word per worker, so only this flow's repo can match it +const word = `zq${stamp()}`; +const codeSearch = where( + "the code search", + () => `/search?type=code&q=${encodeURIComponent(word)}` +); + +// the index builds after a push, so ask again until it knows the word +const searchUntilFound = async (ctx: Ctx) => { + await until(`code search should find ${word}`, 60_000, async () => { + await ctx.page.goto(codeSearch.path(ctx), { waitUntil: "networkidle" }); + const found = await repoResult.locate(ctx.page, ctx).isVisible({ timeout: 3000 }); + return [found, (await ctx.page.getByRole("main").innerText()).slice(0, 200)]; + }); +}; + +flow("code search finds a word that was pushed to a repo") + .actor("owner") + .repo() + .push({ "needle.txt": `${word}\n` }) + .follow(refsId, through(git, knotCopy, hydrant, bobbin)) + .open(where("the home page", () => "/")) + .step("search for it until the index has it", searchUntilFound) + .sees(repoResult); diff --git a/e2e/specs/session.spec.ts b/e2e/specs/session.spec.ts new file mode 100644 index 000000000..2a71904da --- /dev/null +++ b/e2e/specs/session.spec.ts @@ -0,0 +1,304 @@ +import { type BrowserContext, expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; + +import { type Account, authFile, config, seeded } from "../harness/config"; +import { + bobbin, + button, + type Ctx, + exactLink, + flow, + heading, + hydrant, + knots, + loginThen, + menuItem, + page, + pds, + starId, + target, + text, + through, + where +} from "../harness/flow"; +import { parseBody, stamp } from "../harness/net"; +import { signInHere } from "../harness/signin"; +import { forgetTheLogin, recall, remember } from "../harness/shared"; + +const menuOf = (who: (ctx: Ctx) => Account) => + target("the account menu", (tab, ctx) => + tab.getByRole("banner").getByRole("button", { name: who(ctx).handle }) + ); +const actorMenu = menuOf((ctx) => ctx.actor); +const logout = menuItem("Logout"); + +const otherOf = (ctx: Ctx) => recall(ctx, "other"); + +const signInFromHere = (who: (ctx: Ctx) => Account) => (ctx: Ctx) => signInHere(ctx.page, who(ctx)); +const signInAsActor = signInFromHere((ctx) => ctx.actor); + +const browserState = (ctx: Ctx) => + ctx.page.evaluate(() => ({ + current: localStorage.getItem("tangled.currentDid"), + sessions: localStorage.getItem("atcute-oauth:sessions") ?? "", + accounts: localStorage.getItem("tangled.accounts") ?? "" + })); + +// dids become "did" so two logins compare equal +const dropDids = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(dropDids); + if (!value || typeof value !== "object") return typeof value; + return Object.fromEntries( + Object.entries(value).map(([key, inner]) => [ + key.startsWith("did:") ? "did" : key, + dropDids(inner) + ]) + ); +}; +const kept = (state: Awaited>) => ({ + cookies: state.cookies + .map( + (c) => + `${c.domain} ${c.name} ${c.httpOnly} ${c.secure} ${c.sameSite} ${c.expires === -1}` + ) + .sort(), + storage: state.origins.map(({ origin, localStorage }) => ({ + origin, + items: Object.fromEntries( + localStorage.map(({ name, value }) => [name, dropDids(parseBody(value))]) + ) + })) +}); + +const keptLikeALogin = async (ctx: Ctx) => { + const login = JSON.parse(readFileSync(authFile(seeded.alice), "utf8")); + expect(kept(await ctx.page.context().storageState())).toEqual(kept(login)); +}; + +const firstStar = (name: string, options: { login?: "ui" } = {}) => + flow(name) + .actor("owner") + .repo() + .actor("fan", options) + + .open(page.repo) + .sees(actorMenu) + .step("the browser keeps the session the way a login does", keptLikeALogin) + .click(button("Star")) + .sends( + starId, + pds, + (ctx) => ({ record: { subject: { identity: ctx.repo.did } } }), + "create" + ) + .trace(starId, through(pds, hydrant, bobbin)) + .reload() + .sees(button("Starred")); + +firstStar("a new account logged in through the login page stars a repo", { login: "ui" }); +firstStar("a new account with a session minted from node stars a repo"); + +flow("logging out of one of two accounts in a browser keeps the other signed in") + .actor("second") + .step("remember the second account", remember("other")) + .actor("first", { login: "ui" }) + + .open(knots) + .click(actorMenu) + .click(menuItem("Add account")) + .at(loginThen(() => "/settings/knots")) + .step("the second account signs in beside the first", signInFromHere(otherOf)) + .at(knots) + .sees(menuOf(otherOf)) + + .click(menuOf(otherOf)) + .sees(menuItem("Logout")) + .click(logout) + .sees(actorMenu) + .step("the browser keeps the first account and has let go of the second", async (ctx) => { + const saw = await browserState(ctx); + expect(saw.current).toBe(ctx.actor.did); + expect(saw.sessions).toContain(ctx.actor.did); + expect(saw.sessions).not.toContain(otherOf(ctx).did); + expect(saw.accounts).not.toContain(otherOf(ctx).did); + }) + .reload() + .sees(actorMenu) + .open(knots) + .at(knots); + +flow("logging out ends the session and a settings page asks for a login again") + .actor("leaver", { login: "ui" }) + + .open(page.profile) + .step("the page is signed in", async (ctx) => { + expect((await browserState(ctx)).current).toBe(ctx.actor.did); + }) + .click(actorMenu) + .click(logout) + .sees(exactLink("Login")) + .step("the browser has forgotten the account", async (ctx) => { + const saw = await browserState(ctx); + expect(saw.current).toBeNull(); + expect(saw.sessions).not.toContain(ctx.actor.did); + const cookies = await ctx.page.context().cookies(); + expect(cookies.find((cookie) => cookie.name === "tangled.currentDid")).toBeUndefined(); + }) + .open(knots) + .at(loginThen(() => "/settings/knots")); + +flow("switching to the other account in the menu changes who the page is signed in as") + .actor("second") + .step("remember the second account", remember("other")) + .actor("first", { login: "ui" }) + + .open(knots) + .click(actorMenu) + .click(menuItem("Add account")) + .step("the second account signs in beside the first", signInFromHere(otherOf)) + .at(knots) + .sees(menuOf(otherOf)) + + .click(menuOf(otherOf)) + .click( + target("the first account in the menu", (tab, ctx) => + tab.getByRole("menuitem").filter({ hasText: ctx.actor.handle }) + ) + ) + .sees(actorMenu) + .step( + "the browser says the first account is the one in use and still has both", + async (ctx) => { + const saw = await browserState(ctx); + expect(saw.current).toBe(ctx.actor.did); + expect(saw.sessions).toContain(otherOf(ctx).did); + const cookies = await ctx.page.context().cookies(); + expect(cookies.find((cookie) => cookie.name === "tangled.currentDid")?.value).toContain( + ctx.actor.did.replace(/:/g, "%3A") + ); + } + ) + .reload() + .sees(actorMenu); + +const followeeProfile = where("the followee's profile", (ctx) => `/${otherOf(ctx).handle}`); + +flow("a visitor who clicks Follow is sent to log in and comes back to that profile") + .actor("star") + .step("remember who is followed", remember("other")) + .actor("fan", { login: "ui" }) + + .open(followeeProfile) + .step("signs out", forgetTheLogin) + .open(followeeProfile) + .click(exactLink("Follow")) + .at(loginThen((ctx) => `/${otherOf(ctx).handle}`)) + .step("signs back in", signInAsActor) + .at(followeeProfile) + .sees(button("Follow")); + +flow("a visitor who clicks Star is sent to log in and comes back to that repo") + .actor("owner") + .repo() + .actor("reader", { login: "ui" }) + + .open(page.repo) + .step("signs out", forgetTheLogin) + .open(page.repo) + .click(exactLink("Star")) + .at(loginThen((ctx) => ctx.repo.path)) + .step("signs back in", signInAsActor) + .at(page.repo) + .sees(button("Star")); + +flow("a visitor who opens a repo's settings is sent to log in and comes back to them") + .actor("owner", { login: "ui" }) + .repo() + + .open(page.repo) + .step("signs out", forgetTheLogin) + .open(page.settings) + .at(loginThen((ctx) => `${ctx.repo.path}/settings`)) + .step("signs back in", signInAsActor) + .at(page.settings) + .sees(text("Danger zone")); + +const knotsWithQuery = where("the knots settings with a query", () => "/settings/knots?from=email"); + +flow("a visitor who opens the knot settings is sent to log in and comes back to them") + .actor("reader", { login: "ui" }) + + .open(knots) + .step("signs out", forgetTheLogin) + .open(knotsWithQuery) + .at(loginThen(() => "/settings/knots?from=email")) + .step("signs back in", signInAsActor) + .at(knotsWithQuery) + .sees(heading("Knots")); + +const inviteHost = "knot.invited.example.test"; +const invite = where("the invite page", (ctx) => `/invite?knot=${inviteHost}&code=${code(ctx)}`); +const codes = new WeakMap(); +// the page only reads the code, so a made up one is fine until Join +const pickCode = async (ctx: Ctx) => { + codes.set(ctx, `e2e-${stamp()}`); +}; +const code = (ctx: Ctx) => codes.get(ctx)!; + +flow("an invite link opened signed out logs you in and lands on the join page") + .actor("guest", { login: "ui" }) + .step("pick a code", pickCode) + + .open(page.profile) + .step("signs out", forgetTheLogin) + .open(invite) + .at(loginThen((ctx) => `/invite?knot=${inviteHost}&code=${code(ctx)}`)) + .step("signs in", signInAsActor) + .at(invite) + .sees(heading(`Join ${inviteHost}`)) + .sees(button(`Join ${inviteHost}`)); + +const banners = [ + { error: "access_denied", says: "You have not authorized the app." }, + { error: "session", says: "Your session expired. Sign in again." }, + { error: "exchange", says: "Something went wrong signing in. Please try again." } +]; + +for (const { error, says } of banners) { + test(`the login page says "${says}" when it is opened with ${error}`, async ({ page: tab }) => { + await tab.goto(`/login?error=${error}`, { waitUntil: "networkidle" }); + await expect(tab.getByRole("heading", { name: "Login error" })).toBeVisible(); + await expect(tab.getByText(says, { exact: true })).toBeVisible(); + }); +} + +test("a handle that does not exist is told so on the login page and stays there", async ({ + page: tab +}) => { + await tab.goto("/login", { waitUntil: "networkidle" }); + const handle = tab.getByLabel("Handle", { exact: true }); + await handle.fill(`nobody-${stamp()}.pds.tngl.boltless.dev`); + await handle.press("Enter"); + await expect(tab.getByRole("heading", { name: "Login error" })).toBeVisible(); + await expect(tab.getByText("Please try again.")).toBeVisible(); + await expect(tab).toHaveURL(`${config.webUrl}/login`); +}); + +const guarded = [ + "/welcome", + "/strings/new", + "/organizations/new", + "/settings/profile/delete?step=1", + "/settings/keys/new" +]; + +for (const path of guarded) { + test(`a visitor opening ${path} is sent to log in with that page to come back to`, async ({ + page: tab + }) => { + await tab.goto(path, { waitUntil: "networkidle" }); + await expect(tab).toHaveURL( + `${config.webUrl}/login?return_url=${encodeURIComponent(path)}` + ); + }); +} diff --git a/e2e/specs/social.spec.ts b/e2e/specs/social.spec.ts new file mode 100644 index 000000000..140689140 --- /dev/null +++ b/e2e/specs/social.spec.ts @@ -0,0 +1,391 @@ +import { expect } from "@playwright/test"; + +import { pdsCall } from "../harness/api"; +import { type Account, config } from "../harness/config"; +import { + bobbin, + button, + commentId, + type Ctx, + discussion, + field, + flow, + followId, + heading, + hydrant, + knot, + not, + page, + pds, + rkeyOf, + starCount, + starId, + subscriptionId, + target, + text, + through, + ticketId, + where +} from "../harness/flow"; +import { get, until, xrpc } from "../harness/net"; +import { type Notice, noticesOf } from "../harness/services"; +import { deletesIssue, pdsRecordUrl, recall, remember } from "../harness/shared"; +import { type Follow } from "../harness/lexicons"; + +const noticeFor = async (ctx: Ctx, type: string, about: (notice: Notice) => boolean) => + until(`deliberi should list a ${type} notice for ${ctx.actor.handle}`, 20_000, async () => { + const found = (await noticesOf(ctx.actor)).filter( + (notice) => notice.type === type && about(notice) + ); + return [ + found.length > 0, + `it lists ${JSON.stringify((await noticesOf(ctx.actor)).map((notice) => notice.type))}`, + found[0] + ]; + }); + +const notices = where("the notifications page", () => "/notifications"); +const bell = (unread: number) => + target(`the bell saying ${unread} unread`, (tab) => + tab.getByRole("button", { name: `Notifications, ${unread} unread`, exact: true }) + ); +const noticeLink = (phrase: string) => + target(`the notice "${phrase}"`, (tab, ctx) => + tab.getByRole("link", { name: `${phrase} ${ctx.repo.path.slice(1)}`, exact: true }) + ); + +const starsOwner = (ctx: Ctx) => ({ + record: { subject: { $type: "org.tangled.feed.star#repo", identity: ctx.repo.did } } +}); + +flow("a star notifies the repo owner and opening the notice marks it read") + .actor("fan") + .step("remember the fan", remember("fan")) + .actor("owner") + .repo() + .actor("fan") + .open(page.repo) + .click(button("Star")) + .sends(starId, pds, starsOwner, "create") + .trace(starId, through(pds, hydrant, bobbin)) + + .actor("owner") + .step("deliberi lists the star as unread", async (ctx) => { + const notice = await noticeFor( + ctx, + "repo_starred", + (n) => n.actorDid === recall(ctx, "fan").did + ); + expect(notice.read).toBe(false); + }) + .open(notices) + .sees(noticeLink("starred")) + .sees(bell(1)) + .click(noticeLink("starred")) + .at(page.repo) + .step("deliberi lists the star as read", async (ctx) => { + await until("deliberi should list the star as read", 10_000, async () => { + const found = (await noticesOf(ctx.actor)).find((n) => n.type === "repo_starred"); + return [found?.read === true, `it lists ${JSON.stringify(found)}`]; + }); + }) + .sees(bell(0)) + .open(notices) + .sees(noticeLink("starred")) + .sees(bell(0)); + +const issueTitle = "somebody will answer"; + +// deliberi only tells subscribers, so the owner has to open the issue in the browser first +const commentedByAFan = (name: string) => + flow(name) + .actor("fan") + .step("remember the fan", remember("fan")) + .actor("owner") + .repo() + .open(page.newIssue) + .fill(field("Title"), issueTitle) + .click(button("Create issue")) + .trace(ticketId, through(knot, hydrant, bobbin)) + .trace(subscriptionId, through(pds, hydrant, bobbin)) + .actor("fan") + .open(page.issue(ticketId)) + .fill(discussion, "me too") + .click(button("Comment")) + .trace(commentId, through(knot, hydrant, bobbin)) + .trace(subscriptionId, through(pds)) + .actor("owner") + .step("deliberi lists the comment as unread", async (ctx) => { + const notice = await noticeFor( + ctx, + "issue_commented", + (n) => n.actorDid === recall(ctx, "fan").did + ); + expect(notice.issueAt).toBe(ctx.get(ticketId).uri); + }); + +commentedByAFan("the notice for a comment on an issue opens that issue") + .open(notices) + .click(noticeLink("commented on an issue on")) + .at(page.issue(ticketId)) + .sees(heading(issueTitle)); + +commentedByAFan("deleting an issue takes the notice about it away") + .open(page.issue(ticketId)) + .use(deletesIssue) + .step("deliberi no longer lists the comment", async (ctx) => { + await until("deliberi should drop the notice about the erased issue", 15_000, async () => { + const left = (await noticesOf(ctx.actor)).filter( + (n) => n.issueAt === ctx.get(ticketId).uri + ); + return [left.length === 0, `it still lists ${JSON.stringify(left)}`]; + }); + }) + .open(notices) + .sees(not(noticeLink("commented on an issue on"))); + +const stargazers = where("the stargazers page", (ctx) => `${ctx.repo.path}/stars`); +const handleLink = (who: (ctx: Ctx) => Account) => + target("the stargazer's link", (tab, ctx) => + tab.getByRole("link", { name: who(ctx).handle, exact: true }) + ); + +flow("a star shows on the repo's stargazers page") + .actor("owner") + .repo() + .actor("fan") + + .open(stargazers) + .sees(text("No stars yet.")) + .open(page.repo) + .click(button("Star")) + .sends(starId, pds, starsOwner, "create") + .trace(starId, through(pds, hydrant, bobbin)) + .open(stargazers) + .sees(handleLink((ctx) => ctx.actor)) + .sees(not(text("No stars yet."))) + .reload() + .sees(handleLink((ctx) => ctx.actor)); + +const repoPaths = new WeakMap>(); +const rememberRepo = (label: string) => async (ctx: Ctx) => { + repoPaths.set(ctx, { ...repoPaths.get(ctx), [label]: ctx.repo.path }); +}; +const repoRow = (label: string) => + target(`the ${label}'s repo in the feed`, (tab, ctx) => + tab.locator(`a[href="${repoPaths.get(ctx)![label]}"]`).first() + ); + +const followingFeed = where("the following timeline", () => "/timeline?following=true"); + +flow("the following timeline shows a followed account's repo and not a stranger's") + .actor("maker") + .step("remember the maker", remember("maker")) + .repo() + .step("remember the maker's repo", rememberRepo("maker")) + .actor("rando") + .repo() + .step("remember the rando's repo", rememberRepo("rando")) + .actor("fan") + .step("the fan follows the maker", async (ctx) => { + const written = await pdsCall(ctx.actor, "com.atproto.repo.putRecord", { + collection: followId.nsid, + rkey: recall(ctx, "maker").did, + record: { $type: followId.nsid, createdAt: new Date().toISOString() } satisfies Follow + }); + await bobbin.indexed(written); + }) + + .open(followingFeed) + .sees(repoRow("maker")) + .sees(not(repoRow("rando"))) + .reload() + .sees(repoRow("maker")) + .sees(not(repoRow("rando"))); + +// one flow at a time per worker, so a module-level let is safe +let star: Account; + +const profile = where("the followee's profile", () => `/${star.handle}`); + +const followersAre = (count: number) => async () => { + await until(`bobbin should count ${count} followers of ${star.handle}`, 15_000, async () => { + const { status, body } = await get( + xrpc(config.bobbinUrl, "sh.tangled.graph.countFollows", { subject: star.did }) + ); + return [ + status === 200 && body.count === count, + `countFollows answered ${status} ${JSON.stringify(body)}` + ]; + }); +}; + +// the follow record has no subject, the followee's did is the rkey +const followedBy = (rkey: () => string) => (ctx: { actor: Account }) => ({ + collection: "org.tangled.graph.follow", + repo: ctx.actor.did, + rkey: rkey() +}); + +flow("following someone and unfollowing them") + .actor("star") + .step("remember who is being followed", async (ctx) => { + star = ctx.actor; + }) + .actor("fan") + + .open(profile) + .sees(text("0 followers")) + .click(button("Follow")) + .sends( + followId, + pds, + followedBy(() => star.did), + "put" + ) + // the button waits on the server, so the state doesn't flip yet + .sees(button("Follow")) + .trace(followId, through(pds, hydrant, bobbin)) + .step("the follow record has no subject, its rkey is the followee", async (ctx) => { + const { body } = await get(pdsRecordUrl(ctx.actor.did, followId.nsid, star.did)); + expect(Object.keys(body.value).sort()).toEqual(["$type", "createdAt"]); + }) + .step("bobbin counts one follower", followersAre(1)) + .sees(button("Unfollow")) + .sees(text("1 follower")) + .reload() + .sees(button("Unfollow")) + .sees(text("1 follower")) + + .click(button("Unfollow")) + .sends( + followId, + pds, + followedBy(() => star.did), + "delete" + ) + .sees(button("Unfollow")) + .trace(followId, through(pds, hydrant, bobbin)) + .step("bobbin counts no followers", followersAre(0)) + .sees(button("Follow")) + .reload() + .sees(button("Follow")) + .sees(text("0 followers")); + +flow("starring and unstarring a repo") + .actor("owner") + .repo() + .actor("fan") + + .open(page.repo) + .click(button("Star")) + .sends(starId, pds, starsOwner, "create") + // optimistic ui, the server hasn't seen the write yet + .sees(button("Starred")) + .sees(starCount, "1") + .step("bobbin has not counted it", async (ctx) => + expect(await bobbin.countStars(ctx.repo.did)).toBe(0) + ) + .trace(starId, through(pds, hydrant, bobbin)) + .step("bobbin counts one star", async (ctx) => + expect(await bobbin.countStars(ctx.repo.did)).toBe(1) + ) + .reload() + .sees(button("Starred")) + .sees(starCount, "1") + + .click(button("Starred")) + .sends(starId, pds, (ctx) => ({ rkey: rkeyOf(ctx, starId) }), "delete") + .sees(button("Star")) + .sees(starCount, "0") + .trace(starId, through(pds, hydrant, bobbin)) + .step("bobbin counts no stars", async (ctx) => + expect(await bobbin.countStars(ctx.repo.did)).toBe(0) + ) + .reload() + .sees(button("Star")) + .sees(starCount, "0"); + +// bobbin answers 404 when there is no subscription +const subscribed = (subject: (ctx: Ctx) => string, yes: boolean) => async (ctx: Ctx) => { + const { status, body } = await get( + xrpc(config.bobbinUrl, "org.tangled.feed.subscription.getForActor", { + actor: ctx.actor.did, + subject: subject(ctx) + }) + ); + expect(status, `getForActor answered ${status} ${JSON.stringify(body)}`).toBe(yes ? 200 : 404); +}; + +flow("watching a repo and giving it up again") + .actor("owner") + .repo() + .actor("fan") + + .open(page.repo) + .click(button("Watch")) + .sends( + subscriptionId, + pds, + (ctx) => ({ + record: { subject: { $type: "org.tangled.feed.subscription#repo", did: ctx.repo.did } } + }), + "create" + ) + // the watch button doesn't wait for the server + .sees(button("Watching")) + .trace(subscriptionId, through(pds, hydrant, bobbin)) + .step( + "bobbin lists the watch", + subscribed((ctx) => ctx.repo.did, true) + ) + .reload() + .sees(button("Watching")) + + .click(button("Watching")) + .sends(subscriptionId, pds, (ctx) => ({ rkey: rkeyOf(ctx, subscriptionId) }), "delete") + .sees(button("Watch")) + .trace(subscriptionId, through(pds, hydrant, bobbin)) + .step( + "bobbin no longer lists the watch", + subscribed((ctx) => ctx.repo.did, false) + ) + .reload() + .sees(button("Watch")); + +const issueUri = (ctx: Ctx) => ctx.get(ticketId).uri; + +flow("subscribing to an issue and unsubscribing again") + .actor("owner") + .repo() + .ticket(ticketId, { title: "follow along" }) + .actor("fan") + + .open(page.issue(ticketId)) + .click(button("Subscribe")) + .sends( + subscriptionId, + pds, + (ctx) => ({ + record: { + subject: { $type: "org.tangled.feed.subscription#record", uri: issueUri(ctx) } + } + }), + "create" + ) + // the subscribe button waits for the server + .sees(button("Subscribing...")) + .trace(subscriptionId, through(pds, hydrant, bobbin)) + .step("bobbin lists the subscription", subscribed(issueUri, true)) + .sees(button("Unsubscribe")) + .reload() + .sees(button("Unsubscribe")) + + .click(button("Unsubscribe")) + .sends(subscriptionId, pds, (ctx) => ({ rkey: rkeyOf(ctx, subscriptionId) }), "delete") + .sees(button("Unsubscribing...")) + .trace(subscriptionId, through(pds, hydrant, bobbin)) + .step("bobbin no longer lists the subscription", subscribed(issueUri, false)) + .sees(button("Subscribe")) + .reload() + .sees(button("Subscribe")); diff --git a/e2e/specs/strings.spec.ts b/e2e/specs/strings.spec.ts new file mode 100644 index 000000000..422f10220 --- /dev/null +++ b/e2e/specs/strings.spec.ts @@ -0,0 +1,177 @@ +import { expect } from "@playwright/test"; + +import { getOk } from "../harness/api"; +import { config } from "../harness/config"; +import { + bobbin, + button, + type Ctx, + flow, + hydrant, + link, + menuItem, + not, + pds, + placeholder, + record, + replacing, + rkeyOf, + starId, + target, + text, + through, + where +} from "../harness/flow"; +import { get, until, xrpc } from "../harness/net"; +import { acceptConfirm, pdsRecordUrl, recall, remember } from "../harness/shared"; + +const string = record("org.tangled.snippet.string"); +const stringFilename = "notes.txt"; +const stringBody = "the needle is in the haystack"; +const newString = where("the new string form", () => "/strings/new"); +const stringPage = where( + "the string page", + (ctx) => `/strings/${recall(ctx, "writer").handle}/${rkeyOf(ctx, string)}` +); +const stringsTab = where( + "the writer's strings tab", + (ctx) => `/${recall(ctx, "writer").handle}?tab=strings` +); +// the editor is a web component, its contenteditable is in a shadow root +const stringEditor = target("the string editor", (tab) => + tab.locator("diffs-container").getByRole("textbox") +); +const typeInEditor = + (words: string, replace = false) => + async (ctx: Ctx) => { + const editor = stringEditor.locate(ctx.page, ctx); + const typed = ctx.page.getByText(`${Buffer.byteLength(words)} bytes`); + // the editor drops keys when it redraws after the filename changes + await until("the editor should hold what was typed", 30_000, async () => { + await editor.click(); + await ctx.page.waitForTimeout(500); + await ctx.page.keyboard.press("ControlOrMeta+a"); + if (!replace) await ctx.page.keyboard.press("Backspace"); + await editor.pressSequentially(words, { delay: 20 }); + const held = await typed.isVisible({ timeout: 1500 }).catch(() => false); + return [held, held ? "it does" : "it does not"]; + }); + }; + +const stringOnPds = async (ctx: Ctx) => { + const { value } = await getOk( + pdsRecordUrl(recall(ctx, "writer").did, string.nsid, rkeyOf(ctx, string)) + ); + const blob = await get( + xrpc(config.pdsUrl, "com.atproto.sync.getBlob", { + did: recall(ctx, "writer").did, + cid: value.snippet.content.ref.$link + }) + ); + return { value, contents: blob.body as string }; +}; + +const writtenString = (name: string) => + flow(name) + .actor("writer") + .step("remember the writer", remember("writer")) + .open(newString) + .fill(placeholder("main.rs"), stringFilename) + .fill(placeholder("What this string is for"), "my notes") + .step("types the content", typeInEditor(stringBody)) + .click(button("Publish")) + .sends( + string, + pds, + { + record: { + $type: string.nsid, + snippet: { name: stringFilename }, + description: "my notes" + } + }, + "put" + ) + .trace(string, through(pds, hydrant, bobbin)) + .at(stringPage) + .sees(text(stringBody)); + +writtenString("creating a string") + .step("the pds keeps the text in a blob", async (ctx) => { + const { value, contents } = await stringOnPds(ctx); + expect(value.snippet.name).toBe(stringFilename); + expect(contents).toBe(stringBody); + }) + .reload() + .sees(text(stringBody)) + .sees(text("my notes")) + .open(stringsTab) + .sees(link(stringFilename)); + +const edited = "the needle moved to another haystack"; + +writtenString("editing a string") + .click(button("Edit")) + .fill(placeholder("What this string is for"), "my edited notes") + .step("types the new content", typeInEditor(edited, true)) + .click(button("Save changes")) + .sends( + string, + pds, + replacing(string, { + snippet: { name: stringFilename }, + description: "my edited notes" + }), + "put" + ) + .trace(string, through(pds, hydrant, bobbin)) + .sees(text(edited)) + .sees(text("my edited notes")) + .step("the pds keeps the new text", async (ctx) => { + expect((await stringOnPds(ctx)).contents).toBe(edited); + }) + .reload() + .sees(text(edited)) + .sees(not(text(stringBody))); + +writtenString("deleting a string") + .step("agree to the confirm", acceptConfirm) + .click(button(`More actions for ${stringFilename}`)) + .click(menuItem("Delete")) + .sends(string, pds, replacing(string), "delete") + .trace(string, through(pds, hydrant, bobbin)) + .at(stringsTab) + .sees(not(link(stringFilename))) + .open(stringPage) + .sees(text("Page not found")); + +writtenString("starring a string") + .actor("fan") + .open(stringPage) + .click(button("Star")) + .sends( + starId, + pds, + (ctx) => ({ + record: { + subject: { + $type: "org.tangled.feed.star#record", + record: { uri: ctx.get(string).uri, cid: ctx.get(string).cid } + } + } + }), + "create" + ) + .sees(button("Starred")) + .trace(starId, through(pds, hydrant, bobbin)) + .reload() + .sees(button("Starred")) + .open(where("the fan's starred tab", (ctx) => `/${ctx.actor.handle}?tab=starred`)) + .sees( + target("the starred string", (tab, ctx) => + tab.getByRole("link", { + name: `${recall(ctx, "writer").handle}/${rkeyOf(ctx, string)}`, + exact: true + }) + ) + ); diff --git a/e2e/tools/sweep.spec.ts b/e2e/tools/sweep.spec.ts new file mode 100644 index 000000000..1bc1a1c70 --- /dev/null +++ b/e2e/tools/sweep.spec.ts @@ -0,0 +1,63 @@ +import { test } from "@playwright/test"; + +import { accounts, getOk, repos } from "../harness/api"; +import { config, seeded } from "../harness/config"; +import { partsOf, stampedAt, xrpc } from "../harness/net"; + +const AGE_MINUTES = Number(process.env.E2E_SWEEP_MINUTES ?? 30); +if (!(AGE_MINUTES >= 0)) { + throw new Error( + `E2E_SWEEP_MINUTES is a number of minutes, saw ${process.env.E2E_SWEEP_MINUTES}` + ); +} + +// stopping at the first page would silently miss the rest +const listAll = async (nsid: string, params: Record, key: string) => { + const all: any[] = []; + let cursor: string | undefined; + do { + const page = await getOk( + xrpc(config.pdsUrl, nsid, { ...params, limit: "100", ...(cursor ? { cursor } : {}) }) + ); + all.push(...page[key]); + cursor = page.cursor; + } while (cursor); + return all; +}; + +test("sweep leaked accounts", async () => { + test.setTimeout(300_000); + const keep = new Set(Object.values(seeded).map((account) => account.did)); + const failed: string[] = []; + for (const { did } of await listAll("com.atproto.sync.listRepos", {}, "repos")) { + if (keep.has(did)) continue; + const info = await getOk( + xrpc(config.pdsUrl, "com.atproto.repo.describeRepo", { repo: did }) + ); + const name = info.handle.split(".")[0]; + const made = stampedAt(name); + if (!made || Date.now() - made < AGE_MINUTES * 60_000) continue; + + const account = { name, handle: info.handle, did }; + const declared = await listAll( + "com.atproto.repo.listRecords", + { repo: did, collection: "org.tangled.repo.declaration" }, + "records" + ); + let stuck = false; + for (const record of declared) { + await repos + .remove(account, { did: record.value.repo, name: partsOf(record.uri).rkey }) + .catch((cause) => { + stuck = true; + failed.push(`repo of ${name}: ${String(cause).slice(0, 200)}`); + }); + } + // the account is the only way back to its repos, so keep it while one fails + if (stuck) continue; + await accounts.removeMember(account).catch(() => {}); + await accounts.remove(account); + console.log("swept", info.handle); + } + if (failed.length) throw new Error(`could not sweep:\n ${failed.join("\n ")}`); +}); diff --git a/e2e/tools/tidy.spec.ts b/e2e/tools/tidy.spec.ts new file mode 100644 index 000000000..45bf788ef --- /dev/null +++ b/e2e/tools/tidy.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from "@playwright/test"; +import { readdirSync } from "node:fs"; + +import { LEDGER, replay } from "../harness/chores"; + +// what a worker owed is replayed once it is gone, so this waits for its entries to clear +test("tidy what a stopped worker still owed", async () => { + const worker = process.env.E2E_TIDY_FOR; + test.setTimeout(90_000); + await expect + .poll( + async () => { + await replay("dead"); + return readdirSync(LEDGER).filter((name) => name.split("-")[1] === worker); + }, + { intervals: [1000], timeout: 80_000 } + ) + .toEqual([]); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 000000000..3080aa4a7 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "types": ["node"], + "skipLibCheck": true, + // the web's generated lexicon types import atcute, and web/ may have no node_modules, so + // they read ours. no extension, because playwright loads the .js through these paths too + "paths": { + "@atcute/lexicons": ["./node_modules/@atcute/lexicons/dist/index"], + "@atcute/lexicons/*": [ + "./node_modules/@atcute/lexicons/dist/*/index", + "./node_modules/@atcute/lexicons/dist/*" + ], + "@atcute/atproto/types/*": [ + "./node_modules/@atcute/atproto/dist/lexicons/types/com/atproto/*.d.ts" + ] + } + }, + "include": ["**/*.ts"] +} diff --git a/e2e/up b/e2e/up new file mode 100755 index 000000000..e782a04ee --- /dev/null +++ b/e2e/up @@ -0,0 +1,45 @@ +#!/bin/sh +# starts the localinfra stack the way e2e needs it, with the web as a production build. +# --check says whether it is up and its web build is current: 0 yes, 1 not up, 2 web out of date +set -eu +here="$(cd "$(dirname "$0")" && pwd)" +cd "$here/.." + +# the web container builds what is in web/ when it starts, so it is recreated when web/ changes +E2E_WEB_SRC="$(node "$here/harness/web-hash.ts")" +export E2E_WEB_SRC + +# the web is a production build here, so no page waits on a module compiling when it is first asked for +web="-f localinfra/docker-compose.web-build.yml" +if command -v podman >/dev/null 2>&1; then + engine=podman + compose="podman compose -f docker-compose.yml $web" +else + # docker doesn't publish ports from an internal network, which caddy sits on + engine=docker + compose="docker compose -f docker-compose.yml -f localinfra/docker-compose.docker.yml $web" +fi + +if [ "${1:-}" = "--check" ]; then + id=$($compose ps --status running -q web 2>/dev/null || true) + [ -n "$id" ] || exit 1 + # the dev server answers too, but every first page waits on it compiling + $engine inspect --format '{{join .Config.Cmd " "}}' "$id" | grep -q "vite build" || exit 1 + built="$($engine inspect --format '{{index .Config.Labels "e2e.web-src"}}' "$id")" + [ "$built" = "$E2E_WEB_SRC" ] || exit 2 + exit +fi + +if [ ! -f localinfra/certs/root.crt ]; then + echo "no dev ca yet, run ./localinfra/scripts/make-certs.sh and trust localinfra/certs/root.crt" >&2 + exit 1 +fi + +$compose up -d --wait + +# the browser resolves the stack's names itself, node and curl need the host's caddy +if ! curl -sf --cacert localinfra/certs/root.crt --resolve pds.tngl.boltless.dev:443:127.0.0.1 \ + "https://pds.tngl.boltless.dev/xrpc/com.atproto.identity.resolveHandle?handle=alice.pds.tngl.boltless.dev" >/dev/null; then + echo "alice doesn't resolve, the stack's init-data hasn't run" >&2 + exit 1 +fi diff --git a/localinfra/docker-compose.web-build.yml b/localinfra/docker-compose.web-build.yml index b62cda98d..40bed6172 100644 --- a/localinfra/docker-compose.web-build.yml +++ b/localinfra/docker-compose.web-build.yml @@ -9,5 +9,8 @@ services: corepack pnpm install --frozen-lockfile --store-dir /src/node_modules/.pnpm-store corepack pnpm exec vite build exec corepack pnpm exec vite preview --host 0.0.0.0 --port 5174 --strictPort + # a changed hash recreates the container, which builds again. e2e/up sets it + labels: + e2e.web-src: ${E2E_WEB_SRC:-} healthcheck: start_period: 480s