import { expect, type Page } from "@playwright/test"; import { Build, BuildReview, ScreenshotDiff, } from "../apps/backend/src/database/models"; import { BuildScenario, createBuildScenario, createFallbackBaselineScenario, createSiblingBuildsScenario, createVariantSwitchersScenario, } from "../apps/backend/src/database/seeds"; import { loggedTest } from "./logged-test"; import { ensureTeamOwner, phoneScreenshot, screenshot } from "./util"; const buildExamples: { name: string; getNumber: (builds: BuildScenario) => number; compare?: false; /** * Assert every thumbnail in the diff list actually decoded. Only worth doing * on a build whose snapshots point at the CDN image fixtures: a key nobody * uploaded renders as a broken image, and the screenshots below then burn * their whole stabilization budget waiting on the CDN to answer the miss. */ expectLoadedImages?: true; /** Also capture the build as a phone shows it: once is enough. */ phone?: true; }[] = [ { name: "orphan", getNumber: (b) => b.orphanBuild.number }, { name: "reference", getNumber: (b) => b.referenceBuild.number }, { name: "expired", getNumber: (b) => b.expiredBuild.number, compare: false, }, { name: "aborted", getNumber: (b) => b.abortedBuild.number, compare: false, }, { name: "error", getNumber: (b) => b.errorBuild.number, compare: false }, { name: "changes detected", getNumber: (b) => b.diffDetectedBuild.number, expectLoadedImages: true, phone: true, }, { name: "rejected", getNumber: (b) => b.rejectedBuild.number }, { name: "scheduled", getNumber: (b) => b.pendingBuild.number, compare: false, }, { name: "in progress", getNumber: (b) => b.inProgressBuild.number, compare: false, }, { name: "empty", getNumber: (b) => b.emptyBuild.number, compare: false }, { name: "stable", getNumber: (b) => b.stableBuild.number }, { name: "stable with fail screenshots", getNumber: (b) => b.failBuild.number, }, { name: "stable with removed screenshots", getNumber: (b) => b.removedBuild.number, }, { name: "subset", getNumber: (b) => b.subsetBuild.number }, ]; function getStartButton(page: Page) { return page.getByRole("button", { name: /^(Start review|Browse snapshots|Browse test failures)/, }); } buildExamples.forEach((build) => { loggedTest(build.name, async ({ page, auth, team, project, builds }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); const number = build.getNumber(builds); await page.goto(`/${team.account.slug}/${project.name}/builds/${number}`); await expect(page.getByText(`Build ${number}`)).toBeVisible(); if (build.compare !== false) { await expect(page).toHaveURL(new RegExp(`/builds/${number}/overview$`)); await expect(getStartButton(page)).toBeVisible(); if (build.expectLoadedImages) { // `shown` guards against passing before the virtualized diff list has // mounted any thumbnail, and `broken` names the offending URLs rather // than reporting a bare boolean. await expect .poll(() => page.evaluate(() => { const fixtures = Array.from(document.images).filter((img) => /-\d+x\d+\.(png|jpg)/.test(img.currentSrc), ); return { shown: fixtures.length > 0, broken: fixtures .filter((img) => !(img.complete && img.naturalWidth > 0)) .map((img) => new URL(img.currentSrc).pathname), }; }), ) .toEqual({ shown: true, broken: [] }); } await screenshot(page, `build-overview-${build.name}`, { replacements: { [team.account.slug]: "acme", }, }); if (build.phone) { await phoneScreenshot(page, `build-overview-${build.name}`, { ready: (phone) => expect(getStartButton(phone)).toBeVisible(), replacements: { [team.account.slug]: "acme" }, }); } // Starting the review replaces the overview in history (the whole build // view is a single history entry), so navigate forward rather than relying // on `goBack` to return to the overview. await page.keyboard.press("Enter"); await expect(page).toHaveURL( new RegExp(`/builds/${number}/(?!overview)[^/]+$`), ); // The pane's own heading, not any of the other places the word appears // (the view-mode button, the sidebar's changes count). await expect( page.getByRole("heading", { name: "Changes", exact: true }), ).toBeVisible(); } await screenshot(page, `build-${build.name}`, { replacements: { [team.account.slug]: "acme", }, }); if (build.phone) { await phoneScreenshot(page, `build-${build.name}`, { ready: (phone) => expect( phone.getByRole("heading", { name: "Changes", exact: true }), ).toBeVisible(), replacements: { [team.account.slug]: "acme" }, }); } }); }); loggedTest( "subset build ignores removed snapshots", async ({ page, auth, team, project, builds }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); const { number } = builds.subsetBuild; await page.goto( `/${team.account.slug}/${project.name}/builds/${number}/overview`, ); // The build has 3 unchanged, 1 changed and 2 removed snapshots. Because it // only uploaded a subset, the removals mean "test not run", not "snapshot // deleted": they must not show up as something to review. await expect(page.getByText("A single visual change")).toBeVisible(); await expect(page.getByText("2 removed")).toHaveCount(0); await expect(page.getByText("Skipped", { exact: true })).toBeVisible(); await expect(page.getByText("Removed", { exact: true })).toHaveCount(0); }, ); loggedTest( "total screenshots is what the build is billed for", async ({ page, auth, team, project }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); // Four diffs, one of them removed: the build uploaded three screenshots. const { build } = await createVariantSwitchersScenario({ projectId: project.id, }); await page.goto( `/${team.account.slug}/${project.name}/builds/${build.number}/overview`, ); await page.getByRole("tab", { name: "Info" }).click(); await expect( page.locator("dt:has-text('Total screenshots') + dd"), ).toHaveText("3"); }, ); loggedTest( "snapshot compared against a fallback baseline", async ({ page, auth, team, project }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); const { build } = await createFallbackBaselineScenario({ projectId: project.id, }); await page.goto( `/${team.account.slug}/${project.name}/builds/${build.number}`, ); await page .getByRole("button", { name: /^(Start review|Browse snapshots)/ }) .click(); // The metadata sidebar states which baseline the snapshot was compared // against, since it differs from the snapshot's own name. // The snapshot is titled by its own name, and the metadata sidebar names // the baseline it was compared against. await expect( page.getByRole("heading", { name: "home-variant-b.png" }), ).toBeVisible(); await expect(page.getByText("Baseline home.png")).toBeVisible(); await screenshot(page, "build-fallback-baseline", { replacements: { [team.account.slug]: "acme", }, }); }, ); loggedTest( "offers the next build of the commit once a review is submitted", async ({ page, auth, team, project }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); const { defaultBuild, storybookBuild, docsBuild, docsProject } = await createSiblingBuildsScenario({ projectId: project.id }); await page.goto( `/${team.account.slug}/${project.name}/builds/${defaultBuild.number}`, ); await page.getByRole("button", { name: "Submit review" }).click(); await page.getByRole("button", { name: "Approve" }).click(); // Two more builds ran on the same commit and nobody has reviewed them, so // finishing this one hands the reviewer straight to the next. // // The prompt waits on the review mutation's response, and the server only // answers it once the build notifications and the automations have been // enqueued — a broker round-trip on top of the request. const dialog = page.getByRole("dialog", { name: "Review the next build" }); await expect(dialog).toBeVisible({ timeout: 15_000 }); await expect( dialog.getByText("2 more builds ran on this commit"), ).toBeVisible(); // The row carries what the reviewer needs to choose: which build it is, // which project it ran in, and where its review stands. await expect( dialog.getByRole("link", { name: `storybook ${team.account.slug}/${project.name} Changes detected`, }), ).toBeVisible(); // The commit reaches beyond this project, and that build is offered with a // link into its own project. await expect( dialog.getByRole("link", { name: `default ${team.account.slug}/${docsProject.name} Changes detected`, }), ).toHaveAttribute( "href", `/${team.account.slug}/${docsProject.name}/builds/${docsBuild.number}/overview`, ); await screenshot(page, "build-next-review-dialog", { replacements: { [team.account.slug]: "acme", }, }); await dialog.getByRole("link", { name: "Review next build" }).click(); await expect(page).toHaveURL( new RegExp(`/builds/${storybookBuild.number}/overview$`), ); // The prompt belongs to the build it was raised on: it must not survive // the jump and keep pointing at the build the reviewer just left. await expect(dialog).toBeHidden(); // Landing on the next build means reviewing *its* snapshots: starting the // review has to open a snapshot of the build being looked at, not one the // previous build left in the list. const storybookDiff = await ScreenshotDiff.query() .findOne({ buildId: storybookBuild.id }) .throwIfNotFound(); await page .getByRole("button", { name: /^(Start review|Browse snapshots)/ }) .click(); await expect(page).toHaveURL( new RegExp(`/builds/${storybookBuild.number}/${storybookDiff.id}$`), ); await expect(page.getByRole("heading", { name: "home.png" })).toBeVisible(); }, ); loggedTest( "prompts for the next build from the review dialog too", async ({ page, auth, team, project }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); const { defaultBuild } = await createSiblingBuildsScenario({ projectId: project.id, }); await page.goto( `/${team.account.slug}/${project.name}/builds/${defaultBuild.number}`, ); await page .getByRole("button", { name: /^(Start review|Browse snapshots)/ }) .click(); // Marking the build's only change opens the review dialog on its own. That // dialog hosts a review form outside the page's children — it has to reach // the same next-build prompt the header popover does. IconButtons carry no // accessible name, so the thumb icon locates the button. await page.locator("button:has(.lucide-thumbs-up)").first().click(); const reviewDialog = page.getByRole("dialog", { name: "Submit your review", }); await expect(reviewDialog).toBeVisible(); await reviewDialog.getByRole("button", { name: "Approve" }).click(); const dialog = page.getByRole("dialog", { name: "Review the next build" }); await expect(dialog).toBeVisible({ timeout: 15_000 }); await expect( dialog.getByRole("link", { name: "Review next build" }), ).toBeVisible(); }, ); loggedTest( "sets the next build apart from the others awaiting a review", async ({ page, auth, team, project }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); const { defaultBuild, docsProject, extraBuilds } = await createSiblingBuildsScenario({ projectId: project.id, extraSuites: [ "a11y", "e2e-chrome", "e2e-firefox", "e2e-webkit", "unit", ], }); const [a11yBuild, chromeBuild] = extraBuilds; if (!a11yBuild || !chromeBuild) { throw new Error("The scenario was seeded with the extra suites"); } await Build.query() .findById(a11yBuild.id) .patch({ conclusion: "no-changes" }); await page.goto( `/${team.account.slug}/${project.name}/builds/${defaultBuild.number}`, ); await page.getByRole("button", { name: "Submit review" }).click(); await page.getByRole("button", { name: "Approve" }).click(); const dialog = page.getByRole("dialog", { name: "Review the next build" }); await expect(dialog).toBeVisible({ timeout: 15_000 }); await expect( dialog.getByText("6 more builds ran on this commit"), ).toBeVisible(); // A suite with nothing to review is no stop on the way, so it stays out. const rows = dialog.getByRole("table").getByRole("link"); await expect(rows.filter({ hasText: "a11y" })).toHaveCount(0); // The dialog's action does not name the build it goes to, so that build // is listed on its own. const chromeBuildURL = `/${team.account.slug}/${project.name}/builds/${chromeBuild.number}/overview`; await expect( dialog.getByRole("table", { name: "Up next" }).getByRole("link"), ).toHaveAttribute("href", chromeBuildURL); await expect( dialog.getByRole("link", { name: "Review next build" }), ).toHaveAttribute("href", chromeBuildURL); // Six builds await a review and five are listed: the last one, in the // other project, folds. await expect(rows).toHaveCount(5); const docsRow = rows.filter({ hasText: docsProject.name }); await expect(docsRow).toHaveCount(0); await screenshot(page, "build-next-review-dialog-folded", { replacements: { [team.account.slug]: "acme", }, }); await dialog.getByRole("button", { name: "Show 1 more build" }).click(); await expect(rows).toHaveCount(6); // The button is gone, so focus moves to the build it was hiding. await expect(docsRow).toBeFocused(); }, ); loggedTest( "points an outdated build to the latest build of its suite", async ({ page, auth, team, project }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); const { defaultBuild, newerDefaultBuild } = await createSiblingBuildsScenario({ projectId: project.id, withNewerBuild: true, }); if (!newerDefaultBuild) { throw new Error("The scenario was seeded with a newer build"); } await page.goto( `/${team.account.slug}/${project.name}/builds/${defaultBuild.number}`, ); const banner = page .getByRole("alert") .filter({ hasText: "A newer build exists for branch feat/sparkle." }); await expect(banner).toBeVisible(); await expect( banner.getByRole("link", { name: `Go to build ${newerDefaultBuild.number}`, }), ).toHaveAttribute( "href", `/${team.account.slug}/${project.name}/builds/${newerDefaultBuild.number}/overview`, ); await screenshot(page, "build-newer-build-banner", { replacements: { [team.account.slug]: "acme", }, }); // The latest build has nothing newer to point to. await page.goto( `/${team.account.slug}/${project.name}/builds/${newerDefaultBuild.number}`, ); await expect( page.getByRole("link", { name: `Build ${newerDefaultBuild.number}`, exact: true, }), ).toBeVisible(); await expect(page.getByText("A newer build exists")).toHaveCount(0); }, ); loggedTest( "offers the latest build once an outdated one is reviewed", async ({ page, auth, team, project }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); const { defaultBuild, newerDefaultBuild } = await createSiblingBuildsScenario({ projectId: project.id, withNewerBuild: true, }); if (!newerDefaultBuild) { throw new Error("The scenario was seeded with a newer build"); } await page.goto( `/${team.account.slug}/${project.name}/builds/${defaultBuild.number}`, ); await page.getByRole("button", { name: "Submit review" }).click(); await page.getByRole("button", { name: "Approve" }).click(); const dialog = page.getByRole("dialog", { name: "Review the latest build", }); await expect(dialog).toBeVisible({ timeout: 15_000 }); await expect( dialog.getByText( `Build ${newerDefaultBuild.number} is the latest of branch feat/sparkle.`, ), ).toBeVisible(); // The later commit only ran `default`: the commit's other suites are still // the latest of their own and wait for a review, so they stay listed. await expect( dialog.getByText("2 other suites of this commit still need your review."), ).toBeVisible(); await expect( dialog.getByRole("link", { name: `storybook ${team.account.slug}/${project.name} Changes detected`, }), ).toBeVisible(); await screenshot(page, "build-newer-build-dialog", { replacements: { [team.account.slug]: "acme", }, }); await dialog.getByRole("link", { name: "Review latest build" }).click(); await expect(page).toHaveURL( new RegExp(`/builds/${newerDefaultBuild.number}/overview$`), ); // Both directions of the suite's history agree: the build the reviewer is // sent to offers to carry over what they just approved. await expect( page.getByRole("dialog", { name: "Previous approvals detected" }), ).toBeVisible(); }, ); loggedTest( "does not send the reviewer to a newer build with nothing to review", async ({ page, auth, team, project }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); const { defaultBuild, newerDefaultBuild } = await createSiblingBuildsScenario({ projectId: project.id, withNewerBuild: true, }); if (!newerDefaultBuild) { throw new Error("The scenario was seeded with a newer build"); } await Build.query() .findById(newerDefaultBuild.id) .patch({ conclusion: "no-changes" }); await page.goto( `/${team.account.slug}/${project.name}/builds/${defaultBuild.number}`, ); await page.getByRole("button", { name: "Submit review" }).click(); await page.getByRole("button", { name: "Approve" }).click(); // The latest build has no changes: the prompt falls back to the commit's // suites still awaiting a review. const dialog = page.getByRole("dialog", { name: "Review the next build" }); await expect(dialog).toBeVisible({ timeout: 15_000 }); await expect( dialog.getByText("2 more builds ran on this commit"), ).toBeVisible(); }, ); loggedTest( "header switches between the builds of a commit", async ({ page, auth, team, project }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); const { defaultBuild, storybookBuild, docsBuild, docsProject } = await createSiblingBuildsScenario({ projectId: project.id }); await page.goto( `/${team.account.slug}/${project.name}/builds/${defaultBuild.number}`, ); const trigger = page.getByRole("button", { name: "Switch build" }); await trigger.click(); // Every build of the commit is listed with where its review stands, the // one being looked at included, and each names the project it ran in — the // two projects of the commit both call their suite `default`. The menu kit // is a listbox driven by a search field, so its rows are options rather // than menu items. const menu = page.getByRole("listbox"); await expect( menu.getByRole("option", { name: `Changes detected ${defaultBuild.name} #${defaultBuild.number} ${team.account.slug}/${project.name}`, }), ).toBeVisible(); await expect( menu.getByRole("option", { name: `Changes detected ${storybookBuild.name} #${storybookBuild.number} ${team.account.slug}/${project.name}`, }), ).toBeVisible(); const docsItem = menu.getByRole("option", { name: `Changes detected ${docsBuild.name} #${docsBuild.number} ${team.account.slug}/${docsProject.name}`, }); await expect(docsItem).toBeVisible(); // The click leaves the pointer on the trigger, and the trigger's tooltip // can be up over the menu. Step off and wait for it to go, because the // capture's own step-off lands inside the tooltip's close delay. The menu // is portaled too, hence the tooltip's text rather than its portal. await page.mouse.move(0, 0); await expect(page.getByText("Switch build")).toBeHidden(); await screenshot(page, "build-switcher", { replacements: { [team.account.slug]: "acme", }, }); // Switching lands on the build of the other project, not on a build of // this one that happens to share its number. await docsItem.click(); await expect(page).toHaveURL( new RegExp( `/${team.account.slug}/${docsProject.name}/builds/${docsBuild.number}/overview$`, ), ); }, ); loggedTest( "build sidebar marks a review an agent submitted for its reviewer", async ({ page, auth, team, project, builds }) => { // The seeded approval has no author, so it renders as "Unknown user" with // no avatar to badge. Give it one, and an agent. await BuildReview.query() .patch({ userId: auth.user.id, agent: "claude-code" }) .where({ buildId: builds.acceptedBuild.id }); await page.goto( `/${team.account.slug}/${project.name}/builds/${builds.acceptedBuild.number}`, ); const badge = page.getByRole("img", { name: "Reviewed through Claude Code on behalf of this user", }); await expect(badge.first()).toBeVisible(); await screenshot(page, "build-agent-review"); }, ); loggedTest( "undoes and redoes review marks with the keyboard", async ({ page, auth, team, project, builds }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); await page.goto( `/${team.account.slug}/${project.name}/builds/${builds.diffDetectedBuild.number}`, ); // A standalone changed snapshot, so the mark lands on it alone: the other // changed ones are bundled under a "3 similar" group, which is marked as // a whole. await page .getByRole("button", { name: "dummy-375x1440.png" }) .first() .click(); // `aria-pressed` narrows it to the toolbar's own accept button: a diff // list header carries the same thumb once its whole group is accepted, and // it comes first in the document. const acceptButton = page.locator( "button[aria-pressed]:has(.lucide-thumbs-up)", ); // Marking a snapshot takes the reviewer to the next one, which is why a // mistake is always noticed from somewhere else: undo has to put the mark // back *and* bring its snapshot back on screen. const firstDiffURL = page.url(); await acceptButton.click(); await expect(page).not.toHaveURL(firstDiffURL); const secondDiffURL = page.url(); await acceptButton.click(); await expect(page).not.toHaveURL(secondDiffURL); await page.keyboard.press("ControlOrMeta+z"); await expect(page).toHaveURL(secondDiffURL); await expect(acceptButton).toHaveAttribute("aria-pressed", "false"); await page.keyboard.press("ControlOrMeta+z"); await expect(page).toHaveURL(firstDiffURL); await expect(acceptButton).toHaveAttribute("aria-pressed", "false"); // Shift is the only thing telling redo apart from undo, so a redo that // also read as an undo would walk the reviewer backwards here instead of // forwards. await page.keyboard.press("ControlOrMeta+Shift+z"); await expect(page).toHaveURL(firstDiffURL); await expect(acceptButton).toHaveAttribute("aria-pressed", "true"); await page.keyboard.press("ControlOrMeta+Shift+z"); await expect(page).toHaveURL(secondDiffURL); await expect(acceptButton).toHaveAttribute("aria-pressed", "true"); }, ); loggedTest( "seats the variant switchers and a long name on one toolbar", async ({ page, auth, team, project }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); const { build, variantKey } = await createVariantSwitchersScenario({ projectId: project.id, }); await page.goto( `/${team.account.slug}/${project.name}/builds/${build.number}`, ); await page .getByRole("button", { name: /^(Start review|Browse snapshots)/ }) .click(); // Both switchers offer every sibling, whichever one the review opened on. // Scoped to the group: the title beside it is a link to the test, and its // name starts with "chromium/" — unscoped, so would the browser button. const variants = page.getByRole("group", { name: "Snapshot variants" }); for (const name of ["Chromium", "Firefox", "390", "1280"]) { await expect(variants.getByRole("link", { name })).toBeVisible(); } // The color scheme cannot be switched — every sibling is dark — so it is // stated in the Metadata panel rather than offered in the toolbar. await expect(variants.getByText("Dark")).toHaveCount(0); await expect(page.getByText("Dark", { exact: true })).toBeVisible(); // The question this build exists to answer: the name is 70-odd characters // and shares the row with the switchers. `line-clamp-2` swallows a third // line without a trace, so overflow is the only thing that tells us it // cropped. const title = page.getByRole("heading", { name: new RegExp(variantKey.replaceAll("/", "\\/")), }); await expect(title).toBeVisible(); await expect .poll(() => title.evaluate((el) => el.scrollHeight - el.clientHeight <= 1), ) .toBe(true); await screenshot(page, "build-variant-switchers", { replacements: { [team.account.slug]: "acme", }, }); }, ); loggedTest( "marks the variants holding a change", async ({ page, auth, team, project }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); const { build } = await createVariantSwitchersScenario({ projectId: project.id, }); await page.goto( `/${team.account.slug}/${project.name}/builds/${build.number}`, ); await page .getByRole("button", { name: /^(Start review|Browse snapshots)/ }) .click(); const variants = page.getByRole("group", { name: "Snapshot variants" }); const segment = (name: string) => variants.getByRole("link", { name, exact: true }); // Every marker the switchers can draw, in one toolbar: the reviewer sees // which siblings are worth the jump before making it. The status rides the // accessible name because the tooltip carrying it is out of the // accessibility tree. await expect(segment("Chromium, Changed")).toBeVisible(); await expect(segment("Firefox, Removed")).toBeVisible(); await expect(segment("390px, Added")).toBeVisible(); await expect(segment("1280px, Changed")).toBeVisible(); await screenshot(page, "build-variant-statuses", { replacements: { [team.account.slug]: "acme", }, }); // And the other half of the rule: Firefox's 390 sibling is unchanged, so // that segment says nothing at all rather than marking itself "unchanged". await segment("Firefox, Removed").click(); await expect(segment("390px")).toBeVisible(); await expect(segment("Firefox, Removed")).toHaveAttribute( "aria-current", "page", ); await screenshot(page, "build-variant-statuses-unchanged", { replacements: { [team.account.slug]: "acme", }, }); }, ); loggedTest( "keeps the other variant when switching one", async ({ page, auth, team, project }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); const { build, variantKey } = await createVariantSwitchersScenario({ projectId: project.id, }); await page.goto( `/${team.account.slug}/${project.name}/builds/${build.number}`, ); await page .getByRole("button", { name: /^(Start review|Browse snapshots)/ }) .click(); const variants = page.getByRole("group", { name: "Snapshot variants" }); const snapshotName = (browser: string, width: number) => new RegExp( `^${browser}/${variantKey.replaceAll("/", "\\/")} vw-${width}\\.png$`, ); // The build is a 2×2: two browsers, two viewports. Walking one dimension // has to leave the other where it was — the reviewer asked for a different // browser, not a different viewport. await expect( page.getByRole("heading", { name: snapshotName("chromium", 1280) }), ).toBeVisible(); await variants.getByRole("link", { name: "Firefox" }).click(); await expect( page.getByRole("heading", { name: snapshotName("firefox", 1280) }), ).toBeVisible(); await variants.getByRole("link", { name: "390" }).click(); await expect( page.getByRole("heading", { name: snapshotName("firefox", 390) }), ).toBeVisible(); // And back the other way, so this cannot pass on the diff list's order. await variants.getByRole("link", { name: "Chromium" }).click(); await expect( page.getByRole("heading", { name: snapshotName("chromium", 390) }), ).toBeVisible(); }, ); loggedTest( "filters from a variant segment and follows it", async ({ page, auth, team, project }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); const { build, variantKey } = await createVariantSwitchersScenario({ projectId: project.id, }); await page.goto( `/${team.account.slug}/${project.name}/builds/${build.number}`, ); await page .getByRole("button", { name: /^(Start review|Browse snapshots)/ }) .click(); const variants = page.getByRole("group", { name: "Snapshot variants" }); const snapshotName = (browser: string, width: number) => new RegExp( `^${browser}/${variantKey.replaceAll("/", "\\/")} vw-${width}\\.png$`, ); await expect( page.getByRole("heading", { name: snapshotName("chromium", 1280) }), ).toBeVisible(); // Filtering on a viewport the snapshot on screen does not have would drop // that snapshot out of the list under it, so the filter takes the reviewer // to the sibling it names — the same place a left click would have gone. await variants .getByRole("link", { name: "390px, Added" }) .click({ button: "right" }); await expect( page.getByRole("option", { name: "Add to filters" }), ).toBeVisible(); await screenshot(page, "build-variant-filter-menu", { replacements: { [team.account.slug]: "acme", }, }); await page.getByRole("option", { name: "Add to filters" }).click(); await expect( page.getByRole("heading", { name: snapshotName("chromium", 390) }), ).toBeVisible(); const viewportChip = page.getByRole("button", { name: "Remove Viewport filter", }); await expect(viewportChip).toBeVisible(); // Only the 390 siblings are left in the list. await expect(page.getByText(/vw-1280\.png/)).toHaveCount(0); // And back off the same segment, which now reads as the way out. await variants .getByRole("link", { name: "390px, Added" }) .click({ button: "right" }); await page.getByRole("option", { name: "Remove from filters" }).click(); await expect(viewportChip).toHaveCount(0); // Re-opened, the row has to read the filter as it stands now: the menu is // held at what it said only while it animates out. await variants .getByRole("link", { name: "390px, Added" }) .click({ button: "right" }); await expect( page.getByRole("option", { name: "Add to filters" }), ).toBeVisible(); }, ); loggedTest( "says which of the two emptied the snapshot list", async ({ page, auth, team, project }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); const builds = await createBuildScenario({ projectId: project.id }); await page.goto( `/${team.account.slug}/${project.name}/builds/${builds.diffDetectedBuild.number}`, ); await page .getByRole("button", { name: /^(Browse test failures|Start review|Browse snapshots)/, }) .click(); // Two categories that do not overlap: the stories captured on Firefox are // never the hero ones. An intersection is the only way to empty the list // without emptying the build, which is exactly the case the build's own // "no screenshots" state gets wrong. const addFilter = async (category: string, value: RegExp) => { await page.keyboard.press("f"); await page.getByRole("option", { name: category }).click(); await page.getByRole("option", { name: value }).click(); await page.keyboard.press("Escape"); }; await addFilter("Story kind", /gallery-hero/); await addFilter("Browser", /firefox/); // Not "follow one of our quickstart guides": the build has screenshots, the // filters just leave none, and the reviewer needs to be told which. await expect( page.getByText("No screenshot matches all the filters."), ).toBeVisible(); await page.getByRole("button", { name: "Clear filters" }).click(); await expect( page.getByText("No screenshot matches all the filters."), ).toHaveCount(0); }, );