Files
civitai__civitai/tests/preview-apps-page.spec.ts
T
Zachary Lowden 0de1e5a524 test(app-blocks): fix 3 post-merge test failures (consent race, missing mock, renamed selector) (#2773)
Three report-only suites went red on main after today's app-blocks merges. All
three are test issues — none is a product bug; verified each against source.

1) component PageBlockHost.browser.test.tsx — "REQUEST_CONSENT opens the consent
   dialog". driveToReady() only guarantees the DOM `data-block-ready` attribute,
   but the host's message-gate state can lag it a tick, so the single fire-once
   REQUEST_CONSENT could land while the gate still reads "not ready", get dropped
   (cf. the "before BLOCK_READY is dropped" test), and never retry → dialog never
   opens (a 10s CI-contention flake; yesterday's vi.waitFor timeout bump only
   delayed the failure). Fix: re-post REQUEST_CONSENT INSIDE the waitFor, mirroring
   driveToReady's BLOCK_READY retry. waitFor exits on first success so exactly one
   post opens exactly one dialog. Verified 23/23.

2) unit appBlockReview.reward.test.ts — the ~/server/prom/client mock omitted
   `clickhouseFailSoftCounter`, which base.reward.ts increments on the ClickHouse
   fail-soft path → `.inc` on undefined rejected, failing the "insert throw does
   NOT propagate" test. Added the missing counter to the mock (sibling
   base.reward.failsoft.test.ts already mocks it). Verified 7/7.

3) smoke preview-apps-page.spec.ts — asserted a `getByRole('button', { name:
   'App block menu' })` that #2767 renamed to `aria-label="App menu"` in
   IframeHost.tsx; the component test was updated, the smoke spec was not. Updated
   the selector (+ the two doc-comment references). The button renders fine — pure
   test/UI desync. (Can't run the smoke spec locally; matched against
   IframeHost.tsx:274 + AppBlockChrome.browser.test.tsx which asserts 'App menu'.)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:37:04 -05:00

169 lines
8.2 KiB
TypeScript

import { expect, test } from '@playwright/test';
import { storageStatePath } from './preview-fixtures';
import { trpcQuery } from './preview-trpc';
/**
* Preview-e2e (W10): App Blocks FULL-PAGE app surface — the `/apps/run/<slug>`
* route + the page-token mint. A mod opens a page-declaring app's full-page
* surface; we assert the host trust chrome + the block iframe mount, and that
* the page received a BLOCK_INIT (the iframe transitions to ready, which only
* happens after the host posts BLOCK_INIT with a viewer-scoped page token +
* subPath). Otherwise untested by the preview suite.
*
* Runs as the `mod` fixture (id 2000000001) — the ONLY preview role with BOTH
* `features.appBlocks` AND `features.appBlocksPages` (both flags are
* moderator-segment-only / mod-only by availability). For a non-mod the page
* SSR-resolves to a Next 404 (the two-flag gate), and the token mint 403s. So
* this MUST run as mod to exercise the real page path.
*
* Data resilience: the dev DB is a weekly prod clone. A page-declaring approved
* app may not exist in a given clone, so we DISCOVER one at runtime from
* `listAvailable` (manifest.hasPage === true) and `test.skip()` with an
* annotation when none exists — never hardcode a slug.
*
* NB: the preview run is the GATE — it needs the `app-blocks-pages-enabled`
* Flipt flag relaxed to the mod segment on the preview env (same posture as
* `app-blocks-enabled`). The spec is authored here; the green preview run
* validates it.
*
* Verified tRPC / route shapes (against origin/main + this PR):
* - blocks.listAvailable → `{ items: AvailableBlock[]; nextCursor? }`. Each
* AvailableBlock now carries `manifest.hasPage?: boolean` (W10 public
* projection) + `blockId` (the `<slug>` used by the page route).
* - Page route `/apps/run/[slug]/[[...path]]` SSR-gates on
* `features.appBlocks && features.appBlocksPages`, resolves the approved app
* by `block_id`, and renders `<PageBlockHost>`: the W7 trust chrome
* (`AppBlockChrome`, with an "App menu" button) above the block
* `<iframe>` (`title=<appName>`, `data-block-instance-id="page_<appBlockId>"`,
* `data-block-ready` flipping to "true" on BLOCK_READY).
*
* ⚠️ SELECTORS — NO `data-testid`. The preview is a PRODUCTION Next build
* (NODE_ENV=production), and next.config.mjs strips every `data-testid` in
* production (`compiler.reactRemoveProperties: { properties: ['^data-testid$'] }`).
* So `getByTestId('app-page-iframe' | 'app-block-chrome' | 'app-page-frame')`
* NEVER matches against a deployed preview — the elements render fine, the
* attribute is just gone. We therefore assert on attributes the production build
* KEEPS: the chrome's accessible "App menu" button, and the iframe's
* `data-block-instance-id` (a `page_*` id) + `data-block-ready`. (`reactRemove
* Properties` only strips `^data-testid$`, so other `data-*` attrs survive.)
* This mirrors the sibling preview-apps-* specs, which assert via tRPC + ARIA
* roles, never rendered testids.
*/
const ROLE = 'mod' as const;
type AvailableBlock = {
id: string;
blockId: string;
appId: string;
appName: string | null;
manifest: { name?: string; description?: string; hasPage?: boolean };
installCount: number;
};
type ListAvailableResult = { items: AvailableBlock[]; nextCursor?: string };
test.describe('App Blocks full-page app surface (mod)', () => {
test.use({ storageState: storageStatePath(ROLE) });
test('a page-declaring app opens at /apps/run/<slug>: chrome + iframe mount and the host mints a page token', async ({
page,
}) => {
// DISCOVER a page-declaring approved app from the public listing. `{}` is a
// valid input (all listAvailableSchema fields optional). page.request carries
// the mod cookie; the helper stamps Origin/Referer for the CSRF gate.
const listing = await trpcQuery<ListAvailableResult>(page.request, 'blocks.listAvailable', {});
const blocks = listing?.items ?? [];
const pageApp = blocks.find((b) => b.manifest?.hasPage === true);
test.skip(
!pageApp,
'No approved app declaring a full-page surface (manifest.page) in this dev-DB clone — nothing to open. Skipping rather than hard-failing.'
);
if (!pageApp) return;
// The full-page route. SSR must succeed (status < 400) for the mod who
// clears the two-flag gate (NOT the 404 a non-pages viewer gets).
const resp = await page.goto(`/apps/run/${encodeURIComponent(pageApp.blockId)}`, {
waitUntil: 'domcontentloaded',
});
expect(
resp?.status(),
`GET /apps/run/${pageApp.blockId} status for the appBlocksPages-enabled mod`
).toBeLessThan(400);
// The host trust chrome (rendered in civitai-web, spoof-proof) is present —
// proves PageBlockHost mounted (not a 404 / blank). The chrome's "App block
// menu" button is unique to AppBlockChrome and uses a production-safe
// accessible name (NOT a stripped data-testid).
await expect(
page.getByRole('button', { name: 'App menu' }),
'the full-page host should render the W7 trust chrome'
).toBeVisible();
// The block iframe mounts (server-resolved manifest.iframe.src). Located by
// its surviving `data-block-instance-id` (a synthetic `page_<appBlockId>` id
// unique to the page host) — `getByTestId` would never match (stripped).
const frame = page.locator('iframe[data-block-instance-id^="page_"]');
await expect(frame, 'the full-page block iframe should mount').toBeVisible();
// The iframe points at the server-resolved block origin (manifest.iframe.src
// → `<slug>.civit.ai`), not a blank/about:blank — proves the SSR-resolved
// src reached the DOM.
const iframeSrc = await frame.getAttribute('src');
expect(iframeSrc, 'the iframe src is the server-resolved block origin').toMatch(
/^https?:\/\//
);
// PAGE-MINT PROOF (the host-side half of the handshake we CAN verify on a
// preview): the host posts BLOCK_INIT only after minting a viewer-scoped page
// token from POST /api/v1/block-tokens (entityType:'none', `page_<appBlockId>`).
// Exercise that exact mint with the mod cookie — a 200 + non-empty token is
// the e2e proof the page-mint path works (two-flag gate cleared, synthetic
// page instance resolved, JWT issued). The host then posts BLOCK_INIT to the
// block origin.
//
// We do NOT assert `data-block-ready === 'true'` (the block's BLOCK_READY
// ack): that flips only when the cross-origin block bundle at `<slug>.civit.ai`
// ACCEPTS the BLOCK_INIT, which requires the PARENT origin (here the ephemeral
// preview host `pr-N.civitaic.com`) to be in the block's own
// `allowedParentOrigins` allowlist. Production blocks allowlist civitai.com /
// *.civit.ai, never an ephemeral preview origin, so the ack can never arrive
// on a preview no matter how correct civitai-web is — asserting it makes this
// spec un-passable by construction. The host-side contract (chrome + iframe +
// page-token mint) is what a preview can prove; the BLOCK_READY round-trip is
// covered by the PageBlockHost.browser.test.tsx component test with a stubbed
// block.
const tokenResp = await page.request.post('/api/v1/block-tokens', {
headers: {
'content-type': 'application/json',
origin: process.env.PREVIEW_URL ?? '',
referer: `${process.env.PREVIEW_URL ?? ''}/apps/run/${pageApp.blockId}`,
},
data: {
// Synthetic page instance id: `page_<appBlockId>`, where appBlockId is
// `AvailableBlock.id` (== app_blocks.id, the `apb_*` value) — the same id
// the page route builds in `page_${appBlockId}`.
blockInstanceId: `page_${pageApp.id}`,
slotContext: {
slotId: 'app.page',
entityType: 'none',
slug: pageApp.blockId,
subPath: '',
viewerUserId: null,
viewerUsername: null,
theme: 'dark',
},
},
});
expect(
tokenResp.status(),
'the host mints a viewer-scoped page token (the BLOCK_INIT prerequisite)'
).toBe(200);
const tokenBody = (await tokenResp.json()) as { token?: string };
expect(
typeof tokenBody.token === 'string' && tokenBody.token.length > 0,
'page-token mint returns a non-empty JWT'
).toBe(true);
});
});