import { expect, test } from '@playwright/test'; import { storageStatePath } from './preview-fixtures'; import { trpcQuery } from './preview-trpc'; /** * Preview-e2e (F-C): App Blocks MARKETPLACE discovery + per-app detail — the * anon-capable PUBLIC read path (`blocks.listAvailable` → `blocks.getAppDetail`) * plus `/apps`, and the retirement of the legacy `/apps/` route. * Otherwise untested by the preview suite; a PR that broke the marketplace * listing/detail projection or the `features.appBlocks` page gate passes every * other preview spec today. * * Runs as the `mod` fixture (id 2000000001) — the ONLY preview role with * `features.appBlocks` (the Flipt `app-blocks-enabled` flag is moderator-segment * -only) AND exempt from the per-IP marketplace rate limit (listAvailable / * getAppDetail carry a 60/60 rateLimit that the middleware waives for mods). * The non-mod testers do NOT have appBlocks: for them /apps SSR-resolves to a * Next 404 (resolveAppsPageAccess → notFound) and listAvailable returns empty — * so this MUST run as mod to exercise the real read path. * * Data resilience: the dev DB is a weekly prod clone — there are ~3 approved app * blocks today, but a given clone could have zero. So we DISCOVER an appBlockId * at runtime from `listAvailable` (never hardcode one) and `test.skip()` with an * annotation when the clone has no approved blocks, rather than hard-failing. * * NB: these procs are DB-backed (not meili-backed search), so no `retryFlaky`. * * Verified tRPC shapes (against origin/main, paths relative to civitai/src): * - blocks.listAvailable (blocks.router.ts:952 publicProcedure + enforceApp * BlocksFlag + 60/60 rateLimit; input listAvailableSchema, all fields * optional/defaulted so `{}` is valid) RETURNS AN OBJECT, NOT a bare array: * `{ items: AvailableBlock[]; nextCursor?: string }` * (BlockRegistry.listAvailable, block-registry.service.ts:2226). Each * AvailableBlock (subscription.schema.ts:157) = { id, blockId, appId, * appName, manifest: PublicBlockManifest, installCount, category, * scopesSummary }. We DISCOVER an id from `items[0].id` (that id is the * `appBlockId`). * - blocks.getAppDetail (blocks.router.ts:1001 publicProcedure + flag + * 60/60 rateLimit; input { appBlockId }) returns `PublicAppDetail | null` * (subscription.schema.ts:371): { id, blockId, appId, appName, manifest: * { name?, description?, targets? }, scopes: string[], contentRating, * version, installCount, liveUrl, screenshots }. Returns null for a missing * / non-approved id (the router then throws NOT_FOUND — our helper would * surface that as a thrown error). We assert the shape of a discovered, * known-approved id, so a non-null detail is expected. * - Legacy detail page (`/apps/[appBlockId]/index.tsx`) is RETIRED: its * `getServerSideProps` now resolves the app's approved `AppListing` and * redirects to `/apps/store-preview/` (or `notFound` when the app has no * approved listing). It no longer renders, so this spec asserts the REDIRECT * rather than a heading on it. * - Marketplace page (`/apps/index.tsx`) renders the apps navigation as a LEFT RAIL of * links, whose rows come from `appsSections` in `~/components/Apps/apps-sections`. * The row this spec keys on is `{ id: 'marketplace', path: '', label: 'Marketplace' }` * — `path` is the URL segment UNDER `/apps`, so the marketplace row's is the empty * string, not `'/apps'`. It is the only row whose `visible` predicate is unconditional * for a store-eligible viewer: `(_s, c) => c.canSeeStore`, which is exactly the gate * the page itself is behind. ⚠️ An earlier revision of this line added "every other * row is summary-conditional" as the reason — that is wrong for the BUILD row, which * is `(_s, c) => c.canBuild`, context-conditional and deliberately summary-free. The * conclusion still holds by a different route: `canBuild` is * `hasAppsStoreAccess(features) && (isAppDeveloper || appBlocksGetStarted)`, strictly * NARROWER than `canSeeStore`, so it cannot be visible where the marketplace row is * not. It is NOT necessarily the first row, so the assertion below selects it BY NAME * and the order is irrelevant to it. * * 🔴 THE RAIL IS VIEWPORT-GATED, so this spec asserts BOTH nav forms: the drawer * trigger at this config's default 1280×720 (below `APPS_RAIL_MIN_VIEWPORT`, 1300), * and the `App sections` landmark plus the `Marketplace` LINK after widening to * 1440. See the block comment on the assertion itself for why both are required. * * ⚠️ This docblock described an `AppsSubNav` TABS BAR sourced from `SUB_NAV_LINKS` * until the rail landed. Both are gone — `AppsSubNav.tsx` was DELETED by this PR and * `SUB_NAV_LINKS` no longer exists — and the stale text is what made the assertion * below look correct while it queried a `role="tab"` that nothing renders. * * The page ALSO renders a search control, which this spec does not assert: * `AppListingsMarketplaceBody.tsx` renders `` inside the `apps-store-control-row` group. ⚠️ An * earlier revision of this docblock gave the placeholder as "Search by name or block * id" (stale — only the placeholder text changed), and a later one deleted the whole * claim as "removed upstream in #2767". BOTH were wrong: the control exists at * `origin/main` and at this PR's head, and #2767 does not touch that file. The * deletion was derived from `git log -S`, which reports commits where an occurrence * COUNT changed — a rename is indistinguishable from a removal in that output. * * The page renders no `Civitai App Blocks`: the app-blocks nav * refactor (#2749/#2758) made `AppsPageLayout` DELIBERATELY OMIT the page title on * the marketplace surface ("omit for a header with just the chrome, e.g. the * marketplace" — the `title` prop's docstring in `AppsPageLayout.tsx`; the wording * was "just the tabs" before the rail, and this citation pointed at a line number * that has since drifted onto unrelated text). The nav is therefore what uniquely * identifies the rendered apps surface for an appBlocks-enabled viewer; a * non-appBlocks viewer gets the Next 404 (resolveAppsPageAccess.ts → notFound). */ const ROLE = 'mod' as const; // Public marketplace listing shape (the fields this spec reads). Mirrors // AvailableBlock — typed locally so the tRPC result isn't `unknown`/implicit any. type AvailableBlock = { id: string; blockId: string; appId: string; appName: string | null; manifest: { name?: string; description?: string; targets?: Array<{ slotId?: string }> }; installCount: number; category: string | null; scopesSummary: string[]; }; type ListAvailableResult = { items: AvailableBlock[]; nextCursor?: string }; // Public per-app detail shape (the fields this spec reads). Mirrors // PublicAppDetail — getAppDetail returns this or null. type PublicAppDetail = { id: string; blockId: string; appId: string; appName: string | null; manifest: { name?: string; description?: string; targets?: Array<{ slotId?: string }> }; scopes: string[]; contentRating: string | null; version: string | null; installCount: number; liveUrl: string; screenshots: Array<{ index: number; url: string; contentType: string }>; }; test.describe('App Blocks marketplace discovery + detail render (mod)', () => { test.use({ storageState: storageStatePath(ROLE) }); test('listAvailable → getAppDetail round-trip + /apps and /apps/[id] render', async ({ page, }) => { // The marketplace index renders for an appBlocks-enabled viewer (the mod) and // 404s for everyone else. Asserting it loads (status < 400) + shows the apps // navigation proves the mod cleared the `features.appBlocks` SSR gate and the page // rendered (NOT the 404 a non-appBlocks user gets). The page renders no "Civitai App // Blocks" heading — the app-blocks nav refactor (#2749/#2758) made AppsPageLayout omit // the title on the marketplace surface — so the nav is what uniquely identifies the // apps surface. // // 🔴 THE NAV IS A LEFT RAIL OF LINKS, NOT A TAB STRIP, AND IT IS VIEWPORT-GATED. // This assertion read `getByRole('tab', { name: 'Marketplace' })` until the rail // landed; there are no `role="tab"` elements on `/apps` any more. Two things had to // change together, and the second is the one that bites: // • the role is `link` — the rail is a `