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));