diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index edc646d..84f94c4 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -245,7 +245,6 @@ export async function openUserSettings( | "integrations" | "models" | "memory" - | "profile" | "voice" | "notifications" | "usage" diff --git a/apps/web/e2e/media-card.spec.ts b/apps/web/e2e/media-card.spec.ts index 3b46bbe..6697d4a 100644 --- a/apps/web/e2e/media-card.spec.ts +++ b/apps/web/e2e/media-card.spec.ts @@ -1,3 +1,6 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import type { Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; import { @@ -92,6 +95,38 @@ async function seedMediaMessage(page: Page, botId: string, block: () => MediaBlo } } +/** CI has no ffmpeg or transcription server; the card asks what the space can run. */ +async function offerFollowUps(page: Page) { + await page.route("**/rpc/media/capabilities", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + json: { + generate_image: false, + generate_video: false, + edit_image: false, + edit_video: false, + export_video: true, + transcribe: true, + }, + }), + }), + ); +} + +/** Records follow-up jobs the card starts instead of running them. */ +async function recordStartedJobs(page: Page): Promise { + const started: unknown[] = []; + await page.route("**/rpc/media/jobs/start", async (route) => { + started.push((route.request().postDataJSON() as { json: unknown }).json); + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ json: { jobId: "media-job-2", messageId: "media-message-next" } }), + }); + }); + return started; +} + test("renders a media job card while it runs and when it is done", async ({ page }, testInfo) => { const stamp = Date.now(); await signup(page, `media-card-${stamp}@rakazo.test`, "password12", "Media Card"); @@ -150,30 +185,8 @@ test("renders a media job card while it runs and when it is done", async ({ page ], }, ); - // CI has no transcription server; the card asks what the space can run. - await page.route("**/rpc/media/capabilities", (route) => - route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ - json: { - generate_image: false, - generate_video: false, - edit_image: false, - edit_video: false, - export_video: true, - transcribe: true, - }, - }), - }), - ); - const started: unknown[] = []; - await page.route("**/rpc/media/jobs/start", async (route) => { - started.push((route.request().postDataJSON() as { json: unknown }).json); - await route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ json: { jobId: "media-job-2", messageId: "media-message-next" } }), - }); - }); + await offerFollowUps(page); + const started = await recordStartedJobs(page); await page.reload(); const card = page.getByTestId("media-card"); @@ -209,3 +222,83 @@ test("renders a media job card while it runs and when it is done", async ({ page input: { artifact_id: voice.id, title: "voice-note.wav" }, }); }); + +test("a platform export names its platform, shows a failed check and exports for others", async ({ + page, +}, testInfo) => { + const stamp = Date.now(); + await signup(page, `media-platform-${stamp}@rakazo.test`, "password12", "Media Platform"); + await completeOnboarding(page); + const botId = activeBotId(page); + + const fixture = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "fixtures", + "media-clip.mp4", + ); + const clip = await rpc<{ id: string; size: number }>(page, "artifacts/create", { + botId, + name: "clip-tiktok.mp4", + mimeType: "video/mp4", + contentBase64: readFileSync(fixture).toString("base64"), + }); + await seedMediaMessage(page, botId, () => ({ + kind: "media", + jobId: "media-job-platforms", + operation: "export_video", + status: "done", + title: "TikTok · Snapchat Spotlight", + outputs: [ + { + artifactId: clip.id, + kind: "video", + mimeType: "video/mp4", + name: "clip-tiktok.mp4", + size: clip.size, + width: 1080, + height: 1920, + durationMs: 1000, + platform: "tiktok", + check: { + status: "fail", + issues: [{ code: "file_too_large", maxMb: 287, actualMb: 301.2 }], + }, + }, + ], + skipped: [ + { + platform: "snapchat-spotlight", + issues: [{ code: "too_short", minMs: 5000, actualMs: 1000 }], + }, + ], + })); + await offerFollowUps(page); + const started = await recordStartedJobs(page); + await page.reload(); + + const card = page.getByTestId("media-card"); + await expect(card).toHaveAttribute("data-status", "done", { timeout: 30_000 }); + await expect(card).toContainText("Too large for TikTok (max 287 MB)"); + await expect(card).toContainText("Too short for Snapchat Spotlight (min 0:05)"); + await captureScreenshot(page, testInfo, "media-card-platform-check"); + + await card.getByRole("button", { name: "Export" }).click(); + const menu = page.getByRole("menu"); + await expect(menu.getByRole("menuitem", { name: "YouTube Shorts" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Instagram Reels" })).toBeVisible(); + // Not the platform or the shape it already has. + await expect(menu.getByRole("menuitem", { name: "TikTok" })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: "9:16" })).toHaveCount(0); + await expect(menu.getByRole("separator")).toHaveCount(1); + await expect(menu.getByRole("menuitem", { name: "1:1" })).toBeVisible(); + await captureScreenshot(page, testInfo, "media-card-platform-export-menu"); + + await menu.getByRole("menuitem", { name: "YouTube Shorts" }).click(); + await expect.poll(() => started.length).toBe(1); + expect(started[0]).toEqual({ + botId, + messageId: expect.stringMatching(/^media-message-\d+$/), + operation: "export_video", + input: { artifact_id: clip.id, platforms: ["youtube-shorts"] }, + }); +}); diff --git a/apps/web/e2e/user-profile-edit.spec.ts b/apps/web/e2e/user-profile-edit.spec.ts index 612ab13..8d25c39 100644 --- a/apps/web/e2e/user-profile-edit.spec.ts +++ b/apps/web/e2e/user-profile-edit.spec.ts @@ -1,5 +1,5 @@ /** - * E2E: Settings → About you edits a fact in place: its wording, category and the spaces it + * E2E: Settings → General → About you edits a fact in place: its wording, category and the spaces it * shows in. */ import { expect, test } from "@playwright/test"; @@ -15,7 +15,7 @@ test("a fact's wording, category and spaces are edited in place", async ({ page addFacts: [{ category: "preferences", value: "Prefers tea" }], }); - const settings = await openUserSettings(page, "profile"); + const settings = await openUserSettings(page); const profile = settings.getByTestId("user-profile-settings"); const tea = profile.getByTestId("user-fact").filter({ hasText: "Prefers tea" }); await tea.getByRole("button", { name: "Edit" }).click(); diff --git a/apps/web/e2e/user-profile.spec.ts b/apps/web/e2e/user-profile.spec.ts index da47b22..8d5840e 100644 --- a/apps/web/e2e/user-profile.spec.ts +++ b/apps/web/e2e/user-profile.spec.ts @@ -1,6 +1,6 @@ /** * E2E: onboarding fills the user profile without asking (time zone, locale and language from - * the browser) and records the focus answer as a fact; Settings → About you shows every fact + * the browser) and records the focus answer as a fact; Settings → General → About you shows every fact * with its origin, confirms a pending one, and forgets one or all of them. */ import { expect, test } from "@playwright/test"; @@ -58,8 +58,8 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async addFacts: [{ category: "health", value: "Allergic to peanuts", source: "onboarding" }], }); - const settings = await openUserSettings(page, "profile"); - await expect(settings).toHaveAttribute("data-settings-section", "profile"); + const settings = await openUserSettings(page); + await expect(settings).toHaveAttribute("data-settings-section", "general"); const profile = settings.getByTestId("user-profile-settings"); await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna"); await expect(profile.getByTestId("profile-field-language")).toContainText("English"); @@ -99,6 +99,6 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async await profile.getByRole("button", { name: "Forget everything" }).click(); await page.getByRole("alertdialog").getByRole("button", { name: "Forget", exact: true }).click(); - await expect(profile).toContainText("Nothing yet"); + await expect(profile).toHaveCount(0); expect(await rpc<{ facts: unknown[] }>(page, "profile/get", null)).toMatchObject({ facts: [] }); }); diff --git a/apps/web/src/components/UserProfileSettings.tsx b/apps/web/src/components/UserProfileSettings.tsx index 3811a9a..33ce9da 100644 --- a/apps/web/src/components/UserProfileSettings.tsx +++ b/apps/web/src/components/UserProfileSettings.tsx @@ -151,54 +151,51 @@ export function UserProfileSettings() { ); + if (!error && (!state || (fields.length === 0 && groups.length === 0))) return null; + return ( -
+
+

+ About you +

{error ? (

{error}

) : null} - {state && fields.length === 0 && groups.length === 0 ? ( -

- Nothing yet -

- ) : null} {fields.length > 0 ? ( -
-
    - {fields.map(({ field, value }) => ( - - deleteButton( - describedBy, - () => void apply(() => rpc.profile.update(CLEARED_PROFILE_FIELD[field])), - ) - } - /> - ))} -
-
+
    + {fields.map(({ field, value }) => ( + + deleteButton( + describedBy, + () => void apply(() => rpc.profile.update(CLEARED_PROFILE_FIELD[field])), + ) + } + /> + ))} +
) : null} {groups.map((group) => ( -
-

+
+

{categoryLabels[group.category]} -

-
    +

+
    {group.facts.map((fact) => fact.id === editingId ? ( -
+
))} {fields.length > 0 || groups.length > 0 ? (