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 { get, ok, selfUri, until, xrpc, type Reply, type Written } from "./net"; import { bobbin, knotmirror, hydrant, knot, openers, pds, refsNaming, spindle, type Hop } from "./services"; import { alerts, commentId, commentMenu, declarationId, confirmDelete, discussion, editLabels, labelOption, manifestId, link, mergeButton, moreActions, not, page, patchId, pullId, record, refsId, renameForm, rkeyOf, replacing, resolve, saveButton, subscriptionId, target, testid, text, through, ticketId, triage, triageChip, triageToken, where, within, type Ctx, type Lazy, type Slot, type Target } 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"; interface Pipeline { id: string; commit: string; trigger?: { $type?: string }; workflows?: { id: string; status: string }[]; } export const pullPipelines = async (repoDid: string, sha: string) => { const { status, body } = await get( xrpc(config.spindleUrl, "org.tangled.ci.queryPipelines", { repo: repoDid, commits: sha }) ); expect(status, `spindle answered ${JSON.stringify(body)}`).toBe(200); return ((body.pipelines ?? []) as Pipeline[]).filter( (p) => p.commit === sha && p.trigger?.$type?.endsWith(".event.pullRequest") ); }; // a pull from someone who can't push makes pipelines that wait for the collaborator's approval export const waitsForApproval = (sha: (ctx: Ctx) => string, repoDid: (ctx: Ctx) => string = (ctx) => ctx.repo.did) => (ctx: Ctx) => until( `spindle should block the pull pipelines at ${sha(ctx)}`, 30_000, async () => { const found = await pullPipelines(repoDid(ctx), sha(ctx)); const blocked = found.length > 0 && found.every((p) => p.workflows?.every((w) => w.status === "blocked")); return [ blocked, `it listed ${JSON.stringify(found.map((p) => p.workflows))}`, found ]; } ); 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 } : {}), patch: { ticket: rkeyOf(ctx, pullId), base: { oid: (fork ? ctx.upstream : ctx.repo).heads[branches.into] }, head: { oid: ctx.repo.heads[branches.from] }, sourceRepo: ctx.repo.did } }), "create" ) .trace(patchId, through(knot, hydrant, bobbin)); export const resubmitsForkPatch = (flow: Flow) => flow .click(testid("resubmit")) .sends( patchId, knot, (ctx) => ({ repo: ctx.upstream.did, patch: { ticket: rkeyOf(ctx, pullId), sourceRepo: ctx.repo.did } }), "create" ) .trace(patchId, through(knot, hydrant, bobbin)); export const subscribesToPull = (flow: Flow) => flow .sends( subscriptionId, pds, (ctx) => ({ record: { subject: { uri: ctx.get(pullId).uri } } }), "create" ) .trace(subscriptionId, through(pds)); // the fork is the flow's repo and the upstream the one it asks to pull from export const forkedAhead = (name: string, options: { ci?: boolean } = {}) => flow(name) .actor("owner") .repo(options.ci ? { spindle: config.spindleDid } : {}) .push(options.ci ? { ...base, [workflowPath]: workflow } : 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(); // a fork's first diff read fetches its commits, so the page fails until a reload export const reloadsUntil = (what: Target) => async (ctx: Ctx) => until(`reloading should show ${what.describe}`, 60_000, async () => { const shown = (await what.locate(ctx.page, ctx).count()) > 0; if (!shown) await ctx.page.reload(); return [shown, "it is still not on the page"]; }); export 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}` ); export const upstreamPulls = where( "the upstream's pulls list", (ctx) => `${ctx.upstream.path}/pulls` ); export const upstreamPull = where( "the pull on the upstream", (ctx) => `${ctx.upstream.path}/pulls/${rkeyOf(ctx, pullId)}` ); export const opensForkPull = (title: string) => (flow: Flow) => flow .open(composeFromFork) .fill(testid("title"), title) .click(testid("submit")) .sends( pullId, knot, (ctx) => ({ repo: ctx.upstream.did, record: { state: "open", title: { text: title }, targetBranch: branches.into } }), "create" ) .trace(pullId, through(knot, hydrant, bobbin)) .use(sendsPatch({ fork: true })) .use(subscribesToPull) .at(upstreamPulls) .sees(link(title)); // the page marks the ticket completed with its own put, after the merge export const mergesThrough = (hops: Hop[]) => (flow: Flow) => flow .click(mergeButton) .sends(pullId, knot, replacing(pullId, { state: "completed" }), "put") .trace(pullId, hops) .sees(testid("ticketStatePill", { state: "completed" })); export const merges = mergesThrough(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]); }; // a rebase makes a new tip, so match the tree and commit count, not the sha const tips = new WeakMap(); export const mergedTip = (ctx: Ctx) => { const tip = tips.get(ctx); if (!tip) throw new Error("no merge was checked yet, use targetTookTheFeature first"); return tip; }; export const targetTookTheFeature = (who = (ctx: Ctx) => ctx.actor, { fromFork = false } = {}) => async (ctx: Ctx) => { const target = fromFork ? ctx.upstream : ctx.repo; const before = target.heads[branches.into]; const head = ctx.repo.heads[branches.from]; const repos = [...new Set([target.did, ctx.repo.did])] .map((did) => `repos=${did}`) .join("&"); const tip = (await lsRemote(who(ctx), target.did))[`refs/heads/${branches.into}`]; expect(tip, "the target branch didn't move").not.toBe(before); tips.set(ctx, tip); const between = async (to: string) => { const { status, body } = await get( `${xrpc(config.bobbinUrl, "org.tangled.temp.git.listCommits", { ranges: `${before}..${to}` })}&${repos}` ); return { status, body, commits: (body?.commits ?? []) as { oid: string; parents: string[]; tree: string }[] }; }; await until(`the mirror should list the feature's commits on ${tip}`, 30_000, async () => { const [merged, feature] = [await between(tip), await between(head)]; const [tipCommit, headCommit] = [ merged.commits.find((c) => c.oid === tip), feature.commits.find((c) => c.oid === head) ]; const oldest = merged.commits.find((c) => c.parents[0] === before); const took = merged.status === 200 && feature.commits.length > 0 && merged.commits.length === feature.commits.length && Boolean(oldest) && tipCommit?.tree === headCommit?.tree; return [ took, `merged ${JSON.stringify(merged.body)}, feature ${JSON.stringify(feature.body)}` ]; }); }; 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(testid("submit")) .trace(commentId, through(knot, hydrant, bobbin)) .trace(subscriptionId, subscription) .sees(within(testid("commentBody"), text(words))); export const deletesComment = (flow: Flow) => flow .click(commentMenu) .click(testid("handleDelete")) .click(confirmDelete) .sends(commentId, knot, replacing(commentId), "delete") .trace(commentId, through(knot, hydrant, bobbin)); export const deletesIssue = (flow: Flow) => flow .click(moreActions) .click(testid("openDeleteConfirm")) .click(confirmDelete) .sends(ticketId, knot, replacing(ticketId), "delete") .trace(ticketId, through(knot, hydrant, bobbin)); // a rename writes the new declaration before deleting the old one, so the new one needs its own slot const movedDeclarationId = record("org.tangled.repo.declaration"); export const renamesTo = (name: (ctx: Ctx) => string) => (flow: Flow) => flow .open(renameForm) .fill(testid("name"), name) .click(testid("rename")) .sends(movedDeclarationId, pds, (ctx) => ({ rkey: name(ctx) }), "create") .trace(movedDeclarationId, through(pds, hydrant, bobbin)) .trace(manifestId, through(knot, hydrant, bobbin)) .sends(declarationId, pds, (ctx) => ({ rkey: ctx.repo.name }), "delete") .trace(declarationId, through(pds, hydrant, bobbin)); export const rowOf = (slot: Slot) => target("the ticket's row", (tab, ctx) => tab.locator(`[data-testid="ticketRow"][data-rkey=${JSON.stringify(rkeyOf(ctx, slot))}]`) ); export const authorLink = (who: (ctx: Ctx) => Account) => target("the link to the ticket's author", (tab, ctx) => // the hidden narrow copy comes first, so take the visible one tab .locator(`a[href="/${who(ctx).handle}"]`) .filter({ visible: true }) .first() ); export const bobbinNamesOpener = ( slot: Slot, kind: "issue" | "pull", opener: (ctx: Ctx) => Account, lastWriter: (ctx: Ctx) => Account ) => async (ctx: Ctx) => { const { uri } = ctx.get(slot); const list = kind === "issue" ? "listIssues" : "listPulls"; const [opened, wrote] = [opener(ctx).did, lastWriter(ctx).did]; await until(`bobbin should name ${opened} as the opener of ${uri}`, 20_000, async () => { const seen = { view: await openers.view(uri), listed: await openers.listed(ctx.repo.did, list, uri), searched: (await openers.searched(ctx.repo.did, kind, "")).find( (h) => h.uri === uri )?.author }; const byOpener = await openers.searched(ctx.repo.did, kind, `author:${opened}`); const byWriter = await openers.searched(ctx.repo.did, kind, `author:${wrote}`); const right = Object.values(seen).every((author) => author === opened) && byOpener.some((h) => h.uri === uri) && !byWriter.some((h) => h.uri === uri); return [ right, `views name ${JSON.stringify(seen)}, author:opener lists ${byOpener.length}, author:last writer lists ${byWriter.length}` ]; }); }; export const knotmirrorNamesOpener = (slot: Slot, opener: (ctx: Ctx) => Account) => async (ctx: Ctx) => expect(await knotmirror.openerOf(ctx.get(slot).uri)).toBe(opener(ctx).did); export const theFan = (ctx: Ctx) => recall(ctx, "fan"); export const theOwner = (ctx: Ctx) => recall(ctx, "owner"); export const fanPulled = (name: string, title = "a pull from the fan") => onBranches(name) .step("remember the owner", remember("owner")) .actor("fan") .step("remember the fan", remember("fan")) .pull(pullId, patchId, { ...branches, title }) .actor("owner"); export const fanOpened = (name: string, title = "opened by somebody else") => flow(name) .actor("fan") .step("remember the fan", remember("fan")) .actor("owner") .step("remember the owner", remember("owner")) .repo() .actor("fan") .ticket(ticketId, { title }) .actor("owner");