From 1a6ab6642bfd2452a716b2c19147e229d39a1e71 Mon Sep 17 00:00:00 2001 From: dawn Date: Sun, 11 Oct 2026 23:19:40 +0300 Subject: [PATCH] e2e: find elements by test id in the specs Signed-off-by: dawn Signed-off-by: eti --- e2e/specs/access.spec.ts | 91 ++++++++-------- e2e/specs/account.spec.ts | 118 ++++++++++----------- e2e/specs/comments.spec.ts | 92 ++++++++-------- e2e/specs/faults.spec.ts | 73 ++++++------- e2e/specs/git.spec.ts | 26 ++--- e2e/specs/issues.spec.ts | 106 ++++++++++--------- e2e/specs/knots.spec.ts | 49 ++++----- e2e/specs/labels.spec.ts | 57 ++++------ e2e/specs/merges.spec.ts | 58 +++++----- e2e/specs/permissions.spec.ts | 64 +++++------ e2e/specs/pipelines.spec.ts | 49 ++++----- e2e/specs/profile.spec.ts | 182 +++++++++++++++++--------------- e2e/specs/pulls.spec.ts | 143 ++++++++++++------------- e2e/specs/repo-settings.spec.ts | 56 ++++------ e2e/specs/repos.spec.ts | 99 +++++++---------- e2e/specs/search.spec.ts | 13 +-- e2e/specs/session.spec.ts | 52 ++++----- e2e/specs/social.spec.ts | 98 +++++++++-------- e2e/specs/strings.spec.ts | 39 +++---- 19 files changed, 686 insertions(+), 779 deletions(-) diff --git a/e2e/specs/access.spec.ts b/e2e/specs/access.spec.ts index 6de456557..9d59d3734 100644 --- a/e2e/specs/access.spec.ts +++ b/e2e/specs/access.spec.ts @@ -4,35 +4,31 @@ import { asKnot, editCob, getOk, knotRecord, knotReply } from "../harness/api"; import { config, seeded } from "../harness/config"; import { bobbin, - button, type Ctx, type Flow, - field, flow, git, hydrant, knot, knotCopy, link, - mergeButton, manifestId, not, - openDelete, page, patchId, pds, - placeholder, pullId, refsId, - refusedByPolicy, refusedTicketId, rkeyOf, subscriptionId, target, + testid, text, through, ticketId, - where + where, + within } from "../harness/flow"; import { lsRemote, push } from "../harness/git"; import { selfUri, until, xrpc } from "../harness/net"; @@ -102,16 +98,13 @@ const knotListing = (label: string, wanted: boolean) => const bobbinListing = (label: string, wanted: boolean) => listing("bobbin", config.bobbinUrl, label, wanted); -const helperHandle = target("the helper's handle", (tab, ctx) => - tab.getByText(recall(ctx, "helper").handle, { exact: true }) +const helperHandle = target("the helper's handle", (page, ctx) => + testid("collaboratorHandle", { handle: recall(ctx, "helper").handle }).locate(page, ctx) ); -const suggestion = target("the helper in the suggestions", (tab, ctx) => - tab.getByRole("option").filter({ hasText: recall(ctx, "helper").handle }) +const suggestion = target("the helper in the suggestions", (page, ctx) => + testid("selectActor", { handle: recall(ctx, "helper").handle }).locate(page, ctx) ); -const ownerOnly = "Only the repository owner can change this"; -const noPushAccess = "You need push access to this repository to change its settings"; - // only the knot's "aren't authorized" stderr counts, any other push failure does not const pushRefused = (label: string, branch: string) => async (ctx: Ctx) => { const who = recall(ctx, label); @@ -135,18 +128,18 @@ flow("the owner adds a collaborator from the access page") .actor("owner") .open(access) - .click(link("Add collaborator")) + .click(testid("addCollaborator")) .at(addCollaboratorForm) - .fill(placeholder("user.tngl.sh"), (ctx) => recall(ctx, "helper").handle) + .fill(testid("collaborator"), (ctx) => recall(ctx, "helper").handle) .click(suggestion) - .click(button("Add")) + .click(testid("invite")) .at(access) .step(...knotListing("helper", true)) .step(...bobbinListing("helper", true)) .reload() .sees(helperHandle) - .sees(button("Remove collaborator")) - .sees(not(button("Withdraw invite"))); + .sees(testid("remove", { standing: "collaborator" })) + .sees(not(testid("remove", { standing: "invited" }))); flow("the owner removes a collaborator from the access page") .actor("owner") @@ -156,22 +149,22 @@ flow("the owner removes a collaborator from the access page") .open(access) .sees(helperHandle) - .click(button("Remove collaborator")) + .click(testid("remove", { standing: "collaborator" })) .step(...knotListing("helper", false)) .step(...bobbinListing("helper", false)) .reload() .sees(not(helperHandle)) - .sees(not(button("Remove collaborator"))); + .sees(not(testid("remove", { standing: "collaborator" }))); flow("the access pages don't promise an accept step the knot never asks for") .actor("owner") .repo() .open(addCollaboratorForm) - .sees(text("Add collaborator")) - .sees(not(text("once they accept the invite"))) + .sees(testid("drillDownTitle")) + .sees(not(within(testid("drillDownDescription"), text("once they accept the invite")))) .open(access) - .sees(text("Collaborators")) - .sees(not(text("until they accept the offer"))); + .sees(testid("settingsBlockTitle")) + .sees(not(within(testid("settingsBlockDescription"), text("until they accept the offer")))); flow("a collaborator pushes a branch to the owner's repo") .actor("owner") @@ -231,8 +224,8 @@ flow("under the collaborators policy a collaborator opens an issue and a strange .actor("helper") .open(page.newIssue) - .fill(field("Title"), issueTitle) - .click(button("Create issue")) + .fill(testid("title"), issueTitle) + .click(testid("submit")) .sends( ticketId, knot, @@ -249,10 +242,10 @@ flow("under the collaborators policy a collaborator opens an issue and a strange .actor(seeded.charlie) .open(page.newIssue) - .fill(field("Title"), "let me in") - .click(button("Create issue")) + .fill(testid("title"), "let me in") + .click(testid("submit")) .refused(refusedTicketId, 403) - .sees(refusedByPolicy("collaborators")) + .sees(within(testid("errorAlert"), text("where contribution policy is collaborators"))) .open(page.issues) .sees(link(issueTitle)) .sees(not(link("let me in"))); @@ -263,11 +256,11 @@ onBranches("a collaborator closes and reopens the owner's pull request") .use(withHelper) .actor("helper") .open(page.pull(pullId)) - .sees(button("Close")) - .changeState(pullId, "closed", button("Close"), button("Reopen")) - .changeState(pullId, "open", button("Reopen"), button("Close")) + .sees(testid("requestClose")) + .changeState(pullId, "closed", testid("requestClose"), testid("changeState")) + .changeState(pullId, "open", testid("changeState"), testid("requestClose")) .reload() - .sees(button("Close")); + .sees(testid("requestClose")); onBranches("a collaborator merges the owner's pull request") .pull(pullId, patchId, branches) @@ -275,7 +268,7 @@ onBranches("a collaborator merges the owner's pull request") .use(withHelper) .actor("helper") .open(page.pull(pullId)) - .sees(mergeButton) + .sees(testid("mergeChoice")) .use(merges) .knotStored(pullId, "completed") .step( @@ -294,16 +287,16 @@ const closeThroughApi = async (ctx: Ctx) => { onBranches("a stranger sees no close, merge or label controls on a pull and the knot refuses them") .pull(pullId, patchId, { ...branches, title: strangersPull }) .open(page.pull(pullId)) - .sees(button("Close")) - .sees(mergeButton) - .sees(button("Edit labels")) + .sees(testid("requestClose")) + .sees(testid("mergeChoice")) + .sees(testid("editLabels")) .actor(seeded.charlie) .open(page.pull(pullId)) - .sees(text(strangersPull)) - .sees(not(button("Close"))) - .sees(not(mergeButton)) - .sees(not(button("Edit labels"))) + .sees(within(testid("ticketTitle"), text(strangersPull))) + .sees(not(testid("requestClose"))) + .sees(not(testid("mergeChoice"))) + .sees(not(testid("editLabels"))) .rejects( "charlie merging the pull", (ctx) => @@ -320,17 +313,17 @@ flow("only the owner is offered the delete button in the repo settings") .repo() .use(withHelper) .open(page.settings) - .sees(openDelete) + .sees(testid("setConfirmDelete")) .actor("helper") .open(page.settings) - .sees(text("Default branch")) - .sees(not(openDelete)) + .sees(testid("formDefaultBranch")) + .sees(not(testid("setConfirmDelete"))) .actor(seeded.alice) .open(page.settings) - .sees(text(noPushAccess)) - .sees(not(openDelete)); + .sees(testid("errorMessage")) + .sees(not(testid("setConfirmDelete"))); const aliceDeletes = async (ctx: Ctx) => { const reply = await asKnot(ctx.actor, "sh.tangled.repo.delete", { repo: ctx.repo.did }); @@ -383,6 +376,6 @@ flow("a collaborator can't add or remove collaborators") expect(await subjectsOf(config.knotUrl, ctx.repo.did)).toEqual([recall(ctx, "helper").did]); }) .open(access) - .sees(text(ownerOnly)) + .sees(testid("errorMessage")) .open(addCollaboratorForm) - .sees(text(ownerOnly)); + .sees(testid("errorMessage")); diff --git a/e2e/specs/account.spec.ts b/e2e/specs/account.spec.ts index 75b881ff5..7cc075602 100644 --- a/e2e/specs/account.spec.ts +++ b/e2e/specs/account.spec.ts @@ -3,28 +3,26 @@ import { expect, type Request, test } from "@playwright/test"; import { config, handleOf } from "../harness/config"; import { bobbin, - button, - confirmDelete, type Ctx, type Flow, field, flow, git, - heading, hydrant, keys, keysNew, knotCopy, - link, pds, profileSettings, publicKeyId, refsId, rkeyOf, target, + testid, text, through, - where + where, + within } from "../harness/flow"; import { keyOf, lsRemoteWith, makeKey } from "../harness/git"; import { get, stamp, until, xrpc } from "../harness/net"; @@ -35,9 +33,9 @@ const files = { "README.md": "pushed with a key from settings\n" }; const addsLaptopKey = (flow: Flow) => flow .open(keysNew) - .fill(field("Title"), "laptop") - .fill(field("Key"), (ctx) => keyOf(ctx).pub) - .click(button("Add")) + .fill(testid("name"), "laptop") + .fill(testid("key"), (ctx) => keyOf(ctx).pub) + .click(testid("submit")) .sends( publicKeyId, pds, @@ -73,28 +71,32 @@ flow("a key added in settings is listed with its fingerprint") .at(keys) .sees(text("laptop")) // the page computes the fingerprint, ssh-keygen is the reference - .sees(target("the key's fingerprint", (tab, ctx) => tab.getByText(keyOf(ctx).fingerprint))); + .sees( + target("the key's fingerprint", (tab, ctx) => + within(testid("keyFingerprint"), text(keyOf(ctx).fingerprint)).locate(tab, ctx) + ) + ); const notAKey = "hello, this is not a key"; flow("a title and some words that are not an ssh key are refused, not saved") .actor("typist") .open(keysNew) - .fill(field("Title"), "junk") - .fill(field("Key"), notAKey) - .click(button("Add")) + .fill(testid("name"), "junk") + .fill(testid("key"), notAKey) + .click(testid("submit")) .sendsNothing(publicKeyId) .open(keys) - .sees(text("No keys added yet")); + .sees(testid("settingsEmpty")); test("a visitor opening the verify email page without a token is told so", async ({ browser }) => { const context = await browser.newContext(); const tab = await context.newPage(); await tab.goto("/verify/email", { waitUntil: "networkidle" }); - await expect(tab.getByRole("heading", { name: "Verification failed" })).toBeVisible(); - await expect(tab.getByText("This verification link is missing its token.")).toBeVisible(); - await expect(tab.getByRole("link", { name: "Go to email settings" })).toBeVisible(); + await expect(testid("verificationFailedHeading").locate(tab, {} as Ctx)).toBeVisible(); + await expect(testid("verificationErrorMessage").locate(tab, {} as Ctx)).toBeVisible(); + await expect(testid("emails").locate(tab, {} as Ctx)).toBeVisible(); await context.close(); }); @@ -104,17 +106,14 @@ test("a visitor opening the verify email page with a token nobody issued is told const context = await browser.newContext(); const tab = await context.newPage(); await tab.goto(`/verify/email?token=not-a-real-token-${stamp()}`, { waitUntil: "networkidle" }); - await expect(tab.getByRole("heading", { name: "Verification failed" })).toBeVisible(); - await expect( - tab.getByText("The verification link is invalid or has already been used.") - ).toBeVisible(); + await expect(testid("verificationFailedHeading").locate(tab, {} as Ctx)).toBeVisible(); + await expect(testid("verificationErrorMessage").locate(tab, {} as Ctx)).toBeVisible(); await context.close(); }); const notifications = where("the notification settings", () => "/settings/notifications"); -const toggle = (label: string) => - target(`the ${label} switch`, (tab) => tab.getByRole("switch", { name: label, exact: true })); +const notificationToggle = (key: string) => testid("notificationToggle", { key }); const startedWith = new WeakMap(); const noteNotifications = async (ctx: Ctx) => { @@ -140,10 +139,10 @@ flow( .step("deliberi's starting preferences", noteNotifications) .open(notifications) - .click(toggle("Email notifications")) - .click(toggle("New followers")) + .click(notificationToggle("emailNotifications")) + .click(notificationToggle("followed")) .step("watch for the save", watchTheSave) - .click(button("Save")) + .click(testid("save")) .step("the page sent only the two switches that changed", async (ctx) => { const sent = await saves.get(ctx)!; expect(sent.postDataJSON()).toEqual(flipped); @@ -161,33 +160,30 @@ flow( }) .reload() .step("the page shows the saved switches", async (ctx) => { - const email = toggle("Email notifications").locate(ctx.page, ctx); - const followed = toggle("New followers").locate(ctx.page, ctx); + const email = notificationToggle("emailNotifications").locate(ctx.page, ctx); + const followed = notificationToggle("followed").locate(ctx.page, ctx); await expect(email).toBeChecked(); await expect(followed).not.toBeChecked(); - await expect(button("Save").locate(ctx.page, ctx)).toBeDisabled(); + await expect(testid("save").locate(ctx.page, ctx)).toBeDisabled(); }); const dangers = [ { - open: "Change password", + open: testid("password"), title: "Change password", - label: "Current password", - submit: "Send reset code", + submit: testid("sendResetCodeButton"), path: "password" }, { - open: "Deactivate account", + open: testid("deactivate"), title: "Deactivate account", - label: "Password", - submit: "Deactivate", + submit: testid("deactivateButton"), path: "deactivate" }, { - open: "Delete account", + open: testid("delete"), title: "Delete account", - label: "Password", - submit: "Send deletion code", + submit: testid("sendDeletionCodeButton"), path: "delete" } ]; @@ -195,25 +191,23 @@ const dangers = [ const settingsPage = (name: string) => where(`the ${name} form`, () => `/settings/profile/${name}`); for (const danger of dangers) { - flow( - `the ${danger.open.toLowerCase()} form stays off until a password is typed and cancel goes back` - ) + flow(`the ${danger.path} form stays off until a password is typed and cancel goes back`) .actor("owner") .open(profileSettings) - .click(link(danger.open)) + .click(danger.open) .at(settingsPage(danger.path)) - .sees(heading(danger.title)) + .sees(testid("drillDownTitle")) .step("the button that acts is off", async (ctx) => { - await expect(button(danger.submit).locate(ctx.page, ctx)).toBeDisabled(); + await expect(danger.submit.locate(ctx.page, ctx)).toBeDisabled(); }) - .fill(field(danger.label), "not the password") + .fill(testid("password"), "not the password") .step("the button that acts is on, and is left alone", async (ctx) => { - await expect(button(danger.submit).locate(ctx.page, ctx)).toBeEnabled(); + await expect(danger.submit.locate(ctx.page, ctx)).toBeEnabled(); }) - .click(link("Cancel")) + .click(testid("settings")) .at(profileSettings) - .sees(heading("Profile")); + .sees(testid("settingsHeaderTitle")); } const knotRefusesTheKey = (ctx: Ctx) => @@ -231,13 +225,13 @@ flow("removing a key in settings makes the knot refuse a push with it") .at(keys) .sees(text("laptop")) - .click(button("Delete")) - .click(confirmDelete) + .click(testid("confirmDelete")) + .click(within(testid("deleteConfirm"), testid("confirm"))) .sends(publicKeyId, pds, (ctx) => ({ rkey: rkeyOf(ctx, publicKeyId) }), "delete") .trace(publicKeyId, through(pds, hydrant, bobbin)) - .sees(text("No keys added yet")) + .sees(testid("settingsEmpty")) .reload() - .sees(text("No keys added yet")) + .sees(testid("settingsEmpty")) .step("the knot refuses the removed key", knotRefusesTheKey); // the non-GET requests, which the record holder never sees @@ -262,8 +256,8 @@ flow("changing the password sends a reset code") .actor("pwuser") .open(settingsPage("password")) .step("watch what the page sends", watchRequests) - .fill(field("Current password"), config.password) - .click(button("Send reset code")) + .fill(testid("password"), config.password) + .click(testid("sendResetCodeButton")) .step( "the page asks for a reset code", pageSends("a password reset request", /requestPasswordReset/) @@ -278,8 +272,8 @@ const repoStatus = async (did: string) => flow("deactivating an account deactivates it on the pds and signs the browser out") .actor("leaver", { login: "ui" }) .open(settingsPage("deactivate")) - .fill(field("Password"), config.password) - .click(button("Deactivate")) + .fill(testid("password"), config.password) + .click(testid("deactivateButton")) .step("the pds says the account is not active", (ctx) => until(`the pds should deactivate ${ctx.actor.handle}`, 10_000, async () => { const status = await repoStatus(ctx.actor.did); @@ -297,8 +291,8 @@ flow("deleting an account asks the pds to mail a deletion code and asks for it") .actor("goner") .open(settingsPage("delete")) .step("watch what the page sends", watchRequests) - .fill(field("Password"), config.password) - .click(button("Send deletion code")) + .fill(testid("password"), config.password) + .click(testid("sendDeletionCodeButton")) .step( "the page asks the pds for a deletion code", pageSends("a deletion code request", /requestAccountDelete/) @@ -315,13 +309,9 @@ flow("changing the handle gives the account the new handle") .actor("renmr") .step("pick a handle", pickHandle) .open(settingsPage("handle")) - .click( - target("the own domain switch", (tab) => - tab.getByRole("switch", { name: "I have my own domain" }) - ) - ) - .fill(field("Handle"), newHandle) - .click(button("Save")) + .click(testid("ownDomain")) + .fill(testid("handle"), newHandle) + .click(testid("submit")) .step("the pds knows the account by the new handle", (ctx) => until(`the pds should call ${ctx.actor.did} ${newHandle(ctx)}`, 15_000, async () => { const { body } = await get( diff --git a/e2e/specs/comments.spec.ts b/e2e/specs/comments.spec.ts index 31b6447c5..c92944e65 100644 --- a/e2e/specs/comments.spec.ts +++ b/e2e/specs/comments.spec.ts @@ -4,26 +4,23 @@ import { comments } from "../harness/api"; import { attestationId, bobbin, - button, commentId, - commentMenu, - discussion, - field, + component, flow, hydrant, knot, - menuItem, not, page, pds, - placeholder, record, replacing, subscriptionId, target, + testid, text, through, - ticketId + ticketId, + within } from "../harness/flow"; import { retry429 } from "../harness/net"; import { @@ -37,8 +34,7 @@ import { const busy = 101; const saying = (at: number) => `comment ${String(at).padStart(3, "0")}`; -const exactly = (words: string) => - target(`the comment saying ${words}`, (tab) => tab.getByText(words, { exact: true })); +const exactly = (words: string) => within(testid("commentBody"), text(words)); flow("more than a hundred comments still show the newest one") .actor("owner") @@ -70,29 +66,29 @@ flow("more than a hundred comments still show the newest one") const reply = record("org.tangled.feed.comment"); const reaction = record("org.tangled.feed.reaction"); -const replyBox = placeholder("Leave a reply..."); +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 = target("the issue's react button", (tab) => - tab.getByRole("button", { name: "React", exact: true }).first() -); -const commentPicker = target("the comment's react button", (tab) => - tab.getByRole("button", { name: "React", exact: true }).last() -); -const thumbs = button("React with πŸ‘"); -const chip = target("the πŸ‘ chip", (tab) => tab.getByRole("button", { name: /^πŸ‘/ })); +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) => - target(`the πŸ‘ chip saying ${count}`, (tab) => - tab.getByRole("button", { name: new RegExp(`^πŸ‘ ${count}$`) }) + target(`the πŸ‘ chip saying ${count}`, (page, ctx) => + within(testid("reactionChip", { kind: "πŸ‘" }), text(String(count))).locate(page, ctx) ); // replies sit in the ml-2xl column under their parent, top level comments never do -const nested = (words: string) => - target(`${words} indented under its parent`, (tab) => tab.locator(".ml-2xl").getByText(words)); +const nested = (words: string) => within(testid("replies"), text(words)); const withOneComment = (name: string) => onCommentedIssue(name, { @@ -105,7 +101,7 @@ const withOneComment = (name: string) => withOneComment("replying under a comment nests it and survives a reload") .sees(not(nested(parentText))) .fill(replyBox, replyText) - .click(button("Reply")) + .click(replySubmit) .sends( reply, knot, @@ -224,16 +220,16 @@ withOneComment("reacting to a comment and taking the reaction back") withOneComment("deleting a comment that has replies keeps the replies under a tombstone") .fill(replyBox, replyText) - .click(button("Reply")) + .click(replySubmit) .trace(reply, through(knot, hydrant, bobbin)) .sees(nested(replyText)) .use(deletesComment) - .sees(text("This comment was deleted.")) + .sees(testid("deletedCommentNotice")) .sees(not(text(parentText))) .sees(text(replyText)) .reload() - .sees(text("This comment was deleted.")) + .sees(testid("deletedCommentNotice")) .sees(not(text(parentText))) .sees(nested(replyText)) .step("the reply is still on the knot", async (ctx) => { @@ -241,11 +237,12 @@ withOneComment("deleting a comment that has replies keeps the replies under a to }); // scoped to the edited card, so a chip elsewhere can't stand in for it -const chipOnEdited = target("the πŸ‘ chip on the edited comment", (tab) => - tab - .locator("div.group") +const chipOnEdited = target("the πŸ‘ chip on the edited comment", (page, ctx) => + testid("commentCard") + .locate(page, ctx) .filter({ hasText: editedParent }) - .getByRole("button", { name: /^πŸ‘ 1$/ }) + .locator('[data-testid="reactionChip"][data-kind="πŸ‘"]') + .filter({ hasText: "1" }) ); withOneComment("editing a comment keeps its reactions on screen") @@ -255,10 +252,10 @@ withOneComment("editing a comment keeps its reactions on screen") .trace(attestationId, through(pds)) .sees(chipSaying(1)) - .click(commentMenu) - .click(menuItem("Edit comment")) - .fill(placeholder("Edit your comment. Markdown is supported."), editedParent) - .click(button("Save")) + .click(within(component("CommentCard"), testid("moreMenu"))) + .click(testid("setEditingUri")) + .fill(editBox, editedParent) + .click(editSubmit) .sends( commentId, knot, @@ -285,21 +282,21 @@ flow("commenting on an issue you opened") .repo() .open(page.newIssue) - .fill(field("Title"), title) - .click(button("Create issue")) + .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(button("Unsubscribe")) + .sees(testid("toggleSubscription", { subscribed: "true" })) .fill(discussion, note) - .click(button("Comment")) + .click(commentSubmit) .trace(commentId, through(knot, hydrant, bobbin)) .sees(text(note)) .reload() .sees(text(note)) - .sees(button("Unsubscribe")); + .sees(testid("toggleSubscription", { subscribed: "true" })); flow("commenting on an issue") .actor("owner") @@ -308,7 +305,7 @@ flow("commenting on an issue") .open(page.issue(ticketId)) .fill(discussion, note) - .click(button("Comment")) + .click(commentSubmit) // the knot only takes a comment that names the version of the ticket it saw .sends( commentId, @@ -327,10 +324,10 @@ flow("commenting on an issue") .sees(text(note)); onCommentedIssue("editing a comment") - .click(commentMenu) - .click(menuItem("Edit comment")) - .fill(placeholder("Edit your comment. Markdown is supported."), edited) - .click(button("Save")) + .click(within(component("CommentCard"), testid("moreMenu"))) + .click(testid("setEditingUri")) + .fill(editBox, edited) + .click(editSubmit) .sends( commentId, knot, @@ -356,12 +353,11 @@ onCommentedIssue("deleting a comment") const draft = "typed and never sent"; -const editBox = placeholder("Edit your comment. Markdown is supported."); -const cancel = button("Cancel"); +const cancel = testid("cancel"); onCommentedIssue("cancelling a comment edit leaves the comment as it was") - .click(commentMenu) - .click(menuItem("Edit comment")) + .click(within(component("CommentCard"), testid("moreMenu"))) + .click(testid("setEditingUri")) .fill(editBox, edited) .click(cancel) .sendsNothing(commentId) diff --git a/e2e/specs/faults.spec.ts b/e2e/specs/faults.spec.ts index ed7d7b8e1..5d9cb972d 100644 --- a/e2e/specs/faults.spec.ts +++ b/e2e/specs/faults.spec.ts @@ -4,17 +4,13 @@ import { getOk } from "../harness/api"; import { config } from "../harness/config"; import { bobbin, - button, commentId, + component, type Ctx, - discussion, - field, flow, hydrant, knot, link, - menuItem, - moreActions, not, page, pds, @@ -25,18 +21,16 @@ import { subscriptionId, target, text, + testid, through, - ticketId + ticketId, + within } from "../harness/flow"; import { sleep, until, type Written, xrpc } from "../harness/net"; import { answerWith, commented, jsonError, note } from "../harness/shared"; -const alertSaying = (words: string) => - target(`the alert saying ${words}`, (tab) => tab.getByRole("alert").filter({ hasText: words })); -const statusSaying = (words: string) => - target(`the status saying ${words}`, (tab) => - tab.getByRole("status").filter({ hasText: words }) - ); +const alertSaying = (words: string) => within(testid("errorAlert"), text(words)); +const statusSaying = (words: string) => within(testid("stateChangeError"), text(words)); const knotRecords = async (ctx: Ctx, nsid: string) => { const listed = await getOk( @@ -81,7 +75,7 @@ flow("a star the pds refuses rolls back and the button says so") .open(page.repo) .step("the pds will refuse the next star", racedByAnotherWrite) - .click(button("Star")) + .click(testid("star", { starred: "false" })) .sends( starId, pds, @@ -90,17 +84,17 @@ flow("a star the pds refuses rolls back and the button says so") }), "create" ) - .sees(button("Starred")) + .sees(testid("star", { starred: "true" })) .sees(starCount, "1") .refused(starId, 400) - .sees(button("Star")) + .sees(testid("star", { starred: "false" })) .sees(starCount, "0") .sees(failedFace) .step("bobbin counts no stars", async (ctx) => expect(await bobbin.countStars(ctx.repo.did)).toBe(0) ) .reload() - .sees(button("Star")) + .sees(testid("star", { starred: "false" })) .sees(starCount, "0"); // a real burst is hard to hit on purpose, so the 429 is faked for one comment @@ -115,6 +109,8 @@ const limitsTheNextComment = async (ctx: Ctx) => { }); }; +const discussion = testid("body"); + flow("a comment the knot rate limits keeps its text and can be sent again") .actor("owner") .repo() @@ -123,14 +119,14 @@ flow("a comment the knot rate limits keeps its text and can be sent again") .open(page.issue(ticketId)) .step("the knot will rate limit the next comment", limitsTheNextComment) .fill(discussion, note) - .click(button("Comment")) + .click(testid("submit")) .sees(alertSaying(rateLimited)) .step("the box still holds the text", async (ctx) => expect(discussion.locate(ctx.page, ctx)).toHaveValue(note) ) .sendsNothing(commentId) - .click(button("Comment")) + .click(testid("submit")) .sends( commentId, knot, @@ -187,14 +183,14 @@ flow("reloading while a comment is still sending leaves it posted once") .open(page.issue(ticketId)) .step("the knot is slow to answer", slowAnswer) .fill(discussion, note) - .click(button("Comment")) + .click(testid("submit")) .step("the knot has the comment and the page is still waiting", async (ctx) => { const [stored] = await until("the knot should have the comment", 10_000, async () => { const records = await knotRecords(ctx, commentId.nsid); return [records.length === 1, `it lists ${records.length}`, records]; }); sentComment.set(ctx, stored); - await expect(ctx.page.getByRole("button", { name: "Comment", exact: true })).toBeDisabled(); + await expect(testid("submit").locate(ctx.page, ctx)).toBeDisabled(); }) .step("the page is reloaded", async (ctx) => { await ctx.page.reload({ waitUntil: "networkidle" }); @@ -218,7 +214,7 @@ flow("reloading while a comment is still sending leaves it posted once") const familyOf = (graphemes: number) => "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦".repeat(graphemes); const tooLong = familyOf(60); const trimmed = familyOf(40); -const titleField = field("Title"); +const titleField = testid("title"); flow("a title over the byte limit is refused with a reason and a shorter one saves") .actor("owner") @@ -226,16 +222,16 @@ flow("a title over the byte limit is refused with a reason and a shorter one sav .open(page.newIssue) .fill(titleField, tooLong) - .click(button("Create issue")) + .click(testid("submit")) .sends(refusedTicketId, knot, { record: { title: { text: tooLong } } }, "create") .refused(refusedTicketId, 400) - .sees(target("an alert", (tab) => tab.getByRole("alert"))) + .sees(testid("errorAlert")) .step("the form still holds the title", async (ctx) => - expect(ctx.page.getByLabel("Title")).toHaveValue(tooLong) + expect(titleField.locate(ctx.page, ctx)).toHaveValue(tooLong) ) .fill(titleField, trimmed) - .click(button("Create issue")) + .click(testid("submit")) .sends(ticketId, knot, { record: { title: { text: trimmed } } }, "create") .trace(ticketId, through(knot, hydrant, bobbin)) .trace(subscriptionId, through(pds)) @@ -251,13 +247,13 @@ flow("creating an issue the knot saved while bobbin was slow does not make a sec .open(page.newIssue) .step("bobbin fails the page's wait", bobbinFails) .fill(titleField, slowTitle) - .click(button("Create issue")) + .click(testid("submit")) .sends(ticketId, knot, { record: { title: { text: slowTitle } } }, "create") .trace(ticketId, through(knot, hydrant, bobbin)) .sees(alertSaying(notCaughtUp)) .step("bobbin answers the page again", bobbinRecovers) - .click(button("Create issue")) + .click(testid("submit")) .sendsNothing(ticketId) .step("the knot has one issue with that title", async (ctx) => { const stored = await knotRecords(ctx, ticketId.nsid); @@ -271,15 +267,15 @@ flow("closing an issue the knot took while bobbin was slow shows it closed") .open(page.issue(ticketId)) .step("bobbin fails the page's wait", bobbinFails) - .click(button("Close issue")) + .click(testid("requestStateChange", { state: "open" })) .sends(ticketId, knot, replacing(ticketId, { state: "closed" }), "put") .trace(ticketId, through(knot, hydrant, bobbin)) .knotStored(ticketId, "closed") .sees(statusSaying(notCaughtUp)) - .sees(button("Reopen issue")) + .sees(testid("requestStateChange", { state: "closed" })) .step("bobbin answers the page again", bobbinRecovers) .reload() - .sees(button("Reopen issue")); + .sees(testid("requestStateChange", { state: "closed" })); // guards openTicket and openComment against probing, since a failed probe falls back to the pds const probedTitle = "written while the probe fails"; @@ -291,7 +287,7 @@ flow("an issue opened while the knot's capability probe fails still lands on the .open(page.newIssue) .step("the knot's capability probe fails", probeFails) .fill(titleField, probedTitle) - .click(button("Create issue")) + .click(testid("submit")) .sends( ticketId, knot, @@ -314,7 +310,7 @@ flow("a comment sent while the knot's capability probe fails still lands on the .open(page.issue(ticketId)) .step("the knot's capability probe fails", probeFails) .fill(discussion, note) - .click(button("Comment")) + .click(testid("submit")) .sends( commentId, knot, @@ -332,6 +328,7 @@ const firstTitle = "a title before anyone edits it"; const secondTitle = "retitled from the second window"; const staleTitle = "retitled from the stale window"; const loaded = new WeakMap(); +const moreActions = testid("moreMenu"); flow("an edit from a page that loaded before another edit is refused") .actor("owner") @@ -346,18 +343,18 @@ flow("an edit from a page that loaded before another edit is refused") .window("second") .open(page.issue(ticketId)) .click(moreActions) - .click(menuItem("Edit")) + .click(testid("setEditing")) .fill(titleField, secondTitle) - .click(button("Save")) + .click(within(component("TicketForm"), testid("submit"))) .sends(ticketId, knot, replacing(ticketId, { title: { text: secondTitle } }), "put") .trace(ticketId, through(knot, hydrant, bobbin)) - .sees(text(secondTitle)) + .sees(within(testid("ticketTitle"), text(secondTitle))) .window() .click(moreActions) - .click(menuItem("Edit")) + .click(testid("setEditing")) .fill(titleField, staleTitle) - .click(button("Save")) + .click(within(component("TicketForm"), testid("submit"))) .sends( refusedTicketId, knot, @@ -368,7 +365,7 @@ flow("an edit from a page that loaded before another edit is refused") "put" ) .refused(refusedTicketId, 409) - .sees(target("an alert", (tab) => tab.getByRole("alert"))) + .sees(testid("errorAlert")) .step("the knot still has the second title", async (ctx) => { const stored = await knot.has(ctx.get(ticketId)); expect(stored.title.text).toBe(secondTitle); diff --git a/e2e/specs/git.spec.ts b/e2e/specs/git.spec.ts index f58f77e4b..64aa25757 100644 --- a/e2e/specs/git.spec.ts +++ b/e2e/specs/git.spec.ts @@ -12,12 +12,12 @@ import { type Account, config } from "../harness/config"; import { bobbin, branchesTab, - button, confirmDelete, type Ctx, exactLink, flow, git, + heading, hydrant, knot, knotCopy, @@ -29,8 +29,10 @@ import { refsId, target, text, + testid, through, - where + where, + within } from "../harness/flow"; import { lsRemote, sshAs } from "../harness/git"; import { dispatcher, until } from "../harness/net"; @@ -107,11 +109,7 @@ flow("a readme on a branch renders and its relative link goes to the file") .mirrored() .open(treeAt("feature/readme")) - .sees( - target("the readme's heading", (tab) => - tab.getByRole("heading", { name: "readme from a branch", level: 1 }) - ) - ) + .sees(within(testid("renderedMarkdownBody"), heading("readme from a branch"))) .click(exactLink("the code")) .sees(text("linked file")); @@ -154,18 +152,13 @@ flow("an annotated tag shows under tags with its message") .open(tagsTab) .sees(exactLink("v1.0")) - .sees(text("the first release")) + .sees(within(testid("tagMessageTitle"), text("the first release"))) .click(exactLink("v1.0")) .at(where("the tag's page", (ctx) => `${ctx.repo.path}/tags/v1.0`)) .sees(text("the first release")); -const cloneUrl = (label: "SSH" | "HTTPS") => - target(`the ${label} clone url`, (tab) => - tab - .getByText(label, { exact: true }) - .locator("xpath=following-sibling::div[1]//span[contains(@class,'select-all')]") - ); -const codeMenu = button("Clone this repository"); +const cloneUrl = (label: "SSH" | "HTTPS") => testid("cloneUrlValue", { label }); +const codeMenu = within(testid("cloneThisRepository"), testid("toggleMenu")); flow("the ssh clone url in the code menu is one git accepts") .actor("owner") @@ -221,9 +214,10 @@ const zipNames = (zip: Buffer) => { }; const downloadOf = async (ctx: Ctx, name: string) => { + const format = name === ".zip" ? "zip" : name; const [download] = await Promise.all([ ctx.page.waitForEvent("download"), - ctx.page.getByRole("link", { name, exact: true }).click() + testid("downloadArchiveLink", { format }).locate(ctx.page, ctx).click() ]); const saved = join(tmpdir(), `e2e-archive-${Date.now()}-${download.suggestedFilename()}`); await download.saveAs(saved); diff --git a/e2e/specs/issues.spec.ts b/e2e/specs/issues.spec.ts index e393263d3..d564be9f8 100644 --- a/e2e/specs/issues.spec.ts +++ b/e2e/specs/issues.spec.ts @@ -2,30 +2,28 @@ import { expect } from "@playwright/test"; import { bobbin, - button, commentId, + component, type Ctx, - field, flow, hydrant, knot, link, - menuItem, - moreActions, not, page, patchId, pds, - placeholder, pullId, record, replacing, subscriptionId, target, + testid, text, through, ticketId, - unique + unique, + within } from "../harness/flow"; import { branches, @@ -37,7 +35,7 @@ import { stillStored } from "../harness/shared"; -const closedTab = target("the closed tab", (tab) => tab.getByRole("link", { name: "Closed" })); +const closedTab = testid("segment", { segment: "closed" }); const hostile = [ "before the html", @@ -97,7 +95,7 @@ flow("a closed tab with nothing closed says so") .sees(link("still open")) .click(closedTab) .sees(not(link("still open"))) - .sees(text("No closed issues")); + .sees(not(testid("ticketRow"))); const pageItemNumbers = async (ctx: Ctx) => { const names = await ctx.page.getByRole("link", { name: /page item \d+/ }).allInnerTexts(); @@ -122,16 +120,16 @@ flow("paging past the first thirty issues keeps the closed tab") }) .open(page.issues) - .sees(text("No issues yet.")) + .sees(testid("emptyState")) .click(closedTab) - .sees(target("the next page button", (tab) => tab.getByRole("button", { name: "Next page" }))) + .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(button("Next page")) + .click(testid("nextPage")) .step("the second page is still the closed tab", async (ctx) => { await expect(ctx.page).toHaveURL(/state=closed&page=2|page=2&state=closed/); await expect(ctx.page.getByRole("link", { name: `${many} Closed` })).toBeVisible(); @@ -144,7 +142,7 @@ flow("paging past the first thirty issues keeps the closed tab") const other = record("org.tangled.track.ticket"); -const searchIssues = field("Search issues"); +const searchIssues = within(component("TicketSearch"), testid("q")); const needle = unique("search needle"); const hay = unique("unrelated"); @@ -174,7 +172,7 @@ onBranches("the issues list leaves out pull requests") .sees(link(issued)) .sees(not(link(pulled))) .open(page.issue(pullId)) - .sees(text("Page not found")); + .sees(testid("errorTitle", { status: "404" })); const countMe = unique("count me"); @@ -184,22 +182,22 @@ flow("the comment count on the issue list follows comments and their erase") .ticket(ticketId, { title: countMe }) .open(page.issues) - .sees(text("0 comments")) + .sees(testid("commentCount", { count: 0 })) .open(page.issue(ticketId)) - .fill(placeholder("Add to the discussion. Markdown is supported."), note) - .click(button("Comment")) + .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(text(note)) + .sees(within(testid("commentBody"), text(note))) .open(page.issues) - .sees(text("1 comment")) + .sees(testid("commentCount", { count: 1 })) .open(page.issue(ticketId)) .use(deletesComment) - .sees(not(text(note))) + .sees(not(within(testid("commentBody"), text(note)))) .open(page.issues) - .sees(text("0 comments")); + .sees(testid("commentCount", { count: 0 })); const title = "the build is broken"; const body = "it says no"; @@ -211,9 +209,9 @@ flow("opening an issue") .repo() .open(page.newIssue) - .fill(field("Title"), title) - .fill(field("Body"), body) - .click(button("Create issue")) + .fill(within(component("IssueForm"), testid("title")), title) + .fill(within(component("IssueForm"), testid("body")), body) + .click(within(component("IssueForm"), testid("submit"))) .sends( ticketId, knot, @@ -236,9 +234,9 @@ flow("opening an issue") ) .trace(subscriptionId, through(pds)); -for (const [verb, from, to, label, settled] of [ - ["closing", "open", "closed", "Close issue", "Reopen issue"], - ["reopening", "closed", "open", "Reopen issue", "Close issue"] +for (const [verb, from, to] of [ + ["closing", "open", "closed"], + ["reopening", "closed", "open"] ] as const) { flow(`${verb} an issue`) .actor("owner") @@ -246,9 +244,14 @@ for (const [verb, from, to, label, settled] of [ .ticket(ticketId, { title: `${verb} this`, state: from }) .open(page.issue(ticketId)) - .changeState(ticketId, to, button(label), button(settled)) + .changeState( + ticketId, + to, + testid("requestStateChange", { state: from }), + testid("requestStateChange", { state: to }) + ) .reload() - .sees(button(settled)); + .sees(testid("requestStateChange", { state: to })); } flow("editing an issue") @@ -257,15 +260,15 @@ flow("editing an issue") .ticket(ticketId, { title: "a typo in the titel" }) .open(page.issue(ticketId)) - .click(moreActions) - .click(menuItem("Edit")) - .fill(field("Title"), fixedTitle) - .click(button("Save")) + .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(text(fixedTitle)) + .sees(within(testid("ticketTitle"), text(fixedTitle))) .reload() - .sees(text(fixedTitle)); + .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"); @@ -285,23 +288,21 @@ flow("deleting an issue") const oldTitle = "a typo in the titel"; const newTitle = "a typo in the title"; -const cancel = button("Cancel"); - flow("cancelling an issue edit leaves the issue as it was") .actor("owner") .repo() .ticket(ticketId, { title: oldTitle }) .open(page.issue(ticketId)) - .click(moreActions) - .click(menuItem("Edit")) - .fill(field("Title"), newTitle) - .click(cancel) + .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(text(oldTitle)) - .sees(not(text(newTitle))) + .sees(within(testid("ticketTitle"), text(oldTitle))) + .sees(not(within(testid("ticketTitle"), text(newTitle)))) .reload() - .sees(text(oldTitle)) + .sees(within(testid("ticketTitle"), text(oldTitle))) .step( "the knot and hydrant still have the first version", stillStored(ticketId, (value) => value.title.text, oldTitle) @@ -312,9 +313,9 @@ flow("cancelling a new issue writes nothing") .repo() .open(page.newIssue) - .fill(field("Title"), newTitle) - .fill(field("Body"), "never mind") - .click(cancel) + .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))) @@ -327,13 +328,18 @@ flow("an issue closed in another window shows closed after a reload") .ticket(ticketId, { title: "closed from the other window" }) .open(page.issue(ticketId)) - .sees(button("Close issue")) + .sees(testid("requestStateChange", { state: "open" })) .window("second") .open(page.issue(ticketId)) - .changeState(ticketId, "closed", button("Close issue"), button("Reopen issue")) + .changeState( + ticketId, + "closed", + testid("requestStateChange", { state: "open" }), + testid("requestStateChange", { state: "closed" }) + ) .window() - .sees(button("Close issue")) + .sees(testid("requestStateChange", { state: "open" })) .reload() - .sees(button("Reopen issue")); + .sees(testid("requestStateChange", { state: "closed" })); diff --git a/e2e/specs/knots.spec.ts b/e2e/specs/knots.spec.ts index 878ed290e..e4c4303d7 100644 --- a/e2e/specs/knots.spec.ts +++ b/e2e/specs/knots.spec.ts @@ -3,19 +3,16 @@ import { expect } from "@playwright/test"; import { getOk } from "../harness/api"; import { config } from "../harness/config"; import { - button, type Ctx, - field, flow, - heading, knots, - link, newKnot, newSpindle, not, page, spindles, target, + testid, text, where } from "../harness/flow"; @@ -77,10 +74,10 @@ flow("joining a knot from an invite link makes you a member and saves the knot") .step("the knot mints an invite code", mintInviteCode) .open(invitePage(codeOf)) - .sees(heading(`Join ${knotHost}`)) + .sees(testid("joinHeading", { knot: knotHost })) .joinedKnot() .step("watch for the page's save", watchTheSave) - .click(button(`Join ${knotHost}`)) + .click(testid("join")) .step("the knot lists the guest as a member", becomesMember) .step("the page has saved the knot", theSaveIsDone) @@ -93,10 +90,10 @@ flow("a joined knot says you are in") .open(invitePage(codeOf)) .joinedKnot() - .click(button(`Join ${knotHost}`)) + .click(testid("join")) .step("the knot lists the guest as a member", becomesMember) - .sees(text("You're in")) - .sees(link("Your knots")); + .sees(testid("youreInHeading")) + .sees(testid("knots")); const failSavedList = answerWith( /com\.bad-example\.pocket\.getPreferences/, @@ -112,15 +109,15 @@ const addedKnot = "knot.added.example.test"; flow("adding a knot keeps the saved spindles when the saved list cannot be read") .actor("saver") .open(newSpindle) - .fill(field("Spindle address"), savedSpindle) - .click(button("Add")) + .fill(testid("address"), savedSpindle) + .click(testid("submit")) .at(spindles) .sees(text(savedSpindle)) .open(newKnot) .step("the saved list can't be read", failSavedList) - .fill(field("Knot address"), addedKnot) - .click(button("Add")) + .fill(testid("address"), addedKnot) + .click(testid("submit")) .at(knots) .step("the saved list can be read again", restoreSavedList) .reload() @@ -145,13 +142,9 @@ const savedBecomes = (saved: (ctx: Ctx) => Saved) => async (ctx: Ctx) => { ); }; -// the row is the closest min-h-2xl div above the host text const removeFor = (pick: Pick) => target(`Remove on the row of the ${pick} host`, (tab, ctx) => - tab - .getByText(named(pick)(ctx), { exact: true }) - .locator("xpath=ancestor::div[contains(@class, 'min-h-2xl')][1]") - .getByRole("button", { name: "Remove", exact: true }) + testid("remove", { host: named(pick)(ctx) }).locate(tab, ctx) ); const savedHost = (pick: Pick) => target(`the saved ${pick} host`, (tab, ctx) => @@ -175,8 +168,8 @@ flow("a knot saved in settings is offered when creating a repo") .step("pick hosts", pickHosts) .open(newKnot) - .fill(field("Knot address"), named("a")) - .click(button("Add")) + .fill(testid("address"), named("a")) + .click(testid("submit")) .at(knots) .sees(savedHost("a")) .step( @@ -187,7 +180,7 @@ flow("a knot saved in settings is offered when creating a repo") .open(page.newRepo) .sees( target("the saved knot among the choices", (tab, ctx) => - tab.getByRole("radio", { name: named("a")(ctx) }) + testid("choice", { value: named("a")(ctx) }).locate(tab, ctx) ) ); @@ -221,17 +214,17 @@ flow("removing a saved spindle keeps the saved knot") .open(spindles) .click(removeFor("s")) - .sees(text("No spindles saved yet")) + .sees(testid("settingsEmpty")) .step( "pocket keeps the knot and no spindle", savedBecomes((ctx) => ({ knots: [named("a")(ctx)], spindles: [] })) ) .reload() - .sees(text("No spindles saved yet")) + .sees(testid("settingsEmpty")) .open(knots) .sees(savedHost("a")); -const addKnotButton = button("Add"); +const addKnotButton = testid("submit"); flow("the add knot form is off until an address is typed and cancel saves nothing") .actor("owner") @@ -241,13 +234,13 @@ flow("the add knot form is off until an address is typed and cancel saves nothin .step("Add is off", async (ctx) => { await expect(addKnotButton.locate(ctx.page, ctx)).toBeDisabled(); }) - .fill(field("Knot address"), named("a")) + .fill(testid("address"), named("a")) .step("Add is on, and is left alone", async (ctx) => { await expect(addKnotButton.locate(ctx.page, ctx)).toBeEnabled(); }) - .click(link("Cancel")) + .click(testid("knots")) .at(knots) - .sees(text("No knots saved yet")) + .sees(testid("settingsEmpty")) .step( "pocket keeps nothing", savedBecomes(() => ({ knots: [], spindles: [] })) @@ -258,7 +251,7 @@ flow("a knot typed with https and a path is saved as just its host") .step("pick hosts", pickHosts) .open(newKnot) - .fill(field("Knot address"), (ctx) => `https://${named("a")(ctx).toUpperCase()}/some/path`) + .fill(testid("address"), (ctx) => `https://${named("a")(ctx).toUpperCase()}/some/path`) .click(addKnotButton) .at(knots) .sees(savedHost("a")) diff --git a/e2e/specs/labels.spec.ts b/e2e/specs/labels.spec.ts index 8848b7c8a..40bb2105c 100644 --- a/e2e/specs/labels.spec.ts +++ b/e2e/specs/labels.spec.ts @@ -3,11 +3,10 @@ import { expect } from "@playwright/test"; import { editCob, knotRecordFor } from "../harness/api"; import { bobbin, - button, confirmDelete, + createOption, type Ctx, editLabels, - field, flow, hydrant, knot, @@ -17,9 +16,8 @@ import { newLabel, not, page, - placeholder, record, - target, + testid, text, through, ticketId, @@ -61,8 +59,8 @@ flow("a label chip opens the issue list filtered to that label") .step("label the first ticket", labelTicket) .open(page.issue(ticketId)) - .sees(link(triage)) - .click(link(triage)) + .sees(triageChip) + .click(triageChip) .at(labelList) .sees(link(labelled)) .sees(not(link(plain))); @@ -73,20 +71,18 @@ flow("the repo owner can make a label from the issue sidebar") .ticket(ticketId, { title: labelled }) .open(page.issue(ticketId)) - .click(button("Edit labels")) - .fill(placeholder("Filter labels…"), triage) - .sees( - target(`option Create "${triage}"`, (tab) => tab.getByRole("option", { name: /Create/ })) - ); + .click(editLabels) + .fill(testid("search"), triage) + .sees(createOption); flow("a label made with spaces and a chosen colour is stored under its slug") .actor("owner") .repo() .open(newLabel) - .fill(field("Name"), "Good First Issue") - .click(button("Use colour #3b82f6")) - .click(button("Add")) + .fill(testid("name"), "Good First Issue") + .click(testid("setColor", { value: "#3b82f6" })) + .click(testid("create")) .sends( labelDefinitionId, knot, @@ -99,9 +95,9 @@ flow("a label made with spaces and a chosen colour is stored under its slug") ) .trace(labelDefinitionId, through(knot, hydrant, bobbin)) .at(page.settings) - .sees(button("Delete Good First Issue")) + .sees(testid("confirmLabelDelete", { name: "Good First Issue" })) .reload() - .sees(button("Delete Good First Issue")); + .sees(testid("confirmLabelDelete", { name: "Good First Issue" })); flow("putting a label on an issue and taking it off") .actor("owner") @@ -114,7 +110,7 @@ flow("putting a label on an issue and taking it off") .use(labelOff(ticketId, "ticket")); const settings = where("the repo's settings", (ctx) => `${ctx.repo.path}/settings`); -const deleteLabel = button(`Delete ${triage}`); +const deleteLabel = testid("confirmLabelDelete", { name: triage }); flow("deleting a label definition takes it off the issue") .actor("owner") @@ -122,8 +118,8 @@ flow("deleting a label definition takes it off the issue") .ticket(ticketId, { title: "labelled, then not" }) .open(newLabel) - .fill(field("Name"), triage) - .click(button("Add")) + .fill(testid("name"), triage) + .click(testid("create")) .sends( labelDefinitionId, knot, @@ -165,9 +161,7 @@ flow("deleting a label definition takes it off the issue") expect(await labelsOnKnot(ctx, ticketId)).toEqual([]) ); -const taken = target("the refusal in the form", (tab) => - tab.getByRole("alert").filter({ hasText: "a label def already stands at" }) -); +const taken = within(testid("errorAlert"), text("a label def already stands at")); flow("a label name the repo already has is refused by the form") .actor("owner") @@ -175,8 +169,8 @@ flow("a label name the repo already has is refused by the form") .step("define the label", defineLabel(triage)) .open(newLabel) - .fill(field("Name"), triage) - .click(button("Add")) + .fill(testid("name"), triage) + .click(testid("create")) .refused(labelDefinitionId, 409) .sees(taken) .at(newLabel); @@ -186,13 +180,13 @@ flow("cancelling a new label writes nothing") .repo() .open(newLabel) - .fill(field("Name"), triage) - .click(link("Cancel")) + .fill(testid("name"), triage) + .click(testid("back")) .at(settings) .sendsNothing(labelDefinitionId) - .sees(text("No labels added yet")) + .sees(testid("settingsEmpty")) .reload() - .sees(text("No labels added yet")); + .sees(testid("settingsEmpty")); flow("cancelling a label delete keeps the label") .actor("owner") @@ -201,12 +195,7 @@ flow("cancelling a label delete keeps the label") .open(settings) .click(deleteLabel) - .click( - within( - target("the dialog", (tab) => tab.getByRole("dialog")), - button("Cancel") - ) - ) + .click(within(testid("labelDelete"), testid("close"))) .sendsNothing(labelDefinitionId) .sees(deleteLabel) .reload() diff --git a/e2e/specs/merges.spec.ts b/e2e/specs/merges.spec.ts index 661b490e6..c1a42b5e7 100644 --- a/e2e/specs/merges.spec.ts +++ b/e2e/specs/merges.spec.ts @@ -1,9 +1,7 @@ import { knotReply, pulls } from "../harness/api"; import { bobbin, - button, type Ctx, - field, flow, hydrant, knot, @@ -16,10 +14,12 @@ import { refusal, rkeyOf, target, + testid, text, through, unique, - where + where, + within } from "../harness/flow"; import { base, @@ -46,8 +46,8 @@ onBranches("the merged branch can be deleted after a merge") .pull(pullId, patchId, branches) .open(page.pull(pullId)) .click(mergeButton) - .sees(text("Merged")) - .sees(button("Delete branch")); + .sees(testid("ticketStatePill", { state: "completed" })) + .sees(testid("openDeleteConfirm")); onBranches("merging a pull request", { ci: true }) .pull(pullId, patchId, branches) @@ -118,8 +118,8 @@ flow("opening a pull request from a fork to the upstream") .mirrored() .open(composeFromFork) - .fill(field("Title"), forkTitle) - .click(button("Create pull request")) + .fill(testid("title"), forkTitle) + .click(testid("submit")) .sends( pullId, knot, @@ -138,15 +138,15 @@ flow("opening a pull request from a fork to the upstream") .actor("owner") .open(upstreamPull) .sees(text(forkTitle)) - .sees(target("the fork's file in the diff", (tab) => tab.getByText("/fork.txt").first())); + .sees(within(testid("diffFileName"), text("/fork.txt"))); const mergeDisabled = target("the disabled merge button", (tab) => - tab.getByRole("button", { name: "Rebase & merge", exact: true, disabled: true }) + tab + .locator('[data-testid="mergeChoice"] [data-testid="primaryAction"]:disabled') + .filter({ visible: true }) ); -const conflictsSummary = target("the conflicts summary", (tab) => - tab.getByText("Conflicts", { exact: true }) -); -const conflictedFile = target("the conflicted file", (tab) => tab.getByTitle("e2e/conflict.txt")); +const conflictsSummary = testid("conflictsSummary"); +const conflictedFile = testid("conflictedFile", { filename: "e2e/conflict.txt" }); const rejectMerge = (ctx: Ctx) => knotReply(ctx.actor, ctx.repo.did, "sh.tangled.repo.pull.merge", { @@ -195,13 +195,9 @@ flow("a pull merges when the target gained another file in the same folder") const short = (sha: string) => sha.slice(0, 8); const commitRow = (nth: "first" | "last") => - target(`the ${nth} commit's button`, (tab) => - tab.getByRole("button", { name: /^Show changes in / })[nth]() - ); -// the diff writes the count twice, the phone layout's copy is hidden -const changedFiles = (count: string) => - target(count, (tab) => tab.getByText(count).filter({ visible: true })); -const inDiff = (file: string) => target(`${file} in the diff`, (tab) => tab.getByText(`/${file}`)); + target(`the ${nth} commit's button`, (tab, ctx) => testid("commitRow").locate(tab, ctx)[nth]()); +const changedFiles = (count: number) => testid("changedFilesCount", { count }); +const inDiff = (file: string) => within(testid("diffFileName"), text(`/${file}`)); const twoCommits = (name: string) => flow(name) @@ -217,7 +213,7 @@ const twoCommits = (name: string) => twoCommits("narrowing a pull's diff to one commit and back to all of them") .open(page.pull(pullId)) - .sees(changedFiles("2 changed files")) + .sees(changedFiles(2)) .click(commitRow("first")) .at( where("the newest commit's range", (ctx) => @@ -227,21 +223,21 @@ twoCommits("narrowing a pull's diff to one commit and back to all of them") ) ) ) - .sees(changedFiles("1 changed file")) + .sees(changedFiles(1)) .sees(inDiff("second.txt")) .sees(not(inDiff("first.txt"))) .reload() - .sees(changedFiles("1 changed file")) + .sees(changedFiles(1)) .sees(inDiff("second.txt")) .sees(not(inDiff("first.txt"))) - .click(button("Show all changes")) + .click(testid("resetRange")) .at(where("the whole version", (ctx) => pullAt(ctx, "/0"))) - .sees(changedFiles("2 changed files")); + .sees(changedFiles(2)); twoCommits("the oldest commit's button narrows the diff to the first file") .open(page.pull(pullId)) .click(commitRow("last")) - .sees(changedFiles("1 changed file")) + .sees(changedFiles(1)) .sees(inDiff("first.txt")) .sees(not(inDiff("second.txt"))); @@ -251,7 +247,7 @@ twoCommits("a range that names no commit in the url goes back to the whole versi pullAt(ctx, `/0/${"0".repeat(8)}..${"1".repeat(8)}`) ) ) - .sees(changedFiles("2 changed files")) + .sees(changedFiles(2)) .at(where("the version without the range", (ctx) => pullAt(ctx, "/0"))); const mergedInAnotherWindow = (name: string) => @@ -272,7 +268,7 @@ const closedInAnotherWindow = (name: string) => .sees(mergeButton) .window("second") .open(page.pull(pullId)) - .changeState(pullId, "closed", button("Close"), button("Reopen")) + .changeState(pullId, "closed", testid("requestClose"), testid("changeState")) .window() .click(mergeButton); @@ -287,7 +283,7 @@ mergedInAnotherWindow("a second merge from a stale window is refused and the pag mergedInAnotherWindow("a stale window stops offering merge once the knot said it was merged") .sees(alreadyMerged) .sees(not(mergeButton)) - .sees(text("Merged")); + .sees(testid("ticketStatePill", { state: "completed" })); closedInAnotherWindow( "merging a pull that was closed in another window is refused and the page says why" @@ -296,10 +292,10 @@ closedInAnotherWindow( .knotStored(pullId, "closed") .step("the target branch didn't move", targetIsAt(branches.into)) .reload() - .sees(button("Reopen")) + .sees(testid("changeState")) .sees(not(mergeButton)); closedInAnotherWindow("a stale window shows the pull as closed once the knot refused the merge") .sees(closedPull) - .sees(button("Reopen")) + .sees(testid("changeState")) .sees(not(mergeButton)); diff --git a/e2e/specs/permissions.spec.ts b/e2e/specs/permissions.spec.ts index 6bb2c5719..829949e3f 100644 --- a/e2e/specs/permissions.spec.ts +++ b/e2e/specs/permissions.spec.ts @@ -2,15 +2,13 @@ import { knotReply, ticketRecord, tickets } from "../harness/api"; import { seeded } from "../harness/config"; import { bobbin, - button, + component, type Ctx, - field, flow, hydrant, knot, link, loginThen, - menuItem, moreActions, not, page, @@ -18,19 +16,18 @@ import { refusedByPolicy, refusedTicketId, subscriptionId, - target, + testid, text, through, - ticketId + ticketId, + within } from "../harness/flow"; import { stamp } from "../harness/net"; import { deletesIssue, forgetTheLogin } from "../harness/shared"; const policyIs = (policy: string) => `where contribution policy is ${policy}`; const shownByButton = (policy: string) => - target("the refusal beside the button", (tab) => - tab.getByRole("status").filter({ hasText: policyIs(policy) }) - ); + within(testid("stateChangeError"), text(policyIs(policy))); const title = "the build is broken"; const fixedTitle = "a typo in the title"; @@ -40,8 +37,8 @@ flow("an outsider keeps erase but loses edit and close when the policy tightens" .repo() .actor(seeded.charlie) .open(page.newIssue) - .fill(field("Title"), title) - .click(button("Create issue")) + .fill(testid("title"), title) + .click(testid("submit")) .trace(ticketId, through(knot, hydrant, bobbin)) .trace(subscriptionId, through(pds)) @@ -50,18 +47,18 @@ flow("an outsider keeps erase but loses edit and close when the policy tightens" .actor(seeded.charlie) .open(page.issue(ticketId)) - .click(button("Close issue")) + .click(testid("requestStateChange")) .refused(refusedTicketId, 403) .sees(shownByButton("collaborators")) .knotStored(ticketId, "open") .click(moreActions) - .click(menuItem("Edit")) - .fill(field("Title"), fixedTitle) - .click(button("Save")) + .click(testid("setEditing")) + .fill(testid("title"), fixedTitle) + .click(within(component("TicketForm"), testid("submit"))) .refused(refusedTicketId, 403) .sees(refusedByPolicy("collaborators")) .knotStored(ticketId, "open") - .click(button("Cancel")) + .click(testid("cancel")) .use(deletesIssue) .at(page.issues) @@ -78,7 +75,7 @@ flow( .policy("collaborators") .actor(seeded.charlie) .open(page.issue(ticketId)) - .sees(not(button("Close issue"))); + .sees(not(testid("requestStateChange"))); flow("a members only repo takes a knot member's issue and refuses a stranger's") .actor("owner") @@ -87,8 +84,8 @@ flow("a members only repo takes a knot member's issue and refuses a stranger's") .actor(seeded.bob) .open(page.newIssue) - .fill(field("Title"), title) - .click(button("Create issue")) + .fill(testid("title"), title) + .click(testid("submit")) .trace(ticketId, through(knot, hydrant, bobbin)) .trace(subscriptionId, through(pds)) .at(page.issues) @@ -96,8 +93,8 @@ flow("a members only repo takes a knot member's issue and refuses a stranger's") .actor(seeded.charlie) .open(page.newIssue) - .fill(field("Title"), "let me in") - .click(button("Create issue")) + .fill(testid("title"), "let me in") + .click(testid("submit")) .refused(refusedTicketId, 403) .sees(refusedByPolicy("members")) .at(page.newIssue) @@ -116,7 +113,7 @@ flow("a knot admin closes a stranger's issue through the api") .actor(seeded.alice) .open(page.issue(ticketId)) .sees(text(title)) - .sees(not(button("Close issue"))) + .sees(not(testid("requestStateChange"))) .step("alice closes it through the api", alicesClose) .learn(ticketId) .follow(ticketId, through(knot, hydrant, bobbin)) @@ -133,7 +130,7 @@ flow("a closed issue still has its author after a knot admin closes it") .follow(ticketId, through(knot, hydrant, bobbin)) .actor(seeded.charlie) .open(page.issue(ticketId)) - .sees(button("Reopen issue")); + .sees(testid("requestStateChange")); flow("a knot admin can close an issue on a collaborators only repo but not open one") .actor("owner") @@ -175,17 +172,22 @@ flow("a stranger opening a repo's settings is told they need push access") .actor("owner") .repo() .open(page.settings) - .sees(text("Danger zone")) + .sees(testid("settingsSectionTitle", { name: "danger" })) .actor(seeded.charlie) .open(page.settings) - .sees(text("You need push access to this repository to change its settings")); + .sees( + within( + testid("errorMessage"), + text("You need push access to this repository to change its settings") + ) + ); flow("a stranger can't create a repo on the closed knot and the form says why") .actor(seeded.charlie) .open(page.newRepo) - .fill(field("Repository name"), () => `e2e-denied-${stamp()}`) - .click(button("Create repository")) - .sees(text("only knot admin or member")); + .fill(testid("name"), () => `e2e-denied-${stamp()}`) + .click(testid("submit")) + .sees(within(testid("errorAlert"), text("only knot admin or member"))); flow("a stranger sees no close, edit or delete on someone else's issue") .actor("owner") @@ -194,8 +196,8 @@ flow("a stranger sees no close, edit or delete on someone else's issue") .actor(seeded.charlie) .open(page.issue(ticketId)) .sees(text(title)) - .sees(not(button("Close issue"))) + .sees(not(testid("requestStateChange"))) .click(moreActions) - .sees(menuItem("Copy AT URI")) - .sees(not(menuItem("Edit"))) - .sees(not(menuItem("Delete"))); + .sees(testid("navigatorClipboardWriteText")) + .sees(not(testid("setEditing"))) + .sees(not(testid("openDeleteConfirm"))); diff --git a/e2e/specs/pipelines.spec.ts b/e2e/specs/pipelines.spec.ts index 6d0d0519a..39e86ccb4 100644 --- a/e2e/specs/pipelines.spec.ts +++ b/e2e/specs/pipelines.spec.ts @@ -3,17 +3,15 @@ import { expect } from "@playwright/test"; import { serviceDo } from "../harness/api"; import { type Account, config } from "../harness/config"; import { - button, type Ctx, - dialogButton, flow, - link, not, - placeholder, saveButton, settingsAt, target, - text + testid, + text, + within } from "../harness/flow"; import { stamp, until } from "../harness/net"; import { saveFor } from "../harness/services"; @@ -61,12 +59,10 @@ let hookId = 0; const pickHook = async () => { hookUrl = `https://example.com/e2e/${stamp()}`; }; -const deleteHook = button("Delete"); -const newHookLink = link("New webhook"); -const hookRow = target("the webhook's url", (tab) => tab.getByText(hookUrl, { exact: true })); -const hookSwitch = target("the webhook's switch", (tab) => - tab.getByRole("switch", { name: `Enable ${hookUrl}`, exact: true }) -); +const deleteHook = testid("confirmDelete"); +const newHookLink = testid("newWebhook"); +const hookRow = testid("webhookRow", { url: hookUrl }); +const hookSwitch = testid("webhookSwitch", { url: hookUrl }); interface Hook { id: number; @@ -117,8 +113,8 @@ flow("adding a webhook lists it under hooks") .sees(newHookLink) .click(newHookLink) .at(newHook) - .fill(placeholder("https://example.com/webhook"), () => hookUrl) - .click(button("Add")) + .fill(testid("draftUrl"), () => hookUrl) + .click(testid("add")) .step( "the form finishes on the hooks list", landsOn("the webhook was not added", (ctx) => `${ctx.repo.path}/settings/hooks`) @@ -155,7 +151,7 @@ flow("deleting a webhook takes it off the list and off the spindle") ) .reload() .sees(not(hookRow)) - .sees(text("No webhooks yet")); + .sees(testid("settingsEmpty")); flow("switching a webhook off keeps it listed and inactive") .actor("owner") @@ -190,9 +186,9 @@ let secretKey = ""; const pickSecret = async () => { secretKey = `E2E_${stamp().toUpperCase()}`; }; -const deleteSecret = button("Delete"); -const addSecretLink = link("Add secret"); -const secretRow = target("the secret's name", (tab) => tab.getByText(secretKey, { exact: true })); +const deleteSecret = testid("drop"); +const addSecretLink = testid("addSecret"); +const secretRow = testid("secretName", { key: secretKey }); const secretsOnSpindle = (what: string, ok: (keys: string[], who: string[]) => boolean) => async (ctx: Ctx) => { await until(`spindle should list ${what}`, 10_000, async () => { @@ -231,9 +227,9 @@ flow("adding a pipeline secret lists it under pipelines") .sees(addSecretLink) .click(addSecretLink) .at(newSecret) - .fill(placeholder("SECRET_NAME"), () => secretKey) - .fill(placeholder("very important secret"), "hunter2") - .click(button("Add")) + .fill(testid("name"), () => secretKey) + .fill(testid("secretValue"), "hunter2") + .click(testid("add")) .step( "the form finishes on the pipelines page", landsOn("the secret was not added", (ctx) => `${ctx.repo.path}/settings/pipelines`) @@ -266,17 +262,14 @@ flow("deleting a pipeline secret takes it off the list and off the spindle") ) .reload() .sees(not(secretRow)) - .sees(text("No secrets yet")); + .sees(testid("settingsEmpty")); const spindleHost = new URL(config.spindleUrl).host; const saveSpindle = (ctx: Ctx) => saveFor(ctx.actor, { knots: [], spindles: [spindleHost] }); -const spindlePicker = target("the spindle picker", (tab) => - tab.getByRole("button", { name: "Spindle", exact: true }) -); -const spindleOption = (name: string) => - target(`the spindle ${name}`, (tab) => tab.getByRole("menuitem", { name })); +const spindlePicker = within(testid("spindle"), testid("toggleMenu")); +const spindleOption = (name: string) => within(testid("setPicked"), text(name)); const manifestSpindle = async (ctx: Ctx) => (await manifestOf(ctx)).spindle as string | undefined; @@ -295,7 +288,7 @@ flow("choosing a spindle in the pipeline settings keeps it after a reload") expect(await manifestSpindle(ctx)).toContain(spindleHost); }) .reload() - .sees(text(spindleHost)); + .sees(within(testid("chosenSpindle"), text(spindleHost))); flow("removing a repo's spindle in the pipeline settings keeps it removed after a reload") .actor("owner") @@ -305,7 +298,7 @@ flow("removing a repo's spindle in the pipeline settings keeps it removed after .click(spindlePicker) .click(spindleOption("No spindle")) .click(saveButton) - .click(dialogButton("Remove spindle")) + .click(within(testid("confirming"), testid("confirm"))) .step("the save finishes, which leaves nothing to save", saveFinishes) .step("the manifest names no spindle", async (ctx) => { expect(await manifestSpindle(ctx)).toBeUndefined(); diff --git a/e2e/specs/profile.spec.ts b/e2e/specs/profile.spec.ts index 31d8c05f7..dcfd5bb17 100644 --- a/e2e/specs/profile.spec.ts +++ b/e2e/specs/profile.spec.ts @@ -4,27 +4,24 @@ import { getOk, pdsCall } from "../harness/api"; import { config } from "../harness/config"; import { bobbin, - button, + component, type Ctx, - field, flow, followId, - heading, hydrant, - inVouchDialog, not, page, pds, - placeholder, profileId, profileSettings, publicKeyId, target, + testid, text, through, - vouchDialog, vouchId, - where + where, + within } from "../harness/flow"; import { keyOf, makeKey } from "../harness/git"; import { contentTypeOf, get, stamp, until, xrpc } from "../harness/net"; @@ -74,21 +71,19 @@ flow("editing the bio on the profile page keeps the pinned repo and the other fi .open(page.profile) .sees(pinnedRepo) - .click(button("Edit")) - .fill(field("Bio"), "after") - .click(button("Save")) + .click(within(component("UserProfileChrome"), testid("setEditing"))) + .fill(within(component("ProfileEditForm"), testid("description")), "after") + .click(within(component("ProfileEditForm"), testid("submit"))) .sends(profileId, pds, bioAfter, "put") .trace(profileId, through(pds, hydrant, bobbin)) .step("the pds keeps the pin, the pronouns, the location and the link", keepsTheRest) - .sees(text("after")) + .sees(within(testid("profileBio"), text("after"))) .reload() - .sees(text("after")) - .sees(text("she/her")) + .sees(within(testid("profileBio"), text("after"))) + .sees(testid("profilePronouns", { pronouns: "she/her" })) .sees(pinnedRepo); -const firstSave = target("the first Save", (tab) => - tab.getByRole("button", { name: "Save", exact: true }).first() -); +const firstSave = within(component("SettingsHeader"), testid("save")); flow("editing the bio in profile settings keeps the pinned repo and the other fields") .actor("owner") @@ -96,17 +91,22 @@ flow("editing the bio in profile settings keeps the pinned repo and the other fi .step("a profile with a pin, pronouns, a location and a link", writeRichProfile) .open(profileSettings) - .fill(field("Bio"), "after") + .fill(within(component("ProfileSettingsForm"), testid("formDescription")), "after") .click(firstSave) .sends(profileId, pds, bioAfter, "put") .trace(profileId, through(pds, hydrant, bobbin)) .step("the pds keeps the pin, the pronouns, the location and the link", keepsTheRest) .reload() .step("the settings form shows the saved bio", async (ctx) => { - await expect(field("Bio").locate(ctx.page, ctx)).toHaveValue("after"); + await expect( + within(component("ProfileSettingsForm"), testid("formDescription")).locate( + ctx.page, + ctx + ) + ).toHaveValue("after"); }) .open(page.profile) - .sees(text("after")) + .sees(within(testid("profileBio"), text("after"))) .sees(pinnedRepo); // the pages never ask for size=52&format=png, so no cached placeholder can answer it @@ -120,9 +120,9 @@ flow("an avatar uploaded in the settings is the picture the avatar service serve .open(avatarSettings) .step("choose the picture", (ctx) => - ctx.page.locator("input[type=file]").setInputFiles(picture) + testid("avatarFileInput").locate(ctx.page, ctx).setInputFiles(picture) ) - .click(button("Upload")) + .click(testid("uploadAvatar")) .sends(profileId, pds, { record: { avatar: { mimeType: "image/png" } } }, "put") .trace(profileId, through(pds, hydrant, bobbin)) .step("the avatar service serves the picture and not a placeholder", async (ctx) => { @@ -138,9 +138,9 @@ flow("a javascript: link in a profile is not shown as a link") .actor("owner") .open(page.profile) - .click(button("Edit")) - .fill(placeholder("Social link 1"), scripted) - .click(button("Save")) + .click(within(component("UserProfileChrome"), testid("setEditing"))) + .fill(testid("socialLink", { index: 0 }), scripted) + .click(within(component("ProfileEditForm"), testid("submit"))) .sends(profileId, pds, { record: { links: [scripted] } }, "put") .trace(profileId, through(pds, hydrant, bobbin)) .sees(not(javascriptLink)) @@ -151,9 +151,9 @@ flow("the profile settings refuse a link that is not http or https and write not .actor("owner") .open(profileSettings) - .fill(field("Link 1"), scripted) + .fill(testid("formLink", { index: 0 }), scripted) .click(firstSave) - .sees(text("Links must use http or https.")) + .sees(within(testid("errorAlert"), text("Links must use http or https."))) .sendsNothing(profileId) .step("the pds profile has no link", async (ctx) => { expect((await profileOnPds(ctx)).links).toBeUndefined(); @@ -161,25 +161,29 @@ flow("the profile settings refuse a link that is not http or https and write not const welcome = where("the welcome wizard", () => "/welcome"); -const next = button("Next"); +const next = testid("handleNext"); const unfollowed = target("the Unfollow that replaced it", (tab) => - tab.getByRole("button", { name: "Unfollow", exact: true }).first() + testid("follow", { following: "true" }) + .locate(tab, null as any) + .first() ); const firstFollow = target("the first Follow on the discover step", (tab) => - tab.getByRole("button", { name: "Follow", exact: true }).first() + testid("follow", { following: "false" }) + .locate(tab, null as any) + .first() ); const throughTheProfileStep = (name: string) => flow(name) .actor("newbie", { login: "ui" }) .open(welcome) - .sees(heading("Welcome!")) + .sees(within(component("ProfileStep"), testid("stepHeaderTitle"))) .step("picks a picture", (ctx) => - ctx.page.locator("input[type=file]").setInputFiles(picture) + testid("welcomeAvatarInput").locate(ctx.page, ctx).setInputFiles(picture) ) - .fill(field("Bio"), "i write small tools") - .fill(field("Pronouns"), "she/her") - .fill(field("Website"), "example.com") + .fill(within(component("ProfileStep"), testid("draftDescription")), "i write small tools") + .fill(within(component("ProfileStep"), testid("draftPronouns")), "she/her") + .fill(within(component("ProfileStep"), testid("draftWebsite")), "example.com") .click(next) .sends( profileId, @@ -194,7 +198,7 @@ const throughTheProfileStep = (name: string) => "put" ) .trace(profileId, through(pds, hydrant, bobbin)) - .sees(heading("Verify your emails")); + .sees(within(component("EmailsStep"), testid("stepHeaderTitle"))); throughTheProfileStep("the welcome wizard saves a new account's profile and goes on") .step("the pds keeps the picture, the words and the link", async (ctx) => { @@ -207,38 +211,40 @@ throughTheProfileStep("the welcome wizard saves a new account's profile and goes expect(value.avatar?.ref?.$link, "the profile has no avatar").toBeTruthy(); }) .open(page.profile) - .sees(text("i write small tools")) - .sees(text("she/her")); + .sees(within(testid("profileBio"), text("i write small tools"))) + .sees(testid("profilePronouns", { pronouns: "she/her" })); // advance() ignores nextReady, so discover can be skipped on purpose throughTheProfileStep("the discover step can be skipped without following anyone") .click(next) - .sees(heading("Discover")) + .sees(within(component("SocialStep"), testid("stepHeaderTitle"))) .click(next) - .sees(heading("Add SSH keys")); + .sees(within(component("KeysStep"), testid("stepHeaderTitle"))); throughTheProfileStep("following someone on the discover step lets the wizard go on") .click(next) - .sees(heading("Discover")) + .sees(within(component("SocialStep"), testid("stepHeaderTitle"))) .click(firstFollow) .sends(followId, pds, { collection: followId.nsid }, "put") .trace(followId, through(pds, hydrant, bobbin)) .sees(unfollowed) .click(next) - .sees(heading("Add SSH keys")); + .sees(within(component("KeysStep"), testid("stepHeaderTitle"))); -const lastStep = button("Finish"); +const lastStep = testid("handleNext"); const wizardKey = { name: "wizard-laptop" }; flow("a new account goes through the whole welcome wizard") .actor("newbie", { login: "ui" }) .step("the account makes a key to add", makeKey) .open(welcome) - .sees(heading("Welcome!")) - .step("picks a picture", (ctx) => ctx.page.locator("input[type=file]").setInputFiles(picture)) - .fill(field("Bio"), "i write small tools") - .fill(field("Pronouns"), "she/her") - .fill(field("Website"), "example.com") + .sees(within(component("ProfileStep"), testid("stepHeaderTitle"))) + .step("picks a picture", (ctx) => + testid("welcomeAvatarInput").locate(ctx.page, ctx).setInputFiles(picture) + ) + .fill(within(component("ProfileStep"), testid("draftDescription")), "i write small tools") + .fill(within(component("ProfileStep"), testid("draftPronouns")), "she/her") + .fill(within(component("ProfileStep"), testid("draftWebsite")), "example.com") .click(next) .trace(profileId, through(pds, hydrant, bobbin)) .click(next) @@ -246,10 +252,10 @@ flow("a new account goes through the whole welcome wizard") .trace(followId, through(pds, hydrant, bobbin)) .sees(unfollowed) .click(next) - .sees(heading("Add SSH keys")) - .fill(field("Key name"), wizardKey.name) - .fill(field("Public key"), (ctx) => keyOf(ctx).pub) - .click(button("Add key")) + .sees(within(component("KeysStep"), testid("stepHeaderTitle"))) + .fill(within(component("KeysStep"), testid("name")), wizardKey.name) + .fill(within(component("KeysStep"), testid("key")), (ctx) => keyOf(ctx).pub) + .click(within(component("KeysStep"), testid("submit"))) .sends( publicKeyId, pds, @@ -258,7 +264,7 @@ flow("a new account goes through the whole welcome wizard") ) .trace(publicKeyId, through(pds, hydrant, bobbin)) .click(next) - .sees(heading("You're all set!")) + .sees(within(component("FinishStep"), testid("stepHeaderTitle"))) .click(lastStep) .at(where("the home page", () => "/")); @@ -284,7 +290,7 @@ flow("an account with no profile record is sent to the welcome wizard from the h .step("the profile record is taken away", withoutProfile) .open(where("the home page", () => "/")) .at(welcome) - .sees(heading("Welcome!")); + .sees(within(component("ProfileStep"), testid("stepHeaderTitle"))); const ownHandle = target("the account's handle", (tab, ctx) => tab.getByText(ctx.actor.handle, { exact: true }).first() @@ -306,7 +312,13 @@ const vouchedBy = (expected: Record | undefined) => async (ctx: }; const friendsProfile = where("the friend's profile", (ctx) => `/${recall(ctx, "friend").handle}`); -const reasonBox = placeholder("Write a reason..."); +const vouchModal = testid("vouch"); +const vouchTrigger = testid("vouchTrigger"); +const vouchStanding = (standing: "vouch" | "denounce" | "none") => + testid("vouchTrigger", { standing }); +const vouchOption = (choice: string) => within(vouchModal, testid("setChoice", { value: choice })); +const saveVouch = within(vouchModal, testid("save")); +const reasonBox = testid("reason"); flow("changing a vouch to a denounce keeps the new reason and the same record") .actor("friend") @@ -314,17 +326,17 @@ flow("changing a vouch to a denounce keeps the new reason and the same record") .actor("fan") .open(friendsProfile) - .click(button("Vouch")) - .click(inVouchDialog("Vouch")) + .click(vouchTrigger) + .click(vouchOption("vouch")) .fill(reasonBox, "knows their stuff") - .click(inVouchDialog("Save")) + .click(saveVouch) .trace(vouchId, through(pds, hydrant, bobbin)) - .sees(button("Vouched")) + .sees(vouchStanding("vouch")) - .click(button("Vouched")) - .click(inVouchDialog("Denounce")) + .click(vouchTrigger) + .click(vouchOption("denounce")) .fill(reasonBox, "changed my mind") - .click(inVouchDialog("Update")) + .click(saveVouch) .sends( vouchId, pds, @@ -335,15 +347,15 @@ flow("changing a vouch to a denounce keeps the new reason and the same record") "put" ) .trace(vouchId, through(pds, hydrant, bobbin)) - .sees(not(vouchDialog)) - .sees(button("Denounced")) - .sees(not(button("Vouched"))) + .sees(not(vouchModal)) + .sees(vouchStanding("denounce")) + .sees(not(vouchStanding("vouch"))) .step( "the pds has one vouch record and it is a denounce", vouchedBy({ kind: "denounce", reason: "changed my mind" }) ) .reload() - .sees(button("Denounced")); + .sees(vouchStanding("denounce")); const signup = where("the sign up page", () => "/signup"); const signupMail = `signup-${stamp()}@e2e.test`; @@ -353,13 +365,13 @@ flow("signing up with an email says to check the mail") .open(page.profile) .step("signs out", forgetTheLogin) .open(signup) - .sees(not(text("Signup is temporarily unavailable"))) - .fill(field("Email"), signupMail) + .sees(not(testid("signupUnavailable"))) + .fill(testid("email"), signupMail) .step("the sign up button is on", async (ctx) => { - await expect(button("Sign up").locate(ctx.page, ctx)).toBeEnabled(); + await expect(testid("submit").locate(ctx.page, ctx)).toBeEnabled(); }) - .click(button("Sign up")) - .sees(heading("Check your email")) + .click(testid("submit")) + .sees(testid("checkEmailTitle")) .sees(text(signupMail)); flow("vouching for someone and withdrawing it") @@ -368,10 +380,10 @@ flow("vouching for someone and withdrawing it") .actor("fan") .open(friendsProfile) - .click(button("Vouch")) - .click(inVouchDialog("Vouch")) - .fill(placeholder("Write a reason..."), "knows their stuff") - .click(inVouchDialog("Save")) + .click(vouchTrigger) + .click(vouchOption("vouch")) + .fill(reasonBox, "knows their stuff") + .click(saveVouch) .sends( vouchId, pds, @@ -382,26 +394,26 @@ flow("vouching for someone and withdrawing it") "put" ) // the dialog waits on bobbin before it closes, so the trigger changes late - .sees(not(button("Vouched"))) + .sees(not(vouchStanding("vouch"))) .trace(vouchId, through(pds, hydrant, bobbin)) .step( "the pds has the vouch and its reason", vouchedBy({ kind: "vouch", reason: "knows their stuff" }) ) - .sees(button("Vouched")) + .sees(vouchStanding("vouch")) .reload() - .sees(button("Vouched")) + .sees(vouchStanding("vouch")) - .click(button("Vouched")) - .click(inVouchDialog("None")) - .click(inVouchDialog("Update")) + .click(vouchTrigger) + .click(vouchOption("none")) + .click(saveVouch) .sends(vouchId, pds, (ctx) => ({ rkey: recall(ctx, "friend").did }), "delete") - .sees(button("Vouched")) + .sees(vouchStanding("vouch")) .trace(vouchId, through(pds, hydrant, bobbin)) // the open dialog has a second Vouch button, so wait for it to close first - .sees(not(vouchDialog)) - .sees(button("Vouch")) + .sees(not(vouchModal)) + .sees(vouchStanding("none")) .step("the pds has dropped the vouch", vouchedBy(undefined)) .reload() - .sees(button("Vouch")) - .sees(not(button("Vouched"))); + .sees(vouchStanding("none")) + .sees(not(vouchStanding("vouch"))); diff --git a/e2e/specs/pulls.spec.ts b/e2e/specs/pulls.spec.ts index 3848ff437..529400f5b 100644 --- a/e2e/specs/pulls.spec.ts +++ b/e2e/specs/pulls.spec.ts @@ -4,31 +4,28 @@ import { comments, editCob } from "../harness/api"; import { seeded } from "../harness/config"; import { bobbin, - button, commentId, + component, type Ctx, - field, hydrant, knot, link, - menuItem, - moreActionsOn, not, page, patchId, pds, - pullDiscussion, pullId, record, - refusal, replacing, rkeyOf, subscriptionId, target, + testid, text, through, triage, - unique + unique, + within } from "../harness/flow"; import { lsRemote } from "../harness/git"; import { @@ -50,8 +47,8 @@ const title = unique("add a feature"); onBranches("opening a pull request", { ci: true }) .open(page.newPull(branches)) - .fill(field("Title"), title) - .click(button("Create pull request")) + .fill(within(component("PullCompose"), testid("title")), title) + .click(within(component("PullCompose"), testid("submit"))) .sends( pullId, knot, @@ -76,38 +73,36 @@ onBranches("opening a pull request", { ci: true }) pipelineFor("pullRequest", (ctx) => ctx.repo.heads[branches.from]) ) .open(page.pull(pullId)) - .sees(text("ci.yml")); + .sees(testid("workflowName", { name: "ci.yml" })); onBranches("closing and reopening a pull request") .pull(pullId, patchId, branches) .open(page.pull(pullId)) - .changeState(pullId, "closed", button("Close"), button("Reopen")) - .changeState(pullId, "open", button("Reopen"), button("Close")) + .changeState(pullId, "closed", testid("requestClose"), testid("changeState")) + .changeState(pullId, "open", testid("changeState"), testid("requestClose")) .reload() - .sees(button("Close")); + .sees(testid("requestClose")); // the diff lists a file only once the mirror can read the commits between the branches -const diffedFile = target("the new file in the diff", (tab) => - tab.getByText("/feature.txt").first() -); +const diffedFile = testid("diffFileName", { path: "e2e/feature.txt" }); onBranches("the new pull form lists the branch's file in its diff") .open(page.newPull(branches)) .sees(diffedFile) - .sees(button("Create pull request")); + .sees(within(component("PullCompose"), testid("submit"))); const viewedTitle = unique("a pull to view"); onBranches("the pull page shows the pull's title") .pull(pullId, patchId, { ...branches, title: viewedTitle }) .open(page.pull(pullId)) - .sees(not(text("Internal Error"))) - .sees(text(viewedTitle)); + .sees(not(testid("errorTitle"))) + .sees(within(testid("ticketTitle"), text(viewedTitle))); const otherPull = record("org.tangled.track.ticket"); const otherPatch = record("org.tangled.review.patch"); -const searchPulls = field("Search pull requests"); +const searchPulls = within(component("TicketSearch"), testid("q")); const pullNeedle = unique("search needle"); const pullHay = unique("unrelated"); @@ -128,17 +123,16 @@ onTwoPulls("searching the pull list finds the pull you named") const note = "first thoughts"; const secondThoughts = "second thoughts"; -const editComment = target("edit comment", (tab) => - tab.getByRole("button", { name: "Edit comment", exact: true }) -); -const editor = target("the comment editor", (tab) => tab.locator('textarea[name="body"]')); +const editComment = within(component("PullReviewComment"), testid("setEditing")); +const editor = within(component("PullReviewCommentEditor"), testid("body")); +const commentField = within(component("PullReviewCommentForm"), testid("reviewComment")); const commented = (name: string) => onBranches(name) .pull(pullId, patchId, branches) .open(page.pull(pullId)) - .fill(pullDiscussion, note) - .click(button("Send comment")) + .fill(commentField, note) + .click(within(component("PullReviewCommentForm"), testid("submit"))) .sends( commentId, knot, @@ -147,15 +141,17 @@ const commented = (name: string) => ) .trace(commentId, through(knot, hydrant, bobbin)) .trace(subscriptionId, through(pds)) - .sees(text(note)); + .sees(within(testid("reviewCommentBody"), text(note))); -commented("commenting on a pull request").reload().sees(text(note)); +commented("commenting on a pull request") + .reload() + .sees(within(testid("reviewCommentBody"), text(note))); const startEdit = >(flowSoFar: T) => flowSoFar .click(editComment) .fill(editor, secondThoughts) - .click(button("Save")) + .click(within(component("PullReviewCommentEditor"), testid("submit"))) .sends( commentId, knot, @@ -169,25 +165,24 @@ const startEdit = >(flowSoFar: T) => startEdit(commented("editing a comment on a pull request")) .reload() - .sees(text(secondThoughts)) - .sees(not(text(note))); + .sees(within(testid("reviewCommentBody"), text(secondThoughts))) + .sees(not(within(testid("reviewCommentBody"), text(note)))); -startEdit(commented("an edited pull comment shows without a reload")).sees(text(secondThoughts)); +startEdit(commented("an edited pull comment shows without a reload")).sees( + within(testid("reviewCommentBody"), text(secondThoughts)) +); const bugTitle = unique("a pull to resubmit"); -const pushedFile = target("the file pushed after the pull was made", (tab) => - tab.getByText("/more.txt").first() -); -const version = (label: string) => - target(`the version ${label}`, (tab) => tab.getByText(label, { exact: true }).first()); +const pushedFile = testid("diffFileName", { path: "e2e/more.txt" }); +const version = (label: string) => testid("roundLabel", { version: label.replace(/^v/, "") }); onBranches("resubmitting a pull request after pushing to its source branch", { ci: true }) .pull(pullId, patchId, { ...branches, title: bugTitle }) .push({ "e2e/more.txt": "more\n" }, { branch: branches.from }) .mirrored() .open(page.pull(pullId)) .sees(not(pushedFile)) - .sees(button("Resubmit")) - .click(button("Resubmit")) + .sees(testid("resubmit")) + .click(testid("resubmit")) .sends(patchId, knot, (ctx) => ({ record: { ticket: rkeyOf(ctx, pullId) } }), "create") .trace(patchId, through(knot, hydrant, bobbin)) .reload() @@ -200,8 +195,8 @@ onBranches("resubmitting a pull request after pushing to its source branch", { c pipelineFor("pullRequest", (ctx) => ctx.repo.heads[branches.from]) ); -const moreActions = moreActionsOn("pull request"); -const editPull = menuItem("Edit"); +const moreActions = within(component("Ticket"), testid("moreMenu")); +const editPull = within(component("Ticket"), testid("setEditing")); const strangerTitle = "a pull from someone who can't contribute"; @@ -212,10 +207,10 @@ onBranches( .actor(seeded.charlie) .open(page.newPull(branches)) - .fill(field("Title"), strangerTitle) - .click(button("Create pull request")) + .fill(within(component("PullCompose"), testid("title")), strangerTitle) + .click(within(component("PullCompose"), testid("submit"))) .refused(pullId, 403) - .sees(refusal("where contribution policy is collaborators")) + .sees(within(testid("errorAlert"), text("where contribution policy is collaborators"))) .open(page.pulls) .sees(not(link(strangerTitle))); @@ -228,16 +223,12 @@ const withOtherBranch = (name: string) => .mirrored() .pull(pullId, patchId, branches); -const targetBranchPicker = target("the target branch picker", (tab) => - tab.getByRole("button", { name: "Target branch", exact: true }) +const targetBranchPicker = within( + component("TicketForm"), + within(testid("targetBranch"), testid("trigger")) ); -const otherOption = target("the other branch in the picker", (tab) => - tab.getByRole("option", { name: otherBranch, exact: true }) -); -const targeting = (branch: string) => - target(`the target branch ${branch}`, (tab) => - tab.getByText(branch, { exact: true }).filter({ visible: true }).first() - ); +const otherOption = testid("option", { value: otherBranch }); +const targeting = (branch: string) => testid("targetBranchTag", { branch }); withOtherBranch("retargeting a pull request to another branch") .open(page.pull(pullId)) @@ -246,7 +237,7 @@ withOtherBranch("retargeting a pull request to another branch") .click(editPull) .click(targetBranchPicker) .click(otherOption) - .click(button("Save")) + .click(within(component("TicketForm"), testid("submit"))) .sends(pullId, knot, replacing(pullId, { targetBranch: otherBranch }), "put") .trace(pullId, through(knot, hydrant, bobbin)) .sees(targeting(otherBranch)) @@ -295,14 +286,12 @@ const peerSays = async (ctx: Ctx) => { await bobbin.indexed({ uri: reply.body.uri, cid: reply.body.cid }); }; -const assigneePicker = target("the assignee picker", (tab) => - tab.getByRole("button", { name: "Assignee", exact: true }) -); +const assigneePicker = within(component("TicketForm"), testid("assignee")); const peerOption = target("the peer in the picker", (tab, ctx) => - tab.getByRole("option").filter({ hasText: recall(ctx, "peer").handle }) + testid("option", { value: recall(ctx, "peer").did }).locate(tab, ctx) ); const assignedTo = target("the assignee in the pull's header", (tab, ctx) => - tab.getByText("assigned to").locator("..").getByText(recall(ctx, "peer").handle) + testid("assignee", { handle: recall(ctx, "peer").handle }).locate(tab, ctx) ); onBranches("assigning a pull request to a peer keeps the assignee after a reload") @@ -318,7 +307,7 @@ onBranches("assigning a pull request to a peer keeps the assignee after a reload .click(editPull) .click(assigneePicker) .click(peerOption) - .click(button("Save")) + .click(within(component("TicketForm"), testid("submit"))) .sends(pullId, knot, replacing(pullId), "put") .trace(pullId, through(knot, hydrant, bobbin)) .sees(assignedTo) @@ -327,11 +316,9 @@ onBranches("assigning a pull request to a peer keeps the assignee after a reload const afterWords = "a note on the second round"; const inRound = (round: string, words: string) => - target(`${words} under ${round}`, (tab) => - tab - .locator("aside div.space-y-xs") - .filter({ has: tab.getByText(round, { exact: true }) }) - .getByText(words) + within( + testid("roundGroup", { version: round.replace(/^v/, "") }), + within(testid("reviewCommentBody"), text(words)) ); onBranches("a comment written after a resubmit files under the new round") @@ -339,13 +326,13 @@ onBranches("a comment written after a resubmit files under the new round") .push({ "e2e/more.txt": "more\n" }, { branch: branches.from }) .mirrored() .open(page.pull(pullId)) - .sees(button("Resubmit")) - .click(button("Resubmit")) + .sees(testid("resubmit")) + .click(testid("resubmit")) .use(sendsPatch()) - .sees(text("v1")) + .sees(version("v1")) - .fill(pullDiscussion, afterWords) - .click(button("Send comment")) + .fill(commentField, afterWords) + .click(within(component("PullReviewCommentForm"), testid("submit"))) .sends( commentId, knot, @@ -362,26 +349,26 @@ onBranches("a comment written after a resubmit files under the new round") const edited = "same here, again"; -const cancel = button("Cancel"); +const cancel = within(component("PullReviewCommentEditor"), testid("cancel")); onBranches("cancelling a pull comment edit leaves the comment as it was") .pull(pullId, patchId, branches) .open(page.pull(pullId)) - .fill(pullDiscussion, note) - .click(button("Send comment")) + .fill(commentField, note) + .click(within(component("PullReviewCommentForm"), testid("submit"))) .trace(commentId, through(knot, hydrant, bobbin)) .trace(subscriptionId, through(pds)) - .sees(text(note)) + .sees(within(testid("reviewCommentBody"), text(note))) .click(editComment) .fill(editor, edited) .click(cancel) .sendsNothing(commentId) - .sees(text(note)) - .sees(not(text(edited))) + .sees(within(testid("reviewCommentBody"), text(note))) + .sees(not(within(testid("reviewCommentBody"), text(edited)))) .reload() - .sees(text(note)) - .sees(not(text(edited))) + .sees(within(testid("reviewCommentBody"), text(note))) + .sees(not(within(testid("reviewCommentBody"), text(edited)))) .step( "the knot and hydrant still have the first version", stillStored(commentId, (value) => value.body.text, note) diff --git a/e2e/specs/repo-settings.spec.ts b/e2e/specs/repo-settings.spec.ts index 4158691a7..ad14ffe5d 100644 --- a/e2e/specs/repo-settings.spec.ts +++ b/e2e/specs/repo-settings.spec.ts @@ -5,29 +5,28 @@ import { type Account, config } from "../harness/config"; import { alerts, bobbin, - button, + component, type Ctx, declarationId, exactLink, flow, hydrant, - nameField, not, page, pds, - placeholder, record, - refusal, renameForm, saveButton, settingsAt, target, text, + testid, topic, topicChip, unique, unsaved, - where + where, + within } from "../harness/flow"; import { headOf } from "../harness/git"; import { get, until, xrpc } from "../harness/net"; @@ -49,8 +48,8 @@ flow("renaming a repo made today moves it to the new name") }) .open(renameForm) - .fill(nameField, () => renamedTo) - .click(button("Rename")) + .fill(testid("name"), () => renamedTo) + .click(testid("rename")) .step("the rename finishes on the new settings page", async (ctx) => finished( ctx, @@ -79,7 +78,7 @@ flow("saving a description in the repo settings shows it on the repo page") .repo() .open(page.settings) - .fill(placeholder("What this project is about"), blurb) + .fill(testid("formDescription"), blurb) .click(saveButton) .step("the save finishes, which leaves nothing to save", saveFinishes) .step("the knot's manifest carries it", async (ctx) => { @@ -87,15 +86,12 @@ flow("saving a description in the repo settings shows it on the repo page") }) .open(page.repo) .reload() - .sees(text(blurb)); + .sees(within(testid("repoDescription"), text(blurb))); const sites = settingsAt("sites"); -const branchPicker = target("the default branch picker", (tab) => - tab.getByRole("button", { name: "Default branch", exact: true }) -); -const branchOption = (name: string) => - target(`the option ${name}`, (tab) => tab.getByRole("option", { name, exact: true })); +const branchPicker = within(testid("formDefaultBranch"), testid("trigger")); +const branchOption = (name: string) => testid("option", { value: name }); flow("a branch pushed over git is offered in the default branch picker") .actor("owner") @@ -171,17 +167,13 @@ flow("changing the default branch in the repo settings moves the knot's head") .open(page.repo) .sees(exactLink("dev-only.txt")); -const liveTag = text("Live at"); -const siteHeading = target("the Sites heading", (tab) => - tab.getByRole("heading", { name: "Sites", exact: true }) -); -const directory = placeholder("/"); +const liveTag = testid("siteLiveTag"); +const siteHeading = testid("settingsHeaderTitle"); +const directory = testid("formDirectory"); const directoryIs = (want: string) => async (ctx: Ctx) => { await expect(directory.locate(ctx.page, ctx)).toHaveValue(want); }; -const disableSwitch = target("the disable site switch", (tab) => - tab.getByRole("switch", { name: "Disable site", exact: true }) -); +const disableSwitch = testid("formDisabled"); flow("a repo that never published a site doesn't say it is live") .actor("owner") @@ -189,7 +181,7 @@ flow("a repo that never published a site doesn't say it is live") .open(sites) .sees(siteHeading) .sees(not(liveTag)) - .sees(text("No deploys yet.")); + .sees(testid("settingsEmpty")); flow("publishing a site from a directory keeps the directory after a reload") .actor("owner") @@ -228,13 +220,13 @@ flow("renaming a repo to a name the owner already has is refused") .repo() .open(renameForm) - .fill(nameField, () => firstName) - .click(button("Rename")) + .fill(testid("name"), () => firstName) + .click(testid("rename")) .step("the form says the name is taken", (ctx) => finished( ctx, "the form did not refuse the name", - expect(refusal(/already/i).locate(ctx.page, ctx)).toBeVisible() + expect(testid("errorAlert").locate(ctx.page, ctx)).toBeVisible() ) ) .at(renameForm); @@ -274,23 +266,21 @@ flow("removing a topic that was already saved") .open(page.settings) .sees(topicChip) - .click(button(`Remove ${topic}`)) + .click(testid("removeAt")) .click(saveButton) .sees(not(unsaved)) .step("the knot's manifest has no topics", noTopics) .reload() .sees(not(topicChip)); -const resetSettings = target("the reset button", (tab) => - tab.getByRole("button", { name: "Reset", exact: true }).first() -); +const resetSettings = within(component("SettingsHeader"), testid("reset")); flow("adding a topic in the settings and removing it again") .actor("owner") .repo() .open(page.settings) - .fill(placeholder("Add a topic"), topic) + .fill(testid("topics"), topic) .press("Enter") .sees(topicChip) .click(saveButton) @@ -301,7 +291,7 @@ flow("adding a topic in the settings and removing it again") .reload() .sees(topicChip) - .click(button(`Remove ${topic}`)) + .click(testid("removeAt")) .click(saveButton) .sees(not(unsaved)) .step("the knot's manifest has no topics", noTopics) @@ -315,7 +305,7 @@ flow("resetting an unsaved topic in the settings writes nothing") .repo() .open(page.settings) - .fill(placeholder("Add a topic"), topic) + .fill(testid("topics"), topic) .press("Enter") .sees(topicChip) .sees(unsaved) diff --git a/e2e/specs/repos.spec.ts b/e2e/specs/repos.spec.ts index 81cb1bec3..faf301ec5 100644 --- a/e2e/specs/repos.spec.ts +++ b/e2e/specs/repos.spec.ts @@ -5,29 +5,26 @@ import { config } from "../harness/config"; import { bobbin, button, + component, type Ctx, declarationId, - dialogButton, exactLink, - field, flow, git, hydrant, knot, knotCopy, manifestId, - nameField, not, - openDelete, page, pds, - placeholder, refsId, - refusal, target, + testid, text, through, - where + where, + within } from "../harness/flow"; import { lsRemote, push } from "../harness/git"; import { partsOf, selfUri, stamp, until } from "../harness/net"; @@ -80,9 +77,9 @@ flow("creating a repo from the new repository form") }) .open(page.newRepo) - .fill(field("Repository name"), () => made) - .fill(placeholder("A brief description of your project..."), "made by the form") - .click(button("Create repository")) + .fill(testid("name"), () => made) + .fill(testid("description"), "made by the form") + .click(testid("submit")) .traceNewRepo(() => made, through(pds, hydrant, bobbin)) .follow(manifestId, through(knotCopy, hydrant, bobbin)) .step("the manifest names the declaration and carries the description", async (ctx) => { @@ -104,35 +101,27 @@ flow("a name the owner already has is refused by the form") .repo() .open(page.newRepo) - .fill(field("Repository name"), (ctx) => ctx.repo.name) - .click(button("Create repository")) - .sees( - target("the duplicate name error", (tab, ctx) => - tab.getByText( - `A repository named "${ctx.repo.name}" already exists in your account. Choose another name.` - ) - ) - ) + .fill(testid("name"), (ctx) => ctx.repo.name) + .click(testid("submit")) + .sees(testid("errorAlert")) .at(page.newRepo); -const typedName = target("the name field in the delete dialog", (tab, ctx) => - tab.getByRole("dialog").getByPlaceholder(ctx.repo.name) -); +const typedName = testid("confirmName"); flow("the delete dialog forgets a name you typed and cancelled") .actor("owner") .repo() .open(page.settings) - .click(openDelete) + .click(testid("setConfirmDelete")) .fill(typedName, (ctx) => ctx.repo.name) - .click(dialogButton("Cancel")) - .click(openDelete) + .click(testid("clearConfirmDelete")) + .click(testid("setConfirmDelete")) .step("the name field is empty again", async (ctx) => { await expect(typedName.locate(ctx.page, ctx)).toHaveValue(""); }) .step("the delete button is disabled again", async (ctx) => { - await expect(dialogButton("Delete repository").locate(ctx.page, ctx)).toBeDisabled(); + await expect(testid("destroy").locate(ctx.page, ctx)).toBeDisabled(); }); flow("a repo nobody pushed to says it is empty") @@ -140,19 +129,17 @@ flow("a repo nobody pushed to says it is empty") .repo() .mirrored() .open(page.repo) - .sees(text("This is an empty repository.")); + .sees(testid("emptyRepoNote")); -const confirm = target("confirm delete", (tab) => - tab.getByRole("dialog").getByRole("button", { name: "Delete repository" }) -); +const confirm = testid("destroy"); flow("deleting a repo") .actor("owner") .repo() .open(page.settings) - .click(button("Delete repository")) - .fill(nameField, (ctx) => ctx.repo.name) + .click(testid("setConfirmDelete")) + .fill(testid("confirmName"), (ctx) => ctx.repo.name) .click(confirm) .sends(declarationId, pds, (ctx) => ({ rkey: ctx.repo.name }), "delete") .trace(declarationId, through(pds, hydrant, bobbin)) @@ -162,7 +149,7 @@ flow("deleting a repo") .at(page.profile); const forkForm = where("the fork form", (ctx) => `${ctx.repo.path}/fork`); -const forkLink = exactLink("Fork"); +const forkLink = testid("fork"); let upstreamOfFork = { did: "", handle: "", name: "" }; const noteUpstream = async (ctx: Ctx) => { upstreamOfFork = { did: ctx.repo.did, handle: ctx.actor.handle, name: ctx.repo.name }; @@ -180,7 +167,7 @@ flow("forking a repo from its Fork button") .open(page.repo) .click(forkLink) .at(forkForm) - .click(button("Fork repository")) + .click(testid("submit")) .traceNewRepo((ctx) => ctx.repo.name, through(pds, hydrant, bobbin)) .follow(manifestId, through(knotCopy, hydrant, bobbin)) .step("the manifest names the upstream", async (ctx) => { @@ -195,7 +182,7 @@ flow("forking a repo from its Fork button") }) .at(page.repo) .reload() - .sees(text("forked from")) + .sees(testid("forkedFromPrefix")) .sees(exactLink(() => `${upstreamOfFork.handle}/${upstreamOfFork.name}`)); const syncFork = button("Sync fork"); @@ -235,17 +222,11 @@ flow("syncing a fork brings in what the upstream pushed since") const migrate = where("the migrate page", () => "/repo/migrate"); const stubRepo = "https://github.tngl.boltless.dev/octostub/fresh.git"; -const gitUrlMethod = target("the Git URL method", (tab) => - tab.getByRole("button", { name: /Git URL/ }) -); -const gitUrlField = target("the git repository url field", (tab) => - tab.getByLabel("Git repository URL") -); -const importName = target("the name field of the import", (tab) => - tab.getByLabel("Name", { exact: true }) -); -const fetchDetails = button("Fetch repository details"); -const startMigration = button("Migrate repository"); +const gitUrlMethod = within(component("MethodPicker"), testid("select", { value: "url" })); +const gitUrlField = testid("url"); +const importName = testid("repoConfigName"); +const fetchDetails = testid("fetchDetails"); +const startMigration = testid("importUrl"); // this page polls forever, so .open() would never go idle const goToMigrate = async (ctx: Ctx) => { @@ -269,11 +250,7 @@ for (const [what, url] of [ .click(gitUrlMethod) .fill(gitUrlField, url) .click(fetchDetails) - .sees( - refusal( - "Use a public HTTPS URL without credentials, query, fragment, or local address." - ) - ); + .sees(testid("errorAlert")); } // the first migration's grant goes through the pds login page @@ -285,7 +262,7 @@ const grantTheMigrator = async (ctx: Ctx) => { await ctx.page.waitForURL((url) => url.origin === config.webUrl); }; -const fetched = text("Default branch on the remote"); +const fetched = testid("remoteBranchNotice"); flow("coming back from the migrator grant shows the git url form again") .actor("owner") @@ -350,7 +327,7 @@ flow("importing a repo from a git url brings its files over") .click(gitUrlMethod) .sees(startMigration) .click(startMigration) - .sees(text("Repository migration has successfully started")) + .sees(testid("migrationStartedText")) .step("the migration finishes", migrationFinishes) .step("bobbin knows the new repo", async (ctx) => repoOnBobbin(imported)(ctx)) .step("the knot has the stub's branch", stubBranchOnKnot) @@ -359,14 +336,10 @@ flow("importing a repo from a git url brings its files over") .sees(exactLink("README.md")) .sees(exactLink("src")); -const connectGitHub = target("the connect to github link", (tab) => - tab.getByRole("link", { name: "Connect to your GitHub account" }) -); -const authorizeMigrator = button("Authorize the migrator"); -const pickStubRepo = target("the checkbox of the stub's hello repo", (tab) => - tab.getByRole("checkbox", { name: "Select octostub/hello" }) -); -const migrateOne = button("Migrate 1 repo"); +const connectGitHub = testid("connectGithub"); +const authorizeMigrator = testid("authorize"); +const pickStubRepo = testid("repoRowCheckbox", { repo: "octostub/hello" }); +const migrateOne = testid("migrate"); flow("importing a repo from github brings its files over") .actor("owner") @@ -377,12 +350,12 @@ flow("importing a repo from github brings its files over") }) .step("open the migrate page", goToMigrate) .click(connectGitHub) - .sees(text("Connected")) + .sees(testid("githubConnectedStatus")) .click(authorizeMigrator) .step("grant the migrator access", grantTheMigrator) .click(pickStubRepo) .click(migrateOne) - .sees(text("Repository migration has successfully started")) + .sees(testid("migrationStartedText")) .checkpoint("the migration started") .step("the migration finishes", migrationFinishes) .step("bobbin knows the new repo", async (ctx) => repoOnBobbin(imported)(ctx)) diff --git a/e2e/specs/search.spec.ts b/e2e/specs/search.spec.ts index 0f2a575dc..af7e3be1f 100644 --- a/e2e/specs/search.spec.ts +++ b/e2e/specs/search.spec.ts @@ -6,12 +6,11 @@ import { git, hydrant, knotCopy, - link, manifestId, not, refsId, target, - text, + testid, through, where } from "../harness/flow"; @@ -25,7 +24,7 @@ const reposOf = (query: string) => let firstRepo = ""; -const searchBox = target("the repo search", (tab) => tab.getByLabel("Search repositories")); +const searchBox = testid("queryValue"); const searchedFor = (name: (ctx: Ctx) => string) => where("the repos tab searched for the new repo", (ctx) => reposOf(name(ctx)).path(ctx)); @@ -44,7 +43,7 @@ twoRepos("a profile's repo search finds a repo made today") .sees(exactLink((ctx) => ctx.repo.name)) .sees(not(exactLink(() => firstRepo))) .open(reposOf("no-such-repo")) - .sees(text('No repositories match "no-such-repo".')); + .sees(testid("emptyState")); twoRepos("typing in the profile's repo search narrows the list") .open(reposOf("")) @@ -59,14 +58,12 @@ twoRepos("typing in the profile's repo search narrows the list") twoRepos("clearing the profile's repo search brings the whole list back") .open(searchedFor((ctx) => ctx.repo.name)) .sees(not(exactLink(() => firstRepo))) - .click(link("Clear search")) + .click(testid("clearSearch")) .at(reposOf("")) .sees(exactLink((ctx) => ctx.repo.name)) .sees(exactLink(() => firstRepo)); -const topbarSearch = target("the top bar search", (tab) => - tab.getByRole("searchbox", { name: "Search", exact: true }) -); +const topbarSearch = testid("topbarSearch"); const repoResult = target("the repo in the results", (tab, ctx) => tab.getByRole("link", { name: ctx.repo.path.slice(1), exact: true }) ); diff --git a/e2e/specs/session.spec.ts b/e2e/specs/session.spec.ts index 2a71904da..e678274c4 100644 --- a/e2e/specs/session.spec.ts +++ b/e2e/specs/session.spec.ts @@ -4,22 +4,20 @@ import { readFileSync } from "node:fs"; import { type Account, authFile, config, seeded } from "../harness/config"; import { bobbin, - button, + component, type Ctx, - exactLink, flow, - heading, hydrant, knots, loginThen, - menuItem, page, pds, starId, target, - text, + testid, through, - where + where, + within } from "../harness/flow"; import { parseBody, stamp } from "../harness/net"; import { signInHere } from "../harness/signin"; @@ -30,7 +28,7 @@ const menuOf = (who: (ctx: Ctx) => Account) => tab.getByRole("banner").getByRole("button", { name: who(ctx).handle }) ); const actorMenu = menuOf((ctx) => ctx.actor); -const logout = menuItem("Logout"); +const logout = testid("signOut"); const otherOf = (ctx: Ctx) => recall(ctx, "other"); @@ -84,7 +82,7 @@ const firstStar = (name: string, options: { login?: "ui" } = {}) => .open(page.repo) .sees(actorMenu) .step("the browser keeps the session the way a login does", keptLikeALogin) - .click(button("Star")) + .click(testid("star", { starred: "false" })) .sends( starId, pds, @@ -93,7 +91,7 @@ const firstStar = (name: string, options: { login?: "ui" } = {}) => ) .trace(starId, through(pds, hydrant, bobbin)) .reload() - .sees(button("Starred")); + .sees(testid("star", { starred: "true" })); firstStar("a new account logged in through the login page stars a repo", { login: "ui" }); firstStar("a new account with a session minted from node stars a repo"); @@ -105,14 +103,14 @@ flow("logging out of one of two accounts in a browser keeps the other signed in" .open(knots) .click(actorMenu) - .click(menuItem("Add account")) + .click(testid("addAccount")) .at(loginThen(() => "/settings/knots")) .step("the second account signs in beside the first", signInFromHere(otherOf)) .at(knots) .sees(menuOf(otherOf)) .click(menuOf(otherOf)) - .sees(menuItem("Logout")) + .sees(logout) .click(logout) .sees(actorMenu) .step("the browser keeps the first account and has let go of the second", async (ctx) => { @@ -136,7 +134,7 @@ flow("logging out ends the session and a settings page asks for a login again") }) .click(actorMenu) .click(logout) - .sees(exactLink("Login")) + .sees(within(component("Topbar"), testid("login"))) .step("the browser has forgotten the account", async (ctx) => { const saw = await browserState(ctx); expect(saw.current).toBeNull(); @@ -154,7 +152,7 @@ flow("switching to the other account in the menu changes who the page is signed .open(knots) .click(actorMenu) - .click(menuItem("Add account")) + .click(testid("addAccount")) .step("the second account signs in beside the first", signInFromHere(otherOf)) .at(knots) .sees(menuOf(otherOf)) @@ -191,11 +189,11 @@ flow("a visitor who clicks Follow is sent to log in and comes back to that profi .open(followeeProfile) .step("signs out", forgetTheLogin) .open(followeeProfile) - .click(exactLink("Follow")) + .click(testid("follow", { following: "false" })) .at(loginThen((ctx) => `/${otherOf(ctx).handle}`)) .step("signs back in", signInAsActor) .at(followeeProfile) - .sees(button("Follow")); + .sees(testid("follow", { following: "false" })); flow("a visitor who clicks Star is sent to log in and comes back to that repo") .actor("owner") @@ -205,11 +203,11 @@ flow("a visitor who clicks Star is sent to log in and comes back to that repo") .open(page.repo) .step("signs out", forgetTheLogin) .open(page.repo) - .click(exactLink("Star")) + .click(testid("star", { starred: "false" })) .at(loginThen((ctx) => ctx.repo.path)) .step("signs back in", signInAsActor) .at(page.repo) - .sees(button("Star")); + .sees(testid("star", { starred: "false" })); flow("a visitor who opens a repo's settings is sent to log in and comes back to them") .actor("owner", { login: "ui" }) @@ -221,7 +219,7 @@ flow("a visitor who opens a repo's settings is sent to log in and comes back to .at(loginThen((ctx) => `${ctx.repo.path}/settings`)) .step("signs back in", signInAsActor) .at(page.settings) - .sees(text("Danger zone")); + .sees(testid("settingsSectionTitle", { name: "danger" })); const knotsWithQuery = where("the knots settings with a query", () => "/settings/knots?from=email"); @@ -234,7 +232,7 @@ flow("a visitor who opens the knot settings is sent to log in and comes back to .at(loginThen(() => "/settings/knots?from=email")) .step("signs back in", signInAsActor) .at(knotsWithQuery) - .sees(heading("Knots")); + .sees(testid("settingsHeaderTitle", { name: "knots" })); const inviteHost = "knot.invited.example.test"; const invite = where("the invite page", (ctx) => `/invite?knot=${inviteHost}&code=${code(ctx)}`); @@ -255,8 +253,8 @@ flow("an invite link opened signed out logs you in and lands on the join page") .at(loginThen((ctx) => `/invite?knot=${inviteHost}&code=${code(ctx)}`)) .step("signs in", signInAsActor) .at(invite) - .sees(heading(`Join ${inviteHost}`)) - .sees(button(`Join ${inviteHost}`)); + .sees(testid("joinHeading", { knot: inviteHost })) + .sees(testid("join")); const banners = [ { error: "access_denied", says: "You have not authorized the app." }, @@ -267,8 +265,8 @@ const banners = [ for (const { error, says } of banners) { test(`the login page says "${says}" when it is opened with ${error}`, async ({ page: tab }) => { await tab.goto(`/login?error=${error}`, { waitUntil: "networkidle" }); - await expect(tab.getByRole("heading", { name: "Login error" })).toBeVisible(); - await expect(tab.getByText(says, { exact: true })).toBeVisible(); + await expect(tab.locator('[data-testid="loginErrorHeading"]')).toBeVisible(); + await expect(tab.locator('[data-testid="loginErrorMessage"]')).toContainText(says); }); } @@ -276,11 +274,13 @@ test("a handle that does not exist is told so on the login page and stays there" page: tab }) => { await tab.goto("/login", { waitUntil: "networkidle" }); - const handle = tab.getByLabel("Handle", { exact: true }); + const handle = tab.getByTestId("identifier"); await handle.fill(`nobody-${stamp()}.pds.tngl.boltless.dev`); await handle.press("Enter"); - await expect(tab.getByRole("heading", { name: "Login error" })).toBeVisible(); - await expect(tab.getByText("Please try again.")).toBeVisible(); + await expect(tab.locator('[data-testid="loginErrorHeading"]')).toBeVisible(); + await expect(tab.locator('[data-testid="loginErrorMessage"]')).toContainText( + "Please try again." + ); await expect(tab).toHaveURL(`${config.webUrl}/login`); }); diff --git a/e2e/specs/social.spec.ts b/e2e/specs/social.spec.ts index 140689140..7f49f17f8 100644 --- a/e2e/specs/social.spec.ts +++ b/e2e/specs/social.spec.ts @@ -4,14 +4,10 @@ import { pdsCall } from "../harness/api"; import { type Account, config } from "../harness/config"; import { bobbin, - button, commentId, type Ctx, - discussion, - field, flow, followId, - heading, hydrant, knot, not, @@ -23,9 +19,11 @@ import { subscriptionId, target, text, + testid, through, ticketId, - where + where, + within } from "../harness/flow"; import { get, until, xrpc } from "../harness/net"; import { type Notice, noticesOf } from "../harness/services"; @@ -46,12 +44,12 @@ const noticeFor = async (ctx: Ctx, type: string, about: (notice: Notice) => bool const notices = where("the notifications page", () => "/notifications"); const bell = (unread: number) => - target(`the bell saying ${unread} unread`, (tab) => - tab.getByRole("button", { name: `Notifications, ${unread} unread`, exact: true }) - ); + unread > 0 ? testid("unreadCount", { count: unread }) : not(testid("unreadCount")); const noticeLink = (phrase: string) => target(`the notice "${phrase}"`, (tab, ctx) => - tab.getByRole("link", { name: `${phrase} ${ctx.repo.path.slice(1)}`, exact: true }) + testid("notificationPhraseLink") + .locate(tab, ctx) + .filter({ hasText: `${phrase} ${ctx.repo.path.slice(1)}` }) ); const starsOwner = (ctx: Ctx) => ({ @@ -65,7 +63,7 @@ flow("a star notifies the repo owner and opening the notice marks it read") .repo() .actor("fan") .open(page.repo) - .click(button("Star")) + .click(testid("star", { starred: "false" })) .sends(starId, pds, starsOwner, "create") .trace(starId, through(pds, hydrant, bobbin)) @@ -104,14 +102,14 @@ const commentedByAFan = (name: string) => .actor("owner") .repo() .open(page.newIssue) - .fill(field("Title"), issueTitle) - .click(button("Create issue")) + .fill(testid("title"), issueTitle) + .click(testid("submit")) .trace(ticketId, through(knot, hydrant, bobbin)) .trace(subscriptionId, through(pds, hydrant, bobbin)) .actor("fan") .open(page.issue(ticketId)) - .fill(discussion, "me too") - .click(button("Comment")) + .fill(testid("body"), "me too") + .click(testid("submit")) .trace(commentId, through(knot, hydrant, bobbin)) .trace(subscriptionId, through(pds)) .actor("owner") @@ -128,7 +126,7 @@ commentedByAFan("the notice for a comment on an issue opens that issue") .open(notices) .click(noticeLink("commented on an issue on")) .at(page.issue(ticketId)) - .sees(heading(issueTitle)); + .sees(within(testid("ticketTitle"), text(issueTitle))); commentedByAFan("deleting an issue takes the notice about it away") .open(page.issue(ticketId)) @@ -156,14 +154,14 @@ flow("a star shows on the repo's stargazers page") .actor("fan") .open(stargazers) - .sees(text("No stars yet.")) + .sees(testid("emptyState")) .open(page.repo) - .click(button("Star")) + .click(testid("star", { starred: "false" })) .sends(starId, pds, starsOwner, "create") .trace(starId, through(pds, hydrant, bobbin)) .open(stargazers) .sees(handleLink((ctx) => ctx.actor)) - .sees(not(text("No stars yet."))) + .sees(not(testid("emptyState"))) .reload() .sees(handleLink((ctx) => ctx.actor)); @@ -235,8 +233,8 @@ flow("following someone and unfollowing them") .actor("fan") .open(profile) - .sees(text("0 followers")) - .click(button("Follow")) + .sees(testid("followersCount", { count: 0 })) + .click(testid("follow", { following: "false" })) .sends( followId, pds, @@ -244,33 +242,33 @@ flow("following someone and unfollowing them") "put" ) // the button waits on the server, so the state doesn't flip yet - .sees(button("Follow")) + .sees(testid("follow", { following: "false" })) .trace(followId, through(pds, hydrant, bobbin)) .step("the follow record has no subject, its rkey is the followee", async (ctx) => { const { body } = await get(pdsRecordUrl(ctx.actor.did, followId.nsid, star.did)); expect(Object.keys(body.value).sort()).toEqual(["$type", "createdAt"]); }) .step("bobbin counts one follower", followersAre(1)) - .sees(button("Unfollow")) - .sees(text("1 follower")) + .sees(testid("follow", { following: "true" })) + .sees(testid("followersCount", { count: 1 })) .reload() - .sees(button("Unfollow")) - .sees(text("1 follower")) + .sees(testid("follow", { following: "true" })) + .sees(testid("followersCount", { count: 1 })) - .click(button("Unfollow")) + .click(testid("follow", { following: "true" })) .sends( followId, pds, followedBy(() => star.did), "delete" ) - .sees(button("Unfollow")) + .sees(testid("follow", { following: "true" })) .trace(followId, through(pds, hydrant, bobbin)) .step("bobbin counts no followers", followersAre(0)) - .sees(button("Follow")) + .sees(testid("follow", { following: "false" })) .reload() - .sees(button("Follow")) - .sees(text("0 followers")); + .sees(testid("follow", { following: "false" })) + .sees(testid("followersCount", { count: 0 })); flow("starring and unstarring a repo") .actor("owner") @@ -278,10 +276,10 @@ flow("starring and unstarring a repo") .actor("fan") .open(page.repo) - .click(button("Star")) + .click(testid("star", { starred: "false" })) .sends(starId, pds, starsOwner, "create") // optimistic ui, the server hasn't seen the write yet - .sees(button("Starred")) + .sees(testid("star", { starred: "true" })) .sees(starCount, "1") .step("bobbin has not counted it", async (ctx) => expect(await bobbin.countStars(ctx.repo.did)).toBe(0) @@ -291,19 +289,19 @@ flow("starring and unstarring a repo") expect(await bobbin.countStars(ctx.repo.did)).toBe(1) ) .reload() - .sees(button("Starred")) + .sees(testid("star", { starred: "true" })) .sees(starCount, "1") - .click(button("Starred")) + .click(testid("star", { starred: "true" })) .sends(starId, pds, (ctx) => ({ rkey: rkeyOf(ctx, starId) }), "delete") - .sees(button("Star")) + .sees(testid("star", { starred: "false" })) .sees(starCount, "0") .trace(starId, through(pds, hydrant, bobbin)) .step("bobbin counts no stars", async (ctx) => expect(await bobbin.countStars(ctx.repo.did)).toBe(0) ) .reload() - .sees(button("Star")) + .sees(testid("star", { starred: "false" })) .sees(starCount, "0"); // bobbin answers 404 when there is no subscription @@ -323,7 +321,7 @@ flow("watching a repo and giving it up again") .actor("fan") .open(page.repo) - .click(button("Watch")) + .click(testid("watch", { subscribed: "false" })) .sends( subscriptionId, pds, @@ -333,25 +331,25 @@ flow("watching a repo and giving it up again") "create" ) // the watch button doesn't wait for the server - .sees(button("Watching")) + .sees(testid("watch", { subscribed: "true" })) .trace(subscriptionId, through(pds, hydrant, bobbin)) .step( "bobbin lists the watch", subscribed((ctx) => ctx.repo.did, true) ) .reload() - .sees(button("Watching")) + .sees(testid("watch", { subscribed: "true" })) - .click(button("Watching")) + .click(testid("watch", { subscribed: "true" })) .sends(subscriptionId, pds, (ctx) => ({ rkey: rkeyOf(ctx, subscriptionId) }), "delete") - .sees(button("Watch")) + .sees(testid("watch", { subscribed: "false" })) .trace(subscriptionId, through(pds, hydrant, bobbin)) .step( "bobbin no longer lists the watch", subscribed((ctx) => ctx.repo.did, false) ) .reload() - .sees(button("Watch")); + .sees(testid("watch", { subscribed: "false" })); const issueUri = (ctx: Ctx) => ctx.get(ticketId).uri; @@ -362,7 +360,7 @@ flow("subscribing to an issue and unsubscribing again") .actor("fan") .open(page.issue(ticketId)) - .click(button("Subscribe")) + .click(testid("toggleSubscription", { subscribed: "false" })) .sends( subscriptionId, pds, @@ -374,18 +372,18 @@ flow("subscribing to an issue and unsubscribing again") "create" ) // the subscribe button waits for the server - .sees(button("Subscribing...")) + .sees(testid("toggleSubscription", { subscribed: "false", pending: "true" })) .trace(subscriptionId, through(pds, hydrant, bobbin)) .step("bobbin lists the subscription", subscribed(issueUri, true)) - .sees(button("Unsubscribe")) + .sees(testid("toggleSubscription", { subscribed: "true" })) .reload() - .sees(button("Unsubscribe")) + .sees(testid("toggleSubscription", { subscribed: "true" })) - .click(button("Unsubscribe")) + .click(testid("toggleSubscription", { subscribed: "true" })) .sends(subscriptionId, pds, (ctx) => ({ rkey: rkeyOf(ctx, subscriptionId) }), "delete") - .sees(button("Unsubscribing...")) + .sees(testid("toggleSubscription", { subscribed: "true", pending: "true" })) .trace(subscriptionId, through(pds, hydrant, bobbin)) .step("bobbin no longer lists the subscription", subscribed(issueUri, false)) - .sees(button("Subscribe")) + .sees(testid("toggleSubscription", { subscribed: "false" })) .reload() - .sees(button("Subscribe")); + .sees(testid("toggleSubscription", { subscribed: "false" })); diff --git a/e2e/specs/strings.spec.ts b/e2e/specs/strings.spec.ts index 422f10220..83b52a9ce 100644 --- a/e2e/specs/strings.spec.ts +++ b/e2e/specs/strings.spec.ts @@ -4,23 +4,22 @@ import { getOk } from "../harness/api"; import { config } from "../harness/config"; import { bobbin, - button, type Ctx, flow, hydrant, link, - menuItem, not, pds, - placeholder, record, replacing, rkeyOf, starId, target, text, + testid, through, - where + where, + within } from "../harness/flow"; import { get, until, xrpc } from "../harness/net"; import { acceptConfirm, pdsRecordUrl, recall, remember } from "../harness/shared"; @@ -45,7 +44,9 @@ const typeInEditor = (words: string, replace = false) => async (ctx: Ctx) => { const editor = stringEditor.locate(ctx.page, ctx); - const typed = ctx.page.getByText(`${Buffer.byteLength(words)} bytes`); + const typed = ctx.page.locator( + `[data-testid="contentBytes"][data-bytes="${Buffer.byteLength(words)}"]` + ); // the editor drops keys when it redraws after the filename changes await until("the editor should hold what was typed", 30_000, async () => { await editor.click(); @@ -76,10 +77,10 @@ const writtenString = (name: string) => .actor("writer") .step("remember the writer", remember("writer")) .open(newString) - .fill(placeholder("main.rs"), stringFilename) - .fill(placeholder("What this string is for"), "my notes") + .fill(testid("filename"), stringFilename) + .fill(testid("description"), "my notes") .step("types the content", typeInEditor(stringBody)) - .click(button("Publish")) + .click(testid("submit")) .sends( string, pds, @@ -104,17 +105,17 @@ writtenString("creating a string") }) .reload() .sees(text(stringBody)) - .sees(text("my notes")) + .sees(within(testid("stringDescription"), text("my notes"))) .open(stringsTab) .sees(link(stringFilename)); const edited = "the needle moved to another haystack"; writtenString("editing a string") - .click(button("Edit")) - .fill(placeholder("What this string is for"), "my edited notes") + .click(testid("edit")) + .fill(testid("description"), "my edited notes") .step("types the new content", typeInEditor(edited, true)) - .click(button("Save changes")) + .click(testid("submit")) .sends( string, pds, @@ -126,7 +127,7 @@ writtenString("editing a string") ) .trace(string, through(pds, hydrant, bobbin)) .sees(text(edited)) - .sees(text("my edited notes")) + .sees(within(testid("stringDescription"), text("my edited notes"))) .step("the pds keeps the new text", async (ctx) => { expect((await stringOnPds(ctx)).contents).toBe(edited); }) @@ -136,19 +137,19 @@ writtenString("editing a string") writtenString("deleting a string") .step("agree to the confirm", acceptConfirm) - .click(button(`More actions for ${stringFilename}`)) - .click(menuItem("Delete")) + .click(testid("moreMenu")) + .click(testid("delete")) .sends(string, pds, replacing(string), "delete") .trace(string, through(pds, hydrant, bobbin)) .at(stringsTab) .sees(not(link(stringFilename))) .open(stringPage) - .sees(text("Page not found")); + .sees(testid("errorTitle", { status: "404" })); writtenString("starring a string") .actor("fan") .open(stringPage) - .click(button("Star")) + .click(testid("star", { starred: "false" })) .sends( starId, pds, @@ -162,10 +163,10 @@ writtenString("starring a string") }), "create" ) - .sees(button("Starred")) + .sees(testid("star", { starred: "true" })) .trace(starId, through(pds, hydrant, bobbin)) .reload() - .sees(button("Starred")) + .sees(testid("star", { starred: "true" })) .open(where("the fan's starred tab", (ctx) => `/${ctx.actor.handle}?tab=starred`)) .sees( target("the starred string", (tab, ctx) => -- knotmirror 0.0.1