test(media): library e2e covering sessions, people consent, results import and 50k virtualization
This commit is contained in:
parent
80ca23c546
commit
26872b5dc4
452
apps/web/e2e/media-library.spec.ts
Normal file
452
apps/web/e2e/media-library.spec.ts
Normal 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);
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user