import { 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", ' ', '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(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(); 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 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"; 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 does const 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));