Ctrl K
ComunidadGuías…

tests/media.spec.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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");
});