tests/media.spec.ts
Rama main · réplica de lectura del repositorio original.
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-<projectId>/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 `<video>` sizes itself from its poster, and one that does
// not load lays it out at 0×0 — a playable recording nobody can see.
const video = page.locator("video");
await expect(video).toBeVisible();
await expect(video).not.toHaveAttribute("poster");
});
loggedTest(
"compares a before/after pair of recordings side by side",
async ({ page, project }) => {
// A pair of recordings used to show the "after" alone, and the "before"
// link redirects to it — so the before half could not be watched at all.
const media = await createMediaVideoPairScenario({ projectId: project.id });
// Arriving with onion skin as the stored preference, as anyone who blends
// a build's snapshots does: two players cannot be blended, so the pair
// opens side by side, and the toggle has to say so.
await page.addInitScript(() => {
localStorage.setItem("preferences.diffViewMode", JSON.stringify("onion"));
});
await page.goto(`/m/${media.after.shareToken}`);
const panes = page.locator("[data-media-pane]");
await expect(panes).toHaveCount(2);
// The "before" on the left, whichever half the page is for.
await expect(
panes.nth(0).getByLabel("signup-flow.mp4 (before)"),
).toBeVisible();
await expect(
panes.nth(1).getByLabel("signup-flow.mp4 (after)"),
).toBeVisible();
const before = page.getByRole("button", { name: "Before", exact: true });
const after = page.getByRole("button", { name: "After", exact: true });
const sideBySide = page.getByRole("button", { name: "Side by side" });
await expect(sideBySide).toHaveAttribute("aria-pressed", "true");
await expect(page.getByRole("button", { name: "Onion skin" })).toHaveCount(
0,
);
// `S` leaves side by side for the "after" alone — relative to the view on
// screen, not to the stored blend.
await page.keyboard.press("s");
await expect(after).toHaveAttribute("aria-pressed", "true");
await expect(panes).toHaveCount(1);
await expect(page.getByLabel("signup-flow.mp4 (after)")).toBeVisible();
await before.click();
await expect(panes).toHaveCount(1);
await expect(page.getByLabel("signup-flow.mp4 (before)")).toBeVisible();
await sideBySide.click();
await expect(panes).toHaveCount(2);
},
);
loggedTest("media share page for an unknown token", async ({ page }) => {
// Expired, deleted, and never-valid all render the same state: telling them
// apart would leak whether a token ever pointed at something.
await page.goto("/m/does-not-exist");
await expect(
page.getByRole("heading", { name: "This media is no longer available" }),
).toBeVisible();
await screenshot(page, "media-share-unavailable");
});