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<Ctx, Record<string, Account>>();
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<unknown>) =>
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<string>;
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<Pipeline[]>(
`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<Ctx, string>();
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");