test(media): library e2e covering sessions, people consent, results import and 50k virtualization

This commit is contained in:
Claude 2026-10-03 19:29:21 +00:00
parent 80ca23c546
commit 26872b5dc4
No known key found for this signature in database
6 changed files with 474 additions and 9 deletions

View File

@ -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<string, unknown>;
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);
});

View File

@ -150,6 +150,11 @@ export function CreateProjectDialog({
<FieldLegend variant="label">
<Trans>Platforms</Trans>
</FieldLegend>
{profiles?.length === 0 ? (
<p className="text-ui-body-sm text-muted-foreground">
<Trans>No platform profiles found.</Trans>
</p>
) : null}
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto">
{(profiles ?? []).map((profile) => (
<div

View File

@ -324,7 +324,7 @@ export function CsvImportDialog({
</NativeSelect>
<Input
aria-label={t`Currency`}
className="h-7 w-16 uppercase"
className="h-7! w-16 uppercase"
maxLength={3}
value={draft.defaultCurrency}
onChange={(event) =>

View File

@ -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({
>
<div className="flex items-center gap-3">
<div className="relative size-12 shrink-0 overflow-hidden rounded-full bg-muted">
<AssetThumb
assetId={person.coverThumbAssetId}
hasThumbnail={person.coverThumbAssetId !== null}
/>
{person.coverThumbAssetId ? (
<AssetThumb assetId={person.coverThumbAssetId} hasThumbnail />
) : (
<UserRound
aria-hidden="true"
strokeWidth={1.6}
className="m-3 size-6 text-muted-foreground/60"
/>
)}
</div>
<div className="min-w-0 flex-1">
{name !== null ? (

View File

@ -40,8 +40,11 @@ export function hasAssetQuery(filters: ReadonlySet<LibraryFilter>, 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

View File

@ -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<T> = { items: T[]; cursor: string | null; done: boolean; loading: boolean };
const emptyPage = <T,>(): Paged<T> => ({ items: [], cursor: null, done: false, loading: false });