From 26872b5dc4cb87d30d8901da11d35fd55ea5073d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:29:21 +0000 Subject: [PATCH] test(media): library e2e covering sessions, people consent, results import and 50k virtualization --- apps/web/e2e/media-library.spec.ts | 452 ++++++++++++++++++ .../media/library/CreateProjectDialog.tsx | 5 + .../media/library/CsvImportDialog.tsx | 2 +- .../components/media/library/PersonCard.tsx | 15 +- apps/web/src/lib/media-library.ts | 7 +- apps/web/src/pages/media/LibraryView.tsx | 2 +- 6 files changed, 474 insertions(+), 9 deletions(-) create mode 100644 apps/web/e2e/media-library.spec.ts diff --git a/apps/web/e2e/media-library.spec.ts b/apps/web/e2e/media-library.spec.ts new file mode 100644 index 0000000..cfba3a9 --- /dev/null +++ b/apps/web/e2e/media-library.spec.ts @@ -0,0 +1,452 @@ +import { expect, type Page, type Route, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; + +/* + * Media Studio library, collection, people and results views on the scripted + * runtime (media emulators enabled). The library is seeded through the device + * source RPCs with synthetic metadata; people and results need analysis and + * publications the scripted stack does not produce, so those RPCs are stubbed + * with synthetic records. Every name and value below is fake. + */ + +type Json = Record; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function syntheticItems(count: number) { + const start = Date.UTC(2026, 4, 2, 9); + const cities = [ + { lat: 52.52, lon: 13.4 }, + { lat: 48.14, lon: 11.58 }, + { lat: 53.55, lon: 9.99 }, + ]; + return Array.from({ length: count }, (_, index) => { + const session = index % 3; + const city = cities[session] ?? cities[0]; + const photo = index % 5 === 4; + return { + externalId: `e2e-clip-${index}`, + kind: photo ? "photo" : "video", + capturedAt: new Date(start + session * 3 * DAY_MS + index * 60_000).toISOString(), + durationMs: photo ? null : 4000 + (index % 4) * 1000, + width: 1920, + height: 1080, + fps: photo ? null : 30, + gpsLat: city?.lat ?? null, + gpsLon: city?.lon ?? null, + deviceModel: null, + albumNames: [], + isFavorite: index % 7 === 0, + fileName: `clip-${index}.${photo ? "jpg" : "mov"}`, + }; + }); +} + +async function fulfill(route: Route, json: unknown) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ json }), + }); +} + +async function stub(page: Page, procedure: string, respond: (input: Json) => unknown) { + await page.route(`**/rpc/${procedure}`, async (route) => { + const body = (route.request().postDataJSON() ?? {}) as { json?: Json }; + await fulfill(route, respond(body.json ?? {})); + }); +} + +const now = "2026-10-01T12:00:00.000Z"; + +function person(id: string, name: string, overrides: Json = {}) { + return { + id, + displayName: name, + isSelf: false, + confirmed: false, + anonymizeByDefault: "none", + consentRecordedAt: null, + consentArtifactId: null, + ageVerifiedAt: null, + ageVerificationMethod: "none", + faceTrackCount: 3, + coverThumbAssetId: null, + ...overrides, + }; +} + +function syntheticAsset(index: number) { + return { + id: `scale-${index}`, + sourceId: "scale-source", + externalId: `scale-${index}`, + kind: "video", + storage: "remote", + capturedAt: now, + durationMs: 4000, + width: 1920, + height: 1080, + fps: 30, + gpsLat: null, + gpsLon: null, + deviceModel: null, + albumNames: [], + isFavorite: false, + ingestState: "indexed", + hasProxy: false, + hasThumbnail: false, + qualityScore: null, + createdAt: now, + }; +} + +async function openMediaStudio(page: Page) { + await expect(page.getByTestId("media-studio-open")).toBeVisible({ timeout: 30_000 }); + await page.getByTestId("media-studio-open").click(); + const studio = page.getByTestId("media-studio"); + await expect(studio).toBeVisible(); + return studio; +} + +test("media library groups sessions, selects clips and creates a project", async ({ + page, +}, testInfo) => { + test.setTimeout(240_000); + const stamp = Date.now(); + await signup(page, `media-library-${stamp}@rakazo.test`, "password12", "Media Library"); + await completeOnboarding(page); + + const capabilities = await rpc<{ configured: boolean }>(page, "media/capabilities", {}); + expect(capabilities.configured).toBe(true); + + const source = await rpc<{ id: string }>(page, "media/sources/registerClient", { + kind: "ios_library", + label: "Test phone", + deviceId: `e2e-device-${stamp}`, + }); + const pushed = await rpc<{ created: number }>(page, "media/sources/pushItems", { + sourceId: source.id, + items: syntheticItems(30), + deletedExternalIds: [], + syncToken: null, + complete: true, + }); + expect(pushed.created).toBe(30); + await rpc(page, "media/collections/recluster", {}); + await expect + .poll( + async () => + ( + await rpc<{ items: unknown[] }>(page, "media/collections/list", { + kind: "session", + cursor: null, + limit: 10, + }) + ).items.length, + { timeout: 90_000, intervals: [1000] }, + ) + .toBeGreaterThan(0); + + const studio = await openMediaStudio(page); + const sessions = studio.getByTestId("media-collection-card"); + await expect(sessions.first()).toBeVisible(); + await expect(studio.getByTestId("media-indexing")).toHaveCount(0); + await captureScreenshot(page, testInfo, "media-library-sessions"); + + await studio.getByRole("tab", { name: "Themes" }).click(); + await expect(studio.getByRole("tab", { name: "Themes" })).toHaveAttribute( + "aria-selected", + "true", + ); + await captureScreenshot(page, testInfo, "media-library-themes"); + await studio.getByRole("tab", { name: "Sessions" }).click(); + + await studio.getByRole("button", { name: "Videos", exact: true }).click(); + const grid = studio.getByTestId("media-asset-grid"); + await expect(grid).toBeVisible(); + await expect(grid.getByTestId("media-asset-tile").first()).toBeVisible(); + await expect(grid).toHaveAttribute("data-count", "24"); + await captureScreenshot(page, testInfo, "media-library-filter-videos"); + await studio.getByRole("button", { name: "Videos", exact: true }).click(); + + await studio.getByTestId("media-collection-card").first().click(); + await expect(studio).toHaveAttribute("data-view", "collection"); + const tiles = studio.getByTestId("media-asset-tile"); + await expect(tiles.first()).toBeVisible(); + await tiles.nth(0).click(); + await tiles.nth(1).click(); + await expect(tiles.nth(0)).toHaveAttribute("aria-pressed", "true"); + await expect(studio.getByRole("button", { name: "2 selected" })).toBeVisible(); + await captureScreenshot(page, testInfo, "media-collection-selection"); + + await studio.getByTestId("media-create-project-open").click(); + const create = page.getByTestId("media-create-project"); + await expect(create).toBeVisible(); + await expect(create.getByRole("checkbox").first()).toBeVisible(); + await captureScreenshot(page, testInfo, "media-create-project"); + await create.getByRole("button", { name: "Create project" }).click(); + await expect(studio).toHaveAttribute("data-view", "project"); + + await studio.getByRole("button", { name: "Back" }).click(); + await expect(studio).toHaveAttribute("data-view", "collection"); +}); + +test("people view confirms, merges and records consent", async ({ page }, testInfo) => { + const stamp = Date.now(); + await signup(page, `media-people-${stamp}@rakazo.test`, "password12", "Media People"); + await completeOnboarding(page); + + let people = [ + person("person-a", "Person 1"), + person("person-b", "Person 2", { confirmed: true }), + person("person-c", "Person 3"), + ]; + const consentCalls: Json[] = []; + await stub(page, "media/people/list", () => people); + await stub(page, "media/people/update", (input) => { + const { personId, ...patch } = input; + people = people.map((row) => (row.id === personId ? { ...row, ...patch } : row)); + return people.find((row) => row.id === personId); + }); + await stub(page, "media/people/merge", (input) => { + const [target, ...others] = input.personIds as string[]; + people = people + .filter((row) => !others.includes(row.id)) + .map((row) => (row.id === target ? { ...row, confirmed: true, faceTrackCount: 6 } : row)); + return people.find((row) => row.id === target); + }); + await stub(page, "media/people/recordConsent", (input) => { + consentCalls.push(input); + people = people.map((row) => + row.id === input.personId + ? { + ...row, + consentRecordedAt: input.consentRecordedAt, + ageVerificationMethod: input.ageVerificationMethod, + ageVerifiedAt: input.ageVerifiedAt, + } + : row, + ); + return people.find((row) => row.id === input.personId); + }); + + const studio = await openMediaStudio(page); + await studio.getByTestId("media-connect-source-open").click(); + const connect = page.getByTestId("media-connect-source"); + await expect(connect).toBeVisible(); + await captureScreenshot(page, testInfo, "media-library-connect-source"); + await page.keyboard.press("Escape"); + await expect(connect).toBeHidden(); + await expect(studio).toBeVisible(); + + await studio.getByRole("tab", { name: "People" }).click(); + await expect(studio).toHaveAttribute("data-view", "people"); + const cards = studio.getByTestId("media-person"); + await expect(cards).toHaveCount(3); + await captureScreenshot(page, testInfo, "media-people"); + + const first = cards.filter({ hasText: "Person 1" }); + await first.getByRole("button", { name: "Confirm" }).click(); + await expect(first.getByText("Confirmed")).toBeVisible(); + + await first.getByRole("combobox", { name: "Anonymization" }).selectOption("blur"); + await expect(first.getByRole("combobox", { name: "Anonymization" })).toHaveValue("blur"); + await expect( + first + .getByRole("combobox", { name: "Anonymization" }) + .locator("option[value=synthetic_identity]"), + ).toBeDisabled(); + + await first.getByRole("button", { name: "Add consent" }).click(); + const consent = page.getByTestId("media-consent"); + await expect(consent).toBeVisible(); + await consent.getByLabel("Consent given on").fill("2026-09-01"); + await consent.getByLabel("Age proof").selectOption("platform_verified"); + await consent.getByLabel("Verified on").fill("2026-09-02"); + await captureScreenshot(page, testInfo, "media-people-consent"); + await consent.getByRole("button", { name: "Save" }).click(); + await expect(consent).toBeHidden(); + expect(consentCalls).toEqual([ + { + personId: "person-a", + consentArtifactId: null, + consentRecordedAt: "2026-09-01T12:00:00.000Z", + ageVerificationMethod: "platform_verified", + ageVerifiedAt: "2026-09-02T12:00:00.000Z", + }, + ]); + await expect(first.locator("[data-consent=complete]")).toBeVisible(); + + await cards + .filter({ hasText: "Person 2" }) + .getByRole("checkbox", { name: "Select Person 2" }) + .click(); + await cards + .filter({ hasText: "Person 3" }) + .getByRole("checkbox", { name: "Select Person 3" }) + .click(); + await studio.getByRole("button", { name: "Merge" }).click(); + await page.getByRole("alertdialog").getByRole("button", { name: "Merge" }).click(); + await expect(cards).toHaveCount(2); +}); + +test("results import a CSV with a mapping and show the funnel", async ({ page }, testInfo) => { + const stamp = Date.now(); + await signup(page, `media-results-${stamp}@rakazo.test`, "password12", "Media Results"); + await completeOnboarding(page); + + let imported = false; + const saved: Json[] = []; + await stub(page, "media/metrics/funnel", () => + imported + ? [ + { + publicationId: "pub-1", + projectId: "project-1", + profileId: "example-short-vertical", + variant: "15s", + thumbnailVariant: "a", + views: 12000, + clicks: 840, + conversions: 42, + revenueCents: 41958, + currency: "EUR", + }, + { + publicationId: "pub-2", + projectId: "project-1", + profileId: "example-paid-video", + variant: null, + thumbnailVariant: null, + views: 3000, + clicks: 300, + conversions: 30, + revenueCents: 29970, + currency: "EUR", + }, + ] + : [], + ); + await stub(page, "media/metrics/segments", () => + imported + ? [ + { + segmentId: "segment-1", + assetId: "asset-1", + startMs: 1000, + endMs: 4000, + publications: 2, + views: 15000, + clicks: 1140, + conversions: 72, + revenueCents: 71928, + currency: "EUR", + }, + ] + : [], + ); + await stub(page, "media/publications/list", () => ({ items: [], nextCursor: null })); + await stub(page, "media/metrics/mappings", () => []); + await stub(page, "media/metrics/saveMapping", (input) => { + saved.push(input); + return { ...input, id: "mapping-1" }; + }); + await stub(page, "media/metrics/importCsv", () => { + imported = true; + return { imported: 2, skipped: [] }; + }); + + const studio = await openMediaStudio(page); + await studio.getByRole("tab", { name: "Results" }).click(); + await expect(studio.getByText("No results yet.")).toBeVisible(); + + await studio.getByRole("button", { name: "Import CSV" }).click(); + const dialog = page.getByTestId("media-csv-import"); + await dialog.getByLabel("CSV file").setInputFiles({ + name: "example-platform.csv", + mimeType: "text/csv", + buffer: Buffer.from( + "date;utm_content;views;clicks;purchases;revenue\n2026-09-01;variant-a;12000;840;42;419.58\n2026-09-01;variant-b;3000;300;30;299.70\n", + ), + }); + await expect(dialog.getByLabel("Views")).toHaveValue("views"); + await expect(dialog.getByLabel("Purchases")).toHaveValue("purchases"); + await captureScreenshot(page, testInfo, "media-results-mapping"); + await dialog.getByRole("button", { name: /^Import/ }).click(); + await expect(dialog.getByTestId("media-csv-imported")).toHaveText("2 rows imported"); + expect(saved[0]).toMatchObject({ + name: "example-platform", + delimiter: ";", + publicationKey: "utm_content", + columns: { publication: "utm_content", observedAt: "date", views: "views" }, + }); + await dialog.getByRole("button", { name: "Done" }).click(); + + const funnel = studio.getByTestId("media-funnel"); + await expect(funnel).toBeVisible(); + await expect(funnel).toContainText("15,000"); + await expect(funnel).toContainText("1,140"); + await expect(funnel).toContainText("72"); + await expect(studio.getByTestId("media-results-segments")).toBeVisible(); + await captureScreenshot(page, testInfo, "media-results-funnel"); + + await studio.getByRole("button", { name: "More" }).click(); + await page.getByRole("menuitem", { name: "Reset teaser model" }).click(); + await page.getByRole("alertdialog").getByRole("button", { name: "Reset" }).click(); + await expect(studio.getByText("Reset", { exact: true })).toBeVisible(); +}); + +test("the asset grid stays virtualized across 50k assets", async ({ page }) => { + test.setTimeout(240_000); + const stamp = Date.now(); + await signup(page, `media-scale-${stamp}@rakazo.test`, "password12", "Media Scale"); + await completeOnboarding(page); + + const total = 50_000; + await stub(page, "media/sources/list", () => [ + { + id: "scale-source", + kind: "folder", + label: "Archive", + status: "idle", + assetCount: total, + lastSyncedAt: now, + error: null, + createdAt: now, + }, + ]); + await stub(page, "media/assets/search", (input) => { + if (Array.isArray(input.ingestStates)) return { items: [], nextCursor: null }; + const offset = Number(input.cursor ?? 0); + const limit = Number(input.limit ?? 100); + const end = Math.min(total, offset + limit); + return { + items: Array.from({ length: end - offset }, (_, index) => syntheticAsset(offset + index)), + nextCursor: end < total ? String(end) : null, + }; + }); + + const studio = await openMediaStudio(page); + await studio.getByRole("button", { name: "Videos", exact: true }).click(); + const grid = studio.getByTestId("media-asset-grid"); + await expect(grid.getByTestId("media-asset-tile").first()).toBeVisible(); + + let loaded = 0; + while (loaded < total) { + await grid.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await expect + .poll(async () => Number(await grid.getAttribute("data-count")), { timeout: 10_000 }) + .toBeGreaterThan(loaded); + loaded = Number(await grid.getAttribute("data-count")); + expect(await grid.getByTestId("media-asset-tile").count()).toBeLessThan(400); + } + expect(loaded).toBe(total); + await grid.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await expect(grid.locator('[data-asset-id="scale-49999"]')).toBeVisible(); + expect(await grid.getByTestId("media-asset-tile").count()).toBeLessThan(400); +}); diff --git a/apps/web/src/components/media/library/CreateProjectDialog.tsx b/apps/web/src/components/media/library/CreateProjectDialog.tsx index 0c8d9e4..c0f0571 100644 --- a/apps/web/src/components/media/library/CreateProjectDialog.tsx +++ b/apps/web/src/components/media/library/CreateProjectDialog.tsx @@ -150,6 +150,11 @@ export function CreateProjectDialog({ Platforms + {profiles?.length === 0 ? ( +

+ No platform profiles found. +

+ ) : null}
{(profiles ?? []).map((profile) => (
diff --git a/apps/web/src/components/media/library/PersonCard.tsx b/apps/web/src/components/media/library/PersonCard.tsx index f686e87..79c8372 100644 --- a/apps/web/src/components/media/library/PersonCard.tsx +++ b/apps/web/src/components/media/library/PersonCard.tsx @@ -16,7 +16,7 @@ import { NativeSelect, NativeSelectOption, } from "@rakazo/ui-web"; -import { Check, MoreHorizontal, ShieldAlert, ShieldCheck } from "lucide-react"; +import { Check, MoreHorizontal, ShieldAlert, ShieldCheck, UserRound } from "lucide-react"; import { useState } from "react"; import { consentState } from "../../../lib/media-library"; import { AssetThumb } from "./AssetTile"; @@ -81,10 +81,15 @@ export function PersonCard({ >
- + {person.coverThumbAssetId ? ( + + ) : ( +
{name !== null ? ( diff --git a/apps/web/src/lib/media-library.ts b/apps/web/src/lib/media-library.ts index f16799d..42d06f1 100644 --- a/apps/web/src/lib/media-library.ts +++ b/apps/web/src/lib/media-library.ts @@ -40,8 +40,11 @@ export function hasAssetQuery(filters: ReadonlySet, text: string) return filters.size > 0 || text.trim().length > 0; } -/** Ingest states that mean the server still has work queued for an asset. */ -export const INGEST_PENDING_STATES = ["indexed", "proxying", "proxied", "analyzing"] as const; +/** + * Ingest states that mean the server is working on an asset. "indexed" is left + * out: device-held assets wait there until the device uploads them. + */ +export const INGEST_PENDING_STATES = ["proxying", "proxied", "analyzing"] as const; // --------------------------------------------------------------------------- // Grid geometry diff --git a/apps/web/src/pages/media/LibraryView.tsx b/apps/web/src/pages/media/LibraryView.tsx index e3212af..d889f1a 100644 --- a/apps/web/src/pages/media/LibraryView.tsx +++ b/apps/web/src/pages/media/LibraryView.tsx @@ -23,7 +23,7 @@ import { rpcErrorMessage, useMediaStudio } from "../../lib/media-studio"; import { rpc } from "../../lib/rpc"; import type { MediaViewProps } from "./nav"; -const PAGE = 200; +const PAGE = 500; type Paged = { items: T[]; cursor: string | null; done: boolean; loading: boolean }; const emptyPage = (): Paged => ({ items: [], cursor: null, done: false, loading: false });