import { expect } from "@playwright/test"; import { comments } from "../harness/api"; import { bobbin, button, commentId, component, flow, hydrant, knot, not, page, pds, record, replacing, subscriptionId, target, testid, text, through, ticketId, within } from "../harness/flow"; import { retry429 } from "../harness/net"; import { commented, deletesComment, made, note, onCommentedIssue, stillStored } from "../harness/shared"; const busy = 101; const saying = (at: number) => `comment ${String(at).padStart(3, "0")}`; const exactly = (words: string) => within(testid("commentBody"), text(words)); flow("more than a hundred comments still show the newest one") .actor("owner") .repo() .ticket(ticketId, { title: "a busy thread" }) .step(`${busy} comments`, async (ctx) => { const write = async (at: number) => { // a burst this big outlasts the usual back off, so it waits longer and tries more often const reply = await retry429( () => comments.add(ctx.actor, ctx.repo.did, ctx.get(ticketId), saying(at)), 10, 1_000 ); const written = made(`comment ${at}`, reply); ctx.adopt(commentId, written); return written; }; for (let from = 1; from < busy; from += 4) { const burst = Array.from({ length: Math.min(4, busy - from) }, (_, at) => from + at); await Promise.all(burst.map(write)); } await bobbin.indexed(await write(busy)); }) .open(page.issue(ticketId)) .sees(exactly(saying(busy))) .sees(not(exactly(saying(1)))) .click(button("Load older comments")) .sees(exactly(saying(1))) .sees(exactly(saying(busy))); const reply = record("org.tangled.feed.comment"); const reaction = record("org.tangled.feed.reaction"); const replyBox = within(testid("commentEditor", { mode: "reply" }), testid("body")); const replySubmit = within(testid("commentEditor", { mode: "reply" }), testid("submit")); const editBox = within(testid("commentEditor", { mode: "edit" }), testid("body")); const editSubmit = within(testid("commentEditor", { mode: "edit" }), testid("submit")); const discussion = within(testid("commentEditor", { mode: "new" }), testid("body")); const commentSubmit = within(testid("commentEditor", { mode: "new" }), testid("submit")); const threadTitle = "needs a thread"; const parentText = "the parent says this"; const replyText = "and the reply says that"; const editedParent = "the parent, but better"; const issuePicker = within(component("Ticket"), testid("react")); const commentPicker = within(component("CommentCard"), testid("react")); const thumbs = testid("handlePick", { kind: "👍" }); const chip = testid("reactionChip", { kind: "👍" }); const chipSaying = (count: number) => testid("reactionChip", { kind: "👍", count }); // replies sit in the ml-2xl column under their parent, top level comments never do const nested = (words: string) => within(testid("replies"), text(words)); const withOneComment = (name: string) => onCommentedIssue(name, { title: threadTitle, words: parentText, // a reply looks the subscription up in bobbin, and writes a second one if it's missing subscription: through(pds, hydrant, bobbin) }); withOneComment("replying under a comment nests it and survives a reload") .sees(not(nested(parentText))) .fill(replyBox, replyText) .click(replySubmit) .sends( reply, knot, (ctx) => ({ record: { subject: { uri: ctx.get(ticketId).uri }, replyTo: { uri: ctx.get(commentId).uri, cid: ctx.get(commentId).cid }, body: { text: replyText } } }), "create" ) .trace(reply, through(knot, hydrant, bobbin)) .sees(nested(replyText)) .reload() .sees(text(parentText)) .sees(nested(replyText)) .sees(not(nested(parentText))) .step("the knot's copy of the reply names the parent", async (ctx) => { const stored = await knot.has(ctx.get(reply)); if (stored.replyTo?.uri !== ctx.get(commentId).uri) throw new Error(`the reply says it answers ${stored.replyTo?.uri}`); }); flow("reacting to an issue and taking the reaction back") .actor("owner") .repo() .ticket(ticketId, { title: "react to me" }) .open(page.issue(ticketId)) .click(issuePicker) .click(thumbs) .sends( reaction, knot, (ctx) => ({ record: { reaction: "👍", subject: { uri: ctx.get(ticketId).uri } } }), "create" ) .sees(chipSaying(1)) .trace(reaction, through(knot, hydrant, bobbin)) .sees(chipSaying(1)) .reload() .sees(chipSaying(1)) .click(chip) .sends(reaction, knot, replacing(reaction), "delete") .sees(not(chip)) .trace(reaction, through(knot, hydrant, bobbin)) .sees(not(chip)) .reload() .sees(not(chip)); flow("a reaction from each of two people adds up and one taking theirs back leaves the other") .actor("owner") .repo() .ticket(ticketId, { title: "react to me twice" }) .open(page.issue(ticketId)) .click(issuePicker) .click(thumbs) .trace(reaction, through(knot, hydrant, bobbin)) .sees(chipSaying(1)) .actor("fan") .open(page.issue(ticketId)) .sees(chipSaying(1)) .click(chip) .sends(reaction, knot, { record: { reaction: "👍" } }, "create") .trace(reaction, through(knot, hydrant, bobbin)) .sees(chipSaying(2)) .reload() .sees(chipSaying(2)) .click(chip) .sends(reaction, knot, replacing(reaction), "delete") .trace(reaction, through(knot, hydrant, bobbin)) .sees(chipSaying(1)) .reload() .sees(chipSaying(1)) .actor("owner") .open(page.issue(ticketId)) .sees(chipSaying(1)); withOneComment("reacting to a comment and taking the reaction back") .click(commentPicker) .click(thumbs) .sends( reaction, knot, (ctx) => ({ record: { reaction: "👍", subject: { uri: ctx.get(commentId).uri, cid: ctx.get(commentId).cid } } }), "create" ) .trace(reaction, through(knot, hydrant, bobbin)) .sees(chipSaying(1)) .reload() .sees(chipSaying(1)) .click(chip) .sends(reaction, knot, replacing(reaction), "delete") .trace(reaction, through(knot, hydrant, bobbin)) .sees(not(chip)) .reload() .sees(not(chip)) .sees(text(parentText)); withOneComment("deleting a comment that has replies keeps the replies under a tombstone") .fill(replyBox, replyText) .click(replySubmit) .trace(reply, through(knot, hydrant, bobbin)) .sees(nested(replyText)) .use(deletesComment) .sees(testid("deletedCommentNotice")) .sees(not(text(parentText))) .sees(text(replyText)) .reload() .sees(testid("deletedCommentNotice")) .sees(not(text(parentText))) .sees(nested(replyText)) .step("the reply is still on the knot", async (ctx) => { await knot.has(ctx.get(reply)); }); // scoped to the edited card, so a chip elsewhere can't stand in for it const chipOnEdited = target("the 👍 chip on the edited comment", (page, ctx) => testid("commentCard") .locate(page, ctx) .filter({ hasText: editedParent }) .locator('[data-testid="reactionChip"][data-kind="👍"][data-count="1"]') ); withOneComment("editing a comment keeps its reactions on screen") .click(commentPicker) .click(thumbs) .trace(reaction, through(knot, hydrant, bobbin)) .sees(chipSaying(1)) .click(within(component("CommentCard"), testid("moreMenu"))) .click(testid("setEditingUri")) .fill(editBox, editedParent) .click(editSubmit) .sends( commentId, knot, replacing(commentId, (ctx) => ({ subject: { uri: ctx.get(ticketId).uri }, body: { text: editedParent } })), "put" ) .trace(commentId, through(knot, hydrant, bobbin)) .sees(text(editedParent)) // before the reload, which would hide it .sees(chipOnEdited) .reload() .sees(text(editedParent)) .sees(chipOnEdited); const title = "the build is broken"; const edited = "same here, again"; flow("commenting on an issue you opened") .actor("owner") .repo() .open(page.newIssue) .fill(testid("title"), title) .click(testid("submit")) .trace(ticketId, through(knot, hydrant, bobbin)) // opening subscribed the author, so the comment must not subscribe again .trace(subscriptionId, through(pds, hydrant, bobbin)) .open(page.issue(ticketId)) .sees(testid("toggleSubscription", { subscribed: "true" })) .fill(discussion, note) .click(commentSubmit) .trace(commentId, through(knot, hydrant, bobbin)) .sees(text(note)) .reload() .sees(text(note)) .sees(testid("toggleSubscription", { subscribed: "true" })); flow("commenting on an issue") .actor("owner") .repo() .ticket(ticketId, { title: commented }) .open(page.issue(ticketId)) .fill(discussion, note) .click(commentSubmit) // the knot only takes a comment that names the version of the ticket it saw .sends( commentId, knot, (ctx) => ({ record: { subject: { $type: "com.atproto.repo.strongRef", ...ctx.get(ticketId) } } }), "create" ) .sees(text(commented)) .sees(not(text(note))) .trace(commentId, through(knot, hydrant, bobbin)) .sees(text(note)) .trace(subscriptionId, through(pds)) .reload() .sees(text(note)); onCommentedIssue("editing a comment") .click(within(component("CommentCard"), testid("moreMenu"))) .click(testid("setEditingUri")) .fill(editBox, edited) .click(editSubmit) .sends( commentId, knot, // the knot wants an edit to resend the subject too replacing(commentId, (ctx) => ({ subject: { uri: ctx.get(ticketId).uri }, body: { text: edited } })), "put" ) .trace(commentId, through(knot, hydrant, bobbin)) .sees(text(edited)) .reload() .sees(text(edited)); onCommentedIssue("deleting a comment") .use(deletesComment) .sees(text(commented)) .sees(not(text(note))) .reload() .sees(text(commented)) .sees(not(text(note))); const draft = "typed and never sent"; const cancel = testid("cancel"); onCommentedIssue("cancelling a comment edit leaves the comment as it was") .click(within(component("CommentCard"), testid("moreMenu"))) .click(testid("setEditingUri")) .fill(editBox, edited) .click(cancel) .sendsNothing(commentId) .sees(text(note)) .sees(not(text(edited))) .reload() .sees(text(note)) .sees(not(text(edited))) .step( "the knot and hydrant still have the first version", stillStored(commentId, (value) => value.body.text, note) ); onCommentedIssue("a comment typed and never sent is not written") .fill(discussion, draft) .open(page.issues) .sendsNothing(commentId) .open(page.issue(ticketId)) .sees(not(text(draft))) .step("the box starts empty", async (ctx) => { await expect(discussion.locate(ctx.page, ctx)).toHaveValue(""); });