Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571import { 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, sleep, until, xrpc, type Reply, type Written } from "./net";import { bobbin, gitmirror, 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 goexport 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 itexport 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 pollsexport 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 apiexport 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: dummywhen: - event: ["push", "pull_request"] branch: ["main"]steps: - name: hello command: echo "build ok"`;
export const workflowPath = ".tangled/workflows/ci.yml";
interface Pipeline { commit: string; trigger?: { $type?: string }; workflows?: { id: 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") );};
// spindle reads the stream within a second or two, so this long without a pipeline means noneexport const runsNothing = (sha: (ctx: Ctx) => string, repoDid: (ctx: Ctx) => string = (ctx) => ctx.repo.did) => async (ctx: Ctx) => { await sleep(15_000); expect( await pullPipelines(repoDid(ctx), sha(ctx)), "pull request pipelines for the head" ).toEqual([]); };
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 repoexport const branches = { from: "feature", into: "main" };
// the pull pages read git through the mirror, so the repo has to be mirroredexport 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 fromexport 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 reloadexport 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 mergeexport 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 shaconst 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 slotconst 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 gitmirrorNamesOpener = (slot: Slot, opener: (ctx: Ctx) => Account) => async (ctx: Ctx) => expect(await gitmirror.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");