Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412import { expect } from "@playwright/test";
import { editCob, tickets } from "../harness/api";import { bobbin, commentId, component, type Ctx, flow, hydrant, knot, link, not, page, patchId, pds, pullId, record, replacing, subscriptionId, target, testid, text, through, ticketId, unique, where, within} from "../harness/flow";import { authorLink, bobbinNamesOpener, branches, fanOpened, deletesComment, deletesIssue, knotmirrorNamesOpener, issue, note, onBranches, rowOf, stillStored, theFan, theOwner} from "../harness/shared";
const closedTab = testid("segment", { segment: "closed" });
const hostile = [ "before the html", '<img src="x" onerror="window.__e2e=1"> <script>window.__e2e=2</script>', '<a href="javascript:window.__e2e=3">click me</a> <b onmouseover="window.__e2e=4">bold</b>', "after the html"].join(" ");
// scope to the body, the page's data scripts hold the raw text tooconst 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(not(testid("ticketRow")));
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<Ctx, string[]>();
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(testid("emptyState")) .click(closedTab) .sees(testid("nextPage")) .step("the first page lists thirty", async (ctx) => { const first = await pageItemNumbers(ctx); expect(first).toHaveLength(pageSize); onFirstPage.set(ctx, first); })
.click(testid("nextPage")) .step("the second page is still the closed tab", async (ctx) => { await expect(ctx.page).toHaveURL(/[?&]page=2/); await expect(closedTab.locate(ctx.page, ctx)).toHaveAttribute("aria-current", "page"); }) .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 = within(component("TicketSearch"), testid("q"));
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(testid("errorTitle", { status: "404" }));
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(testid("commentCount", { count: 0 }))
.open(page.issue(ticketId)) .fill(within(testid("commentEditor", { mode: "new" }), testid("body")), note) .click(within(testid("commentEditor", { mode: "new" }), testid("submit"))) .trace(commentId, through(knot, hydrant, bobbin)) .trace(subscriptionId, through(pds)) .sees(within(testid("commentBody"), text(note))) .open(page.issues) .sees(testid("commentCount", { count: 1 }))
.open(page.issue(ticketId)) .use(deletesComment) .sees(not(within(testid("commentBody"), text(note)))) .open(page.issues) .sees(testid("commentCount", { count: 0 }));
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(within(component("IssueForm"), testid("title")), title) .fill(within(component("IssueForm"), testid("body")), body) .click(within(component("IssueForm"), testid("submit"))) .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] of [ ["closing", "open", "closed"], ["reopening", "closed", "open"]] as const) { flow(`${verb} an issue`) .actor("owner") .repo() .ticket(ticketId, { title: `${verb} this`, state: from })
.open(page.issue(ticketId)) .changeState( ticketId, to, testid("requestStateChange", { state: from }), testid("requestStateChange", { state: to }) ) .reload() .sees(testid("requestStateChange", { state: to }));}
flow("editing an issue") .actor("owner") .repo() .ticket(ticketId, { title: "a typo in the titel" })
.open(page.issue(ticketId)) .click(within(component("Ticket"), testid("moreMenu"))) .click(testid("setEditing")) .fill(within(component("TicketForm"), testid("title")), fixedTitle) .click(within(component("TicketForm"), testid("submit"))) .sends(ticketId, knot, replacing(ticketId, { title: { text: fixedTitle } }), "put") .trace(ticketId, through(knot, hydrant, bobbin)) .sees(within(testid("ticketTitle"), text(fixedTitle))) .reload() .sees(within(testid("ticketTitle"), text(fixedTitle)));
// the kept ticket proves the list rendered, or "gone" would pass on an empty listconst 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";
flow("cancelling an issue edit leaves the issue as it was") .actor("owner") .repo() .ticket(ticketId, { title: oldTitle })
.open(page.issue(ticketId)) .click(within(component("Ticket"), testid("moreMenu"))) .click(testid("setEditing")) .fill(within(component("TicketForm"), testid("title")), newTitle) .click(within(component("TicketForm"), testid("cancel"))) .sendsNothing(ticketId) .sees(within(testid("ticketTitle"), text(oldTitle))) .sees(not(within(testid("ticketTitle"), text(newTitle)))) .reload() .sees(within(testid("ticketTitle"), 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(within(component("IssueForm"), testid("title")), newTitle) .fill(within(component("IssueForm"), testid("body")), "never mind") .click(within(component("IssueForm"), testid("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(testid("requestStateChange", { state: "open" }))
.window("second") .open(page.issue(ticketId)) .changeState( ticketId, "closed", testid("requestStateChange", { state: "open" }), testid("requestStateChange", { state: "closed" }) )
.window() .sees(testid("requestStateChange", { state: "open" })) .reload() .sees(testid("requestStateChange", { state: "closed" }));
// bobbin's issue list has no author filter, so this goes through search like the page doesconst closedBy = (who: (ctx: Ctx) => { handle: string }) => where("the closed issues one account opened", (ctx) => { const query = `is:closed author:${who(ctx).handle}`; return `${ctx.repo.path}/issues?q=${encodeURIComponent(query)}`; });
fanOpened("an issue the owner closed still belongs to whoever opened it") .open(page.issue(ticketId)) .changeState( ticketId, "closed", testid("requestStateChange", { state: "open" }), testid("requestStateChange", { state: "closed" }) ) .step( "bobbin names the fan as the opener everywhere, and the owner as no one's", bobbinNamesOpener(ticketId, "issue", theFan, theOwner) ) .reload() .sees(within(component("Ticket"), authorLink(theFan)))
.open(closedBy(theFan)) .sees(within(rowOf(ticketId), authorLink(theFan))) // the row showing proves the list loaded, so an empty owner list means something .open(closedBy(theOwner)) .sees(testid("emptyState")) .sees(not(rowOf(ticketId)));
fanOpened("an issue the owner edited still belongs to whoever opened it") // the edit button is for the author only, the owner has to use the api .step("the owner retitles it through the api", async (ctx) => { const edited = await editCob(ctx.actor, ctx.get(ticketId).uri, "retitle", (stored) => ({ ...stored, title: { ...stored.title, text: fixedTitle } })); expect(edited.status, JSON.stringify(edited.body)).toBe(200); }) .learn(ticketId) .follow(ticketId, through(knot, hydrant, bobbin)) .step( "bobbin names the fan as the opener everywhere, and the owner as no one's", bobbinNamesOpener(ticketId, "issue", theFan, theOwner) ) .open(page.issue(ticketId)) .sees(within(testid("ticketTitle"), text(fixedTitle))) .sees(within(component("Ticket"), authorLink(theFan)));
fanOpened("knotmirror lists the fan as the opener of an issue the owner closed") .step("the owner closes it through the api", async (ctx) => { await tickets.setState(ctx.actor, ctx.get(ticketId), "closed"); }) .learn(ticketId) .follow(ticketId, through(knot, hydrant, bobbin)) .knotStored(ticketId, "closed") .step("knotmirror names the fan", knotmirrorNamesOpener(ticketId, theFan));