Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions e2e/fixtures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ import {
type AttachmentItem,
type AttachmentRejection,
useAttachments,
GeneratedImages,
generatedImageViewerItems,
MediaViewer,
useMediaViewer,
type ConversationImage,
type PartImage,
} from "@corbits/react-ui";
import {
CommandPalette,
Expand Down Expand Up @@ -1457,6 +1463,79 @@ function AttachmentsScenario() {
);
}

/** A flat swatch as an inline SVG, so nothing is fetched. */
function swatch(index: number): ConversationImage {
const shade = 40 + index * 20;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200"><rect width="300" height="200" fill="hsl(${shade} 70% 60%)"/></svg>`;
return {
id: `swatch-${index}`,
mediaType: "image/svg+xml",
width: 300,
height: 200,
alt: `Swatch ${index}`,
url: `data:image/svg+xml,${encodeURIComponent(svg)}`,
};
}

const SWATCHES: PartImage = {
kind: "image",
id: "swatches",
prompt: "Three swatches",
state: "done",
images: [swatch(1), swatch(2), swatch(3)],
};

/** Starts generating, so landing the images is watched and reveals them. */
function MediaViewerScenario() {
const [landed, setLanded] = useState(false);
const part: PartImage = landed
? SWATCHES
: {
...SWATCHES,
state: "generating",
images: [],
expected: { count: 3, width: 300, height: 200 },
};
const viewer = useMediaViewer({ items: generatedImageViewerItems(part) });
return (
<div style={{ width: 600 }}>
<Button onClick={() => setLanded(true)}>Land images</Button>
<GeneratedImages part={part} onOpen={viewer.open} />
<MediaViewer viewer={viewer} />
</div>
);
}

const REMOTE: PartImage = {
kind: "image",
id: "remote",
prompt: "Remote swatches",
state: "done",
images: [1, 2].map((index) => ({
id: `remote-${index}`,
mediaType: "image/svg+xml",
width: 300,
height: 200,
alt: `Remote ${index}`,
url: `https://images.example.test/remote-${index}.svg`,
})),
};

/** Images from another origin inside a scrolling log, on a page that scrolls. */
function MediaViewerChatScenario() {
const viewer = useMediaViewer({ items: generatedImageViewerItems(REMOTE) });
return (
<div style={{ width: 600, paddingBottom: 3000 }}>
<div data-testid="log" style={{ height: 320, overflow: "auto" }}>
<div style={{ height: 600 }} />
<GeneratedImages part={REMOTE} onOpen={viewer.open} />
<div style={{ height: 600 }} />
</div>
<MediaViewer viewer={viewer} />
</div>
);
}

export const scenarios: Record<string, () => ReactNode> = {
smoke: () => <Button>Ready</Button>,
"approval-card": () => <Approval state="idle" />,
Expand Down Expand Up @@ -1527,4 +1606,6 @@ export const scenarios: Record<string, () => ReactNode> = {
</>
),
attachments: () => <AttachmentsScenario />,
"media-viewer": () => <MediaViewerScenario />,
"media-viewer-chat": () => <MediaViewerChatScenario />,
};
179 changes: 179 additions & 0 deletions e2e/media-viewer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { expect, test, type Page } from "@playwright/test";

const grain = (page: Page) =>
page.locator("[data-slot='generated-image-grain']");

async function land(page: Page) {
await page.goto("/media-viewer");
await expect(
page.locator("[data-slot='generated-image-placeholder']"),
).toHaveCount(3);
await expect(page.getByRole("status")).toHaveText("Generating 3 images");
await page.getByRole("button", { name: "Land images" }).click();
}

test("images made while watched are revealed by the grain", async ({
page,
}) => {
await land(page);
await expect(grain(page)).toHaveCount(3);
await expect(page.getByRole("status")).toHaveText("Made 3 images");
// The reveal is one 700ms pass per tile, cascading 90ms apart.
await expect(grain(page)).toHaveCount(0, { timeout: 3_000 });
const tiles = page.locator("[data-slot='generated-image']");
await expect(tiles).toHaveCount(3);
for (const tile of await tiles.all()) {
await expect(tile).toHaveAttribute("data-state", "loaded");
}
});

test("with reduced motion, landed images skip the grain", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await land(page);
await expect(
page.locator("[data-slot='generated-image'][data-state='loaded']"),
).toHaveCount(3);
await expect(grain(page)).toHaveCount(0);
});

test("arrow keys move between items, Escape closes, focus returns to the tile", async ({
page,
}) => {
await land(page);
await expect(grain(page)).toHaveCount(0, { timeout: 3_000 });
const tile = page.getByRole("button", {
name: "Open Swatch 2",
exact: true,
});
await tile.click();

const dialog = page.getByRole("dialog");
await expect(dialog).toHaveAccessibleName("three-swatches-2.svg");
await expect(page.getByRole("button", { name: "Close" })).toBeFocused();
await expect(dialog.getByRole("img")).toHaveAttribute("alt", "Swatch 2");

await page.keyboard.press("ArrowRight");
await expect(dialog).toHaveAccessibleName("three-swatches-3.svg");
await expect(dialog.getByRole("status")).toHaveText(
"three-swatches-3.svg, 3 of 3",
);
// Stepping stops at the ends rather than wrapping.
await page.keyboard.press("ArrowRight");
await expect(dialog).toHaveAccessibleName("three-swatches-3.svg");
await expect(dialog.getByRole("button", { name: "Next" })).toHaveAttribute(
"aria-disabled",
"true",
);

await page.keyboard.press("ArrowLeft");
await page.keyboard.press("ArrowLeft");
await expect(dialog).toHaveAccessibleName("three-swatches-1.svg");
await page.keyboard.press("ArrowLeft");
await expect(dialog).toHaveAccessibleName("three-swatches-1.svg");

await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(tile).toBeFocused();
});

test("the viewer opens from the keyboard and closes from its button or the backdrop", async ({
page,
}) => {
await land(page);
await expect(grain(page)).toHaveCount(0, { timeout: 3_000 });
const tile = page.getByRole("button", {
name: "Open Swatch 3",
exact: true,
});
await tile.focus();
await page.keyboard.press("Enter");
const dialog = page.getByRole("dialog");
await expect(dialog).toHaveAccessibleName("three-swatches-3.svg");
await dialog.getByRole("button", { name: "Previous" }).click();
await expect(dialog).toHaveAccessibleName("three-swatches-2.svg");
await dialog.getByRole("button", { name: "Close" }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(tile).toBeFocused();

// The action button opens it too, and a click outside the media closes it.
const action = page.getByRole("button", {
name: "Open Swatch 1 full size",
});
await action.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("dialog")).toHaveAccessibleName(
"three-swatches-1.svg",
);
await page.mouse.click(4, 4);
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(action).toBeFocused();
});

const REMOTE_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200"><rect width="300" height="200" fill="gray"/></svg>';

async function openRemote(page: Page) {
await page
.context()
.route("https://images.example.test/**", (route) =>
route.fulfill({ contentType: "image/svg+xml", body: REMOTE_SVG }),
);
await page.goto("/media-viewer-chat");
await page
.getByRole("button", { name: "Open Remote 2", exact: true })
.click();
const dialog = page.getByRole("dialog");
await expect(dialog).toHaveAccessibleName("remote-swatches-2.svg");
return dialog;
}

const scrolls = (page: Page) =>
page.evaluate(() => ({
page: document.scrollingElement?.scrollTop ?? 0,
log: document.querySelector("[data-testid='log']")?.scrollTop ?? 0,
}));

test("with the viewer open, the wheel scrolls neither the page nor the log behind it", async ({
page,
}) => {
await openRemote(page);
const before = await scrolls(page);
expect(before.log).toBeGreaterThan(0);
// Over the backdrop, the picture and the bar, in both directions.
for (const [x, y] of [
[640, 360],
[20, 20],
[640, 640],
] as const) {
await page.mouse.move(x, y);
await page.mouse.wheel(0, 800);
await page.mouse.wheel(0, -400);
}
await page.waitForTimeout(300);
expect(await scrolls(page)).toEqual(before);

// Closing hands scrolling back to the page.
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
expect(
await page.evaluate(() => document.documentElement.style.overflow),
).toBe("");
await page.mouse.move(640, 700);
await page.mouse.wheel(0, 400);
await expect
.poll(async () => (await scrolls(page)).page)
.toBeGreaterThan(before.page);
});

test("Download of an image from another origin leaves the chat in place", async ({
page,
}) => {
const dialog = await openRemote(page);
const popup = page.waitForEvent("popup");
await dialog
.getByRole("link", { name: "Download remote-swatches-2.svg" })
.click();
await popup;
expect(new URL(page.url()).pathname).toBe("/media-viewer-chat");
await expect(dialog).toBeVisible();
});
90 changes: 90 additions & 0 deletions src/hooks/use-media-viewer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { act } from "react";
import { describe, expect, test } from "bun:test";

import { renderHook } from "../test/render-hook.js";
import {
useMediaViewer,
type UseMediaViewerOptions,
} from "./use-media-viewer.js";

const ITEMS = ["a", "b", "c"] as const;

function viewer(options: Partial<UseMediaViewerOptions<string>> = {}) {
return renderHook(() => useMediaViewer<string>({ items: ITEMS, ...options }));
}

describe("useMediaViewer", () => {
test("starts closed and opens an item", () => {
const hook = viewer();
expect(hook.result.current.index).toBe(null);
expect(hook.result.current.item).toBe(null);
act(() => hook.result.current.open(1));
expect(hook.result.current.index).toBe(1);
expect(hook.result.current.item).toBe("b");
hook.unmount();
});

test("next and previous stop at the ends", () => {
const hook = viewer({ defaultIndex: 1 });
act(() => hook.result.current.next());
expect(hook.result.current.index).toBe(2);
expect(hook.result.current.hasNext).toBe(false);
act(() => hook.result.current.next());
expect(hook.result.current.index).toBe(2);

act(() => hook.result.current.previous());
act(() => hook.result.current.previous());
expect(hook.result.current.index).toBe(0);
expect(hook.result.current.hasPrevious).toBe(false);
act(() => hook.result.current.previous());
expect(hook.result.current.index).toBe(0);
expect(hook.result.current.hasNext).toBe(true);
hook.unmount();
});

test("closing clears the open item", () => {
const hook = viewer({ defaultIndex: 2 });
act(() => hook.result.current.close());
expect(hook.result.current.index).toBe(null);
expect(hook.result.current.item).toBe(null);
expect(hook.result.current.hasNext).toBe(false);
expect(hook.result.current.hasPrevious).toBe(false);
// Stepping does nothing while closed.
act(() => hook.result.current.next());
expect(hook.result.current.index).toBe(null);
hook.unmount();
});

test("an index outside the items reads as closed, and open ignores one", () => {
const hook = viewer({ defaultIndex: 5 });
expect(hook.result.current.index).toBe(null);
act(() => hook.result.current.open(3));
expect(hook.result.current.index).toBe(null);
act(() => hook.result.current.open(-1));
expect(hook.result.current.index).toBe(null);
hook.unmount();
});

test("controlled, it reports changes and follows the value it is given", () => {
const changes: (number | null)[] = [];
const hook = viewer({
index: 0,
onIndexChange: (index) => changes.push(index),
});
act(() => hook.result.current.next());
act(() => hook.result.current.close());
expect(changes).toEqual([1, null]);
expect(hook.result.current.index).toBe(0);
hook.unmount();
});

test("keeps the element that opened it", () => {
const hook = viewer();
const button = document.createElement("button");
act(() => hook.result.current.open(0, button));
expect(hook.result.current.opener.current).toBe(button);
act(() => hook.result.current.open(1));
expect(hook.result.current.opener.current).toBe(null);
hook.unmount();
});
});
Loading
Loading