import path from "node:path"; import { expect, test, type Page } from "@playwright/test"; import { mockCoreApis } from "@e2e/fixtures/api-mocks"; import { apiEnvelope } from "@e2e/fixtures/data/common"; import { clearBrowserState } from "@e2e/fixtures/test-helpers"; const characters = [ { id: "elio", slug: "elio", displayName: "Elio Silvestri", shortName: "Elio", cover: "elio.png", splashCover: "elio.png", }, { id: "maya-tan", slug: "maya", displayName: "Maya Tan", shortName: "Maya", cover: "maya.webp", splashCover: "maya-home.webp", }, { id: "nayeli-cervantes", slug: "nayeli", displayName: "Nayeli Cervantes", shortName: "Nayeli", cover: "nayeli.webp", splashCover: "nayeli.webp", }, ] as const; const previewDirectory = path.join( process.cwd(), ".codex", "artifacts", "frontend-preview", "2026-07-22", "multi-role-commercial", ); test.beforeEach(async ({ baseURL, context, page }) => { await clearBrowserState(context, page, baseURL); await mockCoreApis(page); await registerMultiRoleCommercialMocks(page); }); for (const character of characters) { test(`${character.displayName} homepage renders its assigned cover`, async ({ page, }, testInfo) => { const mobile = testInfo.project.name.includes("mobile"); await page.setViewportSize( mobile ? { width: 390, height: 844 } : { width: 1440, height: 900 }, ); await page.goto(`/characters/${character.slug}/splash`); const cover = page.locator(`img[src*="${character.splashCover}"]`); await expect(cover).toBeVisible(); await expect( page.getByRole("navigation", { name: "Primary navigation" }), ).toContainText("Private Zone"); await expect .poll(() => cover.evaluate((image: HTMLImageElement) => image.complete ? image.naturalWidth : 0, ), ) .toBeGreaterThan(0); if ( process.env.FRONTEND_PREVIEW_SCREENSHOTS === "1" && character.id !== "elio" ) { await page.screenshot({ path: path.join( previewDirectory, `${character.slug}-${mobile ? "mobile-390x844" : "desktop-1440x900"}.png`, ), animations: "disabled", }); } }); test(`${character.displayName} can open a character-scoped Private Zone`, async ({ page, }) => { const momentsRequest = page.waitForRequest((request) => { const url = new URL(request.url()); return ( url.pathname === "/api/private-zone/posts" && url.searchParams.get("characterId") === character.id ); }); const albumRequest = page.waitForRequest((request) => { const url = new URL(request.url()); return ( url.pathname === "/api/private-zone/albums" && url.searchParams.get("characterId") === character.id ); }); await page.goto(`/characters/${character.slug}/private-zone`); await Promise.all([momentsRequest, albumRequest]); await expect( page.getByRole("heading", { name: "Private moments" }), ).toBeVisible(); await page.getByRole("tab", { name: "Albums" }).click(); await expect( page.getByRole("heading", { name: "Private albums" }), ).toBeVisible(); await expect(page.getByText(character.displayName).last()).toBeVisible(); const albumButton = page.getByRole("button", { name: `View locked collection with 8 images from ${character.displayName}`, }); if (character.id === "maya-tan") { await expect(albumButton).toHaveCount(0); await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0); await expect(page.getByText("No private albums yet.")).toHaveCount(0); } else { await expect(albumButton).toBeVisible(); } }); test(`${character.displayName} receives a character-scoped Tip catalog`, async ({ page, }) => { const catalogRequest = page.waitForRequest((request) => { const url = new URL(request.url()); return ( url.pathname === "/api/payment/gift-products" && url.searchParams.get("characterId") === character.id ); }); await page.goto(`/characters/${character.slug}/tip`); await catalogRequest; await expect( page.getByRole("heading", { name: `Buy ${character.shortName} a coffee` }), ).toBeVisible(); await expect(page.getByRole("radio", { name: /Small Coffee/ })).toBeChecked(); await expect( page.getByRole("button", { name: "Order and Buy" }), ).toBeEnabled(); await expect( page.getByText("This character does not have any gifts available yet."), ).toHaveCount(0); }); } test("a real Private Zone request failure still offers Retry", async ({ page, }) => { await page.route("**/api/private-zone/albums**", async (route) => { await route.fulfill({ status: 503, json: { message: "Albums unavailable" } }); }); await page.goto("/characters/maya/private-zone"); await page.getByRole("tab", { name: "Albums" }).click(); await expect(page.getByRole("button", { name: "Retry" })).toBeVisible(); await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0); }); async function registerMultiRoleCommercialMocks(page: Page): Promise { await page.route("**/api/private-zone/posts**", async (route) => { const url = new URL(route.request().url()); const characterId = url.searchParams.get("characterId") ?? "elio"; await route.fulfill({ json: apiEnvelope({ characterId, items: [], nextCursor: null, hasMore: false, creditBalance: 1000, currency: "credits", }), }); }); await page.route("**/api/private-zone/albums**", async (route) => { const url = new URL(route.request().url()); const characterId = url.searchParams.get("characterId") ?? "elio"; const character = characters.find((candidate) => candidate.id === characterId) ?? characters[0]; const hasCompleteAlbum = character.id !== "maya-tan"; await route.fulfill({ json: apiEnvelope({ creditBalance: 1000, pendingImageCount: hasCompleteAlbum ? 0 : 7, hasIncompleteContent: !hasCompleteAlbum, items: hasCompleteAlbum ? [ { albumId: `album-${character.id}`, title: `${character.shortName} private photos`, content: null, previewText: "Unlock this private album", imageCount: 8, images: [ { url: `/images/private-zone/banner/${character.slug}.png`, locked: true, index: 0, }, ], locked: true, unlocked: false, unlockCost: 320, publishedAt: "2026-07-22T10:00:00+08:00", lockDetail: { locked: true }, }, ] : [], }), }); }); await page.route("**/api/payment/gift-products**", async (route) => { const url = new URL(route.request().url()); const characterId = url.searchParams.get("characterId") ?? "elio"; const planPrefix = characterId === "elio" ? "tip" : `tip_${characterId.replaceAll("-", "_")}`; await route.fulfill({ json: apiEnvelope({ characterId, categories: [ { category: "coffee", name: "Coffee", productCount: 1, imageUrl: null, }, ], plans: [ { planId: `${planPrefix}_coffee_usd_4_99`, planName: "Small Coffee", orderType: "tip", tipType: "coffee_small", category: "coffee", characterId, description: "A character-scoped coffee gift", imageUrl: null, amountCents: 499, currency: "USD", autoRenew: false, isFirstRechargeOffer: false, firstRechargeDiscountPercent: 0, promotionType: null, }, ], }), }); }); }