import { expect } from "@playwright/test"; import { createMediaScenario, createMediaVideoPairScenario, } from "../apps/backend/src/database/seeds"; import { loggedTest } from "./logged-test"; import { ensureTeamOwner, phoneScreenshot, screenshot } from "./util"; /** * The team slug is seeded from the test id and, on a retry, suffixed with the * attempt number so the retried run does not collide with the first one's rows * (see getUniqueTestIdentifier). Every share page prints it in the header, so a * screenshot taken on a retry would otherwise report the whole page as changed. */ function maskTeamSlug(team: { account: { slug: string } }) { return { replacements: { [team.account.slug]: "acme" } }; } loggedTest.beforeEach(async ({ auth, team }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); }); loggedTest("media share page", async ({ team, page, auth, project }) => { const media = await createMediaScenario({ projectId: project.id, commentAuthorId: auth.user.id, withPullRequest: true, }); await page.goto(`/m/${media.after.shareToken}`); // The file name is the page's title, in the header. It also appears inside // the Share panel's Markdown embed, hence the heading role. await expect( page.getByRole("heading", { name: "checkout.png" }), ).toBeVisible(); await expect(page.getByText("375×1024")).toBeVisible(); // Two uploads, so the versions get their own panel — one row per upload // (the activity timeline also says "v2 uploaded"). await expect(page.getByRole("heading", { name: "Versions" })).toBeVisible(); await expect(page.getByRole("button", { name: /^v2/ })).toBeVisible(); // A pair, so both halves are on the page and the alt text distinguishes them. await expect( page.getByRole("img", { name: "checkout.png (before)" }), ).toBeVisible(); await expect( page.getByRole("img", { name: "checkout.png (after)" }), ).toBeVisible(); // The pane label (uppercased by CSS, so the DOM text is the raw state). await expect(page.getByText("before", { exact: true })).toBeVisible(); await expect(page.getByRole("link", { name: "Argos" })).toBeVisible(); // The pull request this media was published to, in the header — the same // widget the build header uses. Only ever rendered for a viewer with access // to the project, which this logged-in owner has. await expect( page.getByRole("link", { name: /Tighten the checkout spacing/ }), ).toBeVisible(); // Everything else uploaded to the same pull request, in a sidebar: three // rows, because the pair counts once. const sidebar = page.getByRole("region", { name: "Pull request media" }); await expect(sidebar.locator("[aria-current]")).toHaveCount(1); await expect(sidebar.getByText("checkout.png")).toBeVisible(); await expect(sidebar.getByText("dashboard.png")).toBeVisible(); await expect(sidebar.getByText("checkout-flow.mp4")).toBeVisible(); // Two threads, one of them pinned to a point on the image — the pin is a // floating marker on the image itself, and the panel tells the media's // whole story. await expect(page.getByRole("heading", { name: "Activity" })).toBeVisible(); await expect( page.getByText("The primary button is misaligned here."), ).toBeVisible(); await expect( page.getByText("Agreed — it should align with the input above."), ).toBeVisible(); await expect( page.getByRole("button", { name: /^Open comment from/ }), ).toBeVisible(); // A commit sha written in a comment links to that commit on the repository the // project is connected to — the seed's is `acme-/sparkle`. await expect(page.getByRole("link", { name: "d15cba5" })).toHaveAttribute( "href", `https://github.com/acme-${project.id}/sparkle/commit/d15cba5`, ); await screenshot(page, "media-share-page", maskTeamSlug(team)); await phoneScreenshot(page, "media-share-page", { ready: (phone) => expect( phone.getByRole("img", { name: "checkout.png (after)" }), ).toBeVisible(), ...maskTeamSlug(team), }); }); loggedTest( "navigates the pull request's media with the sidebar and the keyboard", async ({ team, page, project }) => { // Uploaded oldest first: dashboard.png (07:00), the checkout.png pair // (08:00), then checkout-flow.mp4 (09:30). const media = await createMediaScenario({ projectId: project.id, withPullRequest: true, }); await page.goto(`/m/${media.after.shareToken}`); const sidebar = page.getByRole("region", { name: "Pull request media" }); const previous = page .getByRole("main") .locator("button:has(.lucide-arrow-up)"); const next = page .getByRole("main") .locator("button:has(.lucide-arrow-down)"); await expect(previous).toBeVisible(); await expect(next).toBeVisible(); // Up from the pair reaches the lone screenshot, which is the first upload — // so there is nothing before it. await page.keyboard.press("ArrowUp"); await expect(page).toHaveURL(`/m/${media.solo.shareToken}`); await expect( page.getByRole("heading", { name: "dashboard.png" }), ).toBeVisible(); await expect(previous).toBeDisabled(); // Down twice: back through the pair and on to the recording. A video used // to render no toolbar at all, which took the arrows and the name with it. // // Waiting for the name between the two presses, not just the URL: the next // media is still being fetched when the URL changes, and the arrows are // deliberately shut while it is. await page.keyboard.press("ArrowDown"); await expect(page).toHaveURL(`/m/${media.after.shareToken}`); await expect( page.getByRole("heading", { name: "checkout.png" }), ).toBeVisible(); await expect(next).toBeEnabled(); await page.keyboard.press("ArrowDown"); await expect(page).toHaveURL(`/m/${media.video.shareToken}`); await expect( page.getByRole("heading", { name: "checkout-flow.mp4" }), ).toBeVisible(); await expect(page.locator("video")).toBeVisible(); await expect(next).toBeDisabled(); // Clicking the pair opens the "after": both halves are one row, and the // after is the state of the work being reviewed. await sidebar.getByText("checkout.png").click(); await expect(page).toHaveURL(`/m/${media.after.shareToken}`); await screenshot(page, "media-share-sidebar", maskTeamSlug(team)); }, ); loggedTest( "sends the before half to the after, so a pair has one page", async ({ page, auth, project }) => { // Both halves show the same two images, so two pages meant one subject with // two comment threads on it, split by which link was clicked. const media = await createMediaScenario({ projectId: project.id, commentAuthorId: auth.user.id, withPullRequest: true, }); await page.goto(`/m/${media.before.shareToken}`); await expect(page).toHaveURL(`/m/${media.after.shareToken}`); // Arriving from the "before" link and reading the conversation that was // left on the "after" — the point of the redirect. await expect( page.getByText("The primary button is misaligned here."), ).toBeVisible(); await expect( page.getByRole("img", { name: "checkout.png (before)" }), ).toBeVisible(); const sidebar = page.getByRole("region", { name: "Pull request media" }); const active = sidebar.locator("[aria-current]"); await expect(active).toHaveCount(1); await expect(active).toContainText("checkout.png"); // And navigating on from it moves relative to the row, not to the half. await page.keyboard.press("ArrowUp"); await expect(page).toHaveURL(`/m/${media.solo.shareToken}`); }, ); loggedTest( "pins a comment to a point on the image", async ({ page, auth, project }) => { const media = await createMediaScenario({ projectId: project.id, commentAuthorId: auth.user.id, }); await page.goto(`/m/${media.after.shareToken}`); await page.getByRole("button", { name: "Comment tool" }).click(); await expect( page.getByText("Click the spot on the image you want to comment on."), ).toBeVisible(); // The pin lands wherever the reviewer points, stored as a fraction of the // media's own box, and the composer floats right beside it — the same // flow as the build's screenshot comments. await page .getByRole("button", { name: "Pick the spot to comment on" }) .click({ position: { x: 200, y: 150 } }); const draftDialog = page.getByRole("dialog", { name: "Add a comment" }); const editor = draftDialog.getByLabel("Add a comment"); await editor.click(); await editor.fill("This spacing is off by a few pixels."); await draftDialog .getByRole("button", { name: "Submit the comment" }) .click(); // The created thread opens beside its marker, and the sidebar lists it // too — the comment is on both surfaces. await expect( page.getByText("This spacing is off by a few pixels."), ).toHaveCount(2); await expect( page.getByRole("button", { name: /^Open comment from/ }), ).toHaveCount(2); await expect(page.getByText("pinned on the image")).toHaveCount(2); }, ); loggedTest( "brings a resolved thread's pin back with the thread", async ({ team, page, auth, project }) => { // "The primary button is misaligned here" only says something next to its // pin, so a resolved thread the reviewer expands gets its pin back — wearing // a check, since the expansion outlives the session that made it. const media = await createMediaScenario({ projectId: project.id, commentAuthorId: auth.user.id, }); await page.goto(`/m/${media.after.shareToken}`); const openPin = page.getByRole("button", { name: /^Open comment from/ }); const resolvedPin = page.getByRole("button", { name: /^Open resolved comment from/, }); await openPin.click(); // Resolve from the pin's own popover: the thread is done, so the marker // leaves the image with it. const thread = page.getByRole("dialog", { name: /^Comment from/ }); await thread.getByText("The primary button is misaligned here.").hover(); await thread .getByRole("button", { name: "Comment actions" }) .first() .click(); await page.getByRole("option", { name: "Resolve thread" }).click(); await expect(openPin).toBeHidden(); await expect(resolvedPin).toBeHidden(); // Expanding the thread in the panel puts the pin back on the image. await page.getByRole("button", { name: "Expand thread" }).click(); await expect(resolvedPin).toBeVisible(); // Resolving popped a toast over the panel, and it slides in and out on its // own timeline — left alone it lands in the screenshot mid-animation, at a // different offset every run. Dismissing it is the only way to be sure which // frame is captured. await page.getByRole("button", { name: "Close toast" }).click(); await expect(page.getByText("Thread resolved.")).toBeHidden(); await screenshot(page, "media-resolved-pin-expanded", maskTeamSlug(team)); // And collapsing it takes the pin away again. await page.getByRole("button", { name: "Collapse thread" }).click(); await expect(resolvedPin).toBeHidden(); }, ); loggedTest( "pins a comment on a media that stands alone", async ({ page, auth, project }) => { // A lone media has no counterpart, so no compare toolbar and a single pane — // the pin tool has to work there exactly as it does beside a pair. This one // also has no recorded dimensions, which used to take the comment layer off // the page entirely: the tool armed, showed its crosshair, and dropped every // click. const media = await createMediaScenario({ projectId: project.id, commentAuthorId: auth.user.id, }); await page.goto(`/m/${media.solo.shareToken}`); await page.getByRole("button", { name: "Comment tool" }).click(); await page .getByRole("button", { name: "Pick the spot to comment on" }) .click({ position: { x: 200, y: 150 } }); const draftDialog = page.getByRole("dialog", { name: "Add a comment" }); const editor = draftDialog.getByLabel("Add a comment"); await editor.click(); await editor.fill("The sidebar is cropped here."); await draftDialog .getByRole("button", { name: "Submit the comment" }) .click(); await expect(page.getByText("The sidebar is cropped here.")).toHaveCount(2); await expect(page.getByText("pinned on the image")).toBeVisible(); }, ); loggedTest( "marks the changed pixels of a before/after pair", async ({ team, page, project }) => { // The pair has been compared, so the share page offers the build's own // overlay controls and draws the mask over the "after". const media = await createMediaScenario({ projectId: project.id }); await page.goto(`/m/${media.after.shareToken}`); // IconButtons take their name from a tooltip, which is not an accessible // name — the lucide class is the handle. const viewer = page.getByRole("main"); const toggle = viewer.locator("button:has(.lucide-eye)"); await expect(toggle).toHaveAttribute("aria-pressed", "true"); await expect( viewer.locator("button:has(.lucide-locate-fixed)"), ).toBeVisible(); await expect( viewer.locator("button:has(.lucide-paintbrush)"), ).toBeVisible(); // The mask is a CSS mask on a span rather than an `img`, so it is located // through the style that draws it. const mask = page.locator( '[data-media-pane] span[style*="diff-1024-to-720.png"]', ); await expect(mask).toHaveCount(1); await screenshot(page, "media-share-changes-overlay", maskTeamSlug(team)); // Hiding it leaves the pair on screen, untouched — which is the point of // having a toggle rather than a mode. await toggle.click(); await expect(toggle).toHaveAttribute("aria-pressed", "false"); await expect(mask).toHaveCSS("opacity", "0"); await expect( page.getByRole("img", { name: "checkout.png (after)" }), ).toBeVisible(); }, ); loggedTest( "shows each version its own comparison", async ({ page, project }) => { // Two uploads of the "after", each compared against the "before" when it // landed. Picking an older one used to hide the overlay entirely, because // the only comparison the page could reach was the newest pair's. const media = await createMediaScenario({ projectId: project.id }); await page.goto(`/m/${media.after.shareToken}`); const mask = page.locator( '[data-media-pane] span[style*="diff-1024-to-720.png"]', ); await expect(mask).toHaveCount(1); // v1 is the same file as the "before", so its comparison found nothing to // mark — a real answer, and a different one from v2's. await page.getByRole("button", { name: /^v1/ }).click(); await expect(mask).toHaveCount(0); await expect( page.getByRole("img", { name: "checkout.png (before)" }), ).toBeVisible(); // And back: the newest version's mask returns. await page.getByRole("button", { name: /^v2/ }).click(); await expect(mask).toHaveCount(1); }, ); loggedTest( "switches between the two halves with the build's own controls", async ({ team, page, project }) => { // A pair and a build's baseline-against-changes are the same question, so // they are looked at with the same controls and the same shortcuts — only // the two words differ. const media = await createMediaScenario({ projectId: project.id }); await page.goto(`/m/${media.after.shareToken}`); const panes = page.locator("[data-media-pane]"); await expect(panes).toHaveCount(2); // `S` leaves side by side for one image at a time. await page.keyboard.press("s"); await expect(panes).toHaveCount(1); const after = page.getByRole("button", { name: "After", exact: true }); const before = page.getByRole("button", { name: "Before", exact: true }); await expect(after).toHaveAttribute("aria-pressed", "true"); await expect( page.getByRole("img", { name: "checkout.png (after)" }), ).toBeVisible(); // `←` shows the other half alone, `→` comes back — the build's keys. await page.keyboard.press("ArrowLeft"); await expect(before).toHaveAttribute("aria-pressed", "true"); await expect( page.getByRole("img", { name: "checkout.png (before)" }), ).toBeVisible(); await expect(panes).toHaveCount(1); await page.keyboard.press("ArrowRight"); await expect(after).toHaveAttribute("aria-pressed", "true"); // And the buttons do what the keys do. await before.click(); await expect( page.getByRole("img", { name: "checkout.png (before)" }), ).toBeVisible(); await screenshot(page, "media-share-single-half", maskTeamSlug(team)); // Comments belong to the "after", so with only the "before" on screen there // is nowhere for a pin to land — the tool puts itself away rather than // leaving the reviewer clicking at an image that cannot take one. await after.click(); await page.getByRole("button", { name: "Comment tool" }).click(); await expect( page.getByText("Click the spot on the image you want to comment on."), ).toBeVisible(); await page.keyboard.press("ArrowLeft"); await expect( page.getByText("Click the spot on the image you want to comment on."), ).toBeHidden(); }, ); loggedTest( "rings the half a pin would land on, in compare mode", async ({ team, page, auth, project }) => { // Side by side puts two images on screen and only one of them takes // comments. Arming the tool without saying which is which leaves the // reviewer to click and find out. const media = await createMediaScenario({ projectId: project.id, commentAuthorId: auth.user.id, }); await page.goto(`/m/${media.after.shareToken}`); // Side by side is the default, the same one a build opens with. const panes = page.locator("[data-media-pane]"); await expect(panes).toHaveCount(2); // Nothing is singled out until the tool is armed: a ring that is always on // reads as "selected" and says nothing about where a click goes. await expect( page.locator("[data-media-pane][data-pin-target]"), ).toHaveCount(0); await page.getByRole("button", { name: "Comment tool" }).click(); const target = page.locator("[data-media-pane][data-pin-target]"); await expect(target).toHaveCount(1); // The commentable half is this media's own — the "after" — not the // counterpart drawn beside it. await expect( target.getByRole("img", { name: "checkout.png (after)" }), ).toBeVisible(); await screenshot(page, "media-share-pin-target", maskTeamSlug(team)); }, ); loggedTest( "puts the comment tool away when a recording comes on screen", async ({ page, auth, project }) => { // A recording has no pane a pin can land on, so it offers no comment tool — // which means an armed tool arriving from the image beside it would have // nothing left on the page able to put it away. const media = await createMediaScenario({ projectId: project.id, commentAuthorId: auth.user.id, withPullRequest: true, }); await page.goto(`/m/${media.after.shareToken}`); // Armed with the key rather than the button: the shortcut is registered by // the toolbar control now, so this is what says it is still reachable. // Waiting for the control first — the key goes nowhere until the thing that // listens for it is on the page. const tool = page.getByRole("button", { name: "Comment tool" }); await expect(tool).toBeVisible(); const instruction = page.getByText( "Click the spot on the image you want to comment on.", ); await page.keyboard.press("KeyC"); await expect(instruction).toBeVisible(); // Down to the recording, in the app rather than by URL: the page stays // mounted across the two, which is what carries the armed tool over. await page.keyboard.press("ArrowDown"); await expect(page).toHaveURL(`/m/${media.video.shareToken}`); await expect( page.getByRole("heading", { name: "checkout-flow.mp4" }), ).toBeVisible(); await expect(instruction).toBeHidden(); await expect(tool).toBeHidden(); }, ); loggedTest("media share page for a video", async ({ page, project }) => { const media = await createMediaScenario({ projectId: project.id, }); await page.goto(`/m/${media.video.shareToken}`); await expect( page.getByRole("heading", { name: "checkout-flow.mp4" }), ).toBeVisible(); // Rendered in a player. The seeded poster fails to load, and the player must // not take it: a `