mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
test(preview): tranche-2 e2e — generation whatIf, report loop, moderation (#2470)
* test(preview): tranche-2 e2e — generation whatIf, report loop, moderation
Extends the preview smoke harness to the audit's highest-risk untested flows,
self-seeding per-run uniquely-tagged fixtures (no shared-dev-DB collisions; the
mutation isolation answer to "seed per-PR"). All report-only.
- preview-trpc.ts: tiny superjson+batched tRPC client (CSRF Origin/Referer
stamped) for self-seeding via page.request, + uniqueToken().
- preview-generation.spec.ts: assert the REAL orchestrator.whatIfFromGraph cost
quote fires + returns a numeric cost (gold). De-mocks the pricing path; no Buzz.
- preview-report.spec.ts: tester self-seeds a Post (post.create) and reports it
(report.create {type:'post',reason:'TOSViolation',details:{violation}}).
- preview-moderation.spec.ts: mod queues (/moderator/reports + /images) render;
+ a self-seeded report is actioned end-to-end (report.create -> setStatus).
tRPC input shapes verified against the schema files. NOT YET live-validated —
the CSRF origin gate on direct tRPC + the whatIf/mod selectors get confirmed by
the next report-only preview run (deferred: generation-submit, blocked on the
external Buzz service which can't be seeded via Postgres).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(preview): typecheck — reportId is number | undefined
the pipeline tsc (not playwright --list) caught: reportId inferred number
from report?.id, then assigned mine?.id (number | undefined) -> TS2322.
Type it explicitly; the runtime expect() still guards undefined.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(preview): drop the two fragile secondary DOM checks
The live run validated every core tranche-2 flow (real whatIf cost, tRPC
self-seed report round-trip through the CSRF gate, mod render + report->action
loop). The only two reds were optional best-effort DOM checks that duplicate
passing core tests:
- generation "cost near submit button": the submit button is in a gen panel
collapsed by default on the preview viewport (resolves but hidden). The
network whatIf assertion already covers the cost.
- report "image page report affordance": the report control is behind an entity
action menu, not a top-level button. report.create is already covered.
Removed both rather than leave known flakes in the gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { storageStatePath } from './preview-fixtures';
|
||||
|
||||
/**
|
||||
* Generation cost-quote e2e for a deployed PR preview.
|
||||
*
|
||||
* De-mocks the orchestrator: instead of stubbing the price endpoint, this drives
|
||||
* the REAL `/generate` page as a gate-passing PAID member (gold) and asserts the
|
||||
* client actually fires the tRPC QUERY `orchestrator.whatIfFromGraph` and gets a
|
||||
* numeric Buzz cost back. whatIf is a pure price quote (no Buzz balance needed),
|
||||
* so it fires on-load once a model+workflow is selected — the default form
|
||||
* preselects a model, so we PREFER the on-load path (no interaction).
|
||||
*
|
||||
* Runs only under playwright.preview.config.ts (needs PREVIEW_URL + minted
|
||||
* storage states). Cost source of truth: superjson-wrapped, batched tRPC
|
||||
* response `[{ result: { data: { json: { cost: { total } } } } }]`
|
||||
* (see orchestration-new.service.ts ~L1452 and useWhatIfFromGraph.ts).
|
||||
*/
|
||||
|
||||
// Use the PAID member — passes the preview gate AND is a real generation user.
|
||||
test.describe('generation cost quote (gold)', () => {
|
||||
test.use({ storageState: storageStatePath('gold') });
|
||||
|
||||
const WHATIF_URL = '/api/trpc/orchestrator.whatIfFromGraph';
|
||||
|
||||
/**
|
||||
* Pull the first numeric `cost.total` out of a tRPC response body, tolerating:
|
||||
* - batched array vs single object
|
||||
* - superjson `{ json: ... }` unwrapping at the data layer
|
||||
* - the `{ result: { data: ... } }` envelope
|
||||
* Returns a number, or null if not found. Walks defensively because the exact
|
||||
* nesting depends on tRPC batch-link + superjson transformer versions.
|
||||
*/
|
||||
function extractCostTotal(body: unknown): number | null {
|
||||
const entries = Array.isArray(body) ? body : [body];
|
||||
for (const entry of entries) {
|
||||
// result.data, then optional superjson `.json`, then `.cost.total`.
|
||||
const data = (entry as any)?.result?.data;
|
||||
const payload = data?.json ?? data;
|
||||
const total = payload?.cost?.total;
|
||||
if (typeof total === 'number') return total;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Primary, network-based: whatIf fires on /generate load and quotes a cost.
|
||||
test('whatIfFromGraph fires on /generate and returns a numeric cost', async ({ page }) => {
|
||||
// Arm the response listener BEFORE navigating so an on-load fire isn't missed.
|
||||
const whatIfResponse = page.waitForResponse(
|
||||
(r) => r.url().includes(WHATIF_URL) && r.status() === 200,
|
||||
{ timeout: 25_000 }
|
||||
);
|
||||
|
||||
const resp = await page.goto('/generate', { waitUntil: 'domcontentloaded' });
|
||||
expect(resp?.status(), 'HTTP status for /generate').toBeLessThan(400);
|
||||
|
||||
// Gate must not bounce a gold (paid) member.
|
||||
expect(page.url(), 'should not redirect to /login').not.toContain('/login');
|
||||
expect(page.url(), 'should not redirect to /preview-restricted').not.toContain(
|
||||
'/preview-restricted'
|
||||
);
|
||||
|
||||
// NOTE: relies on the default /generate form preselecting a valid model+workflow
|
||||
// so whatIf fires without interaction. If a future default ships with no model
|
||||
// preselected this will time out — see the UI-fallback test below for the signal.
|
||||
const response = await whatIfResponse;
|
||||
const body = await response.json();
|
||||
const total = extractCostTotal(body);
|
||||
|
||||
expect(total, 'cost.total parsed from whatIfFromGraph response').not.toBeNull();
|
||||
expect(typeof total, 'cost.total is numeric').toBe('number');
|
||||
expect(total as number, 'cost.total is a non-negative quote').toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
// The real pricing path is fully covered by the network assertion above. A DOM
|
||||
// cost-near-submit check was dropped: the submit button + cost live in a gen
|
||||
// panel that's collapsed by default on the preview viewport (button resolves
|
||||
// but is `hidden`), making it a fragile, redundant assertion.
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { storageStatePath } from './preview-fixtures';
|
||||
import { trpcMutation, trpcQuery, uniqueToken } from './preview-trpc';
|
||||
|
||||
/**
|
||||
* Moderation-surface tests for a deployed PR preview environment.
|
||||
*
|
||||
* Runs as the `mod` fixture — the ONLY role that both clears the preview gate and
|
||||
* carries user.isModerator, which the /moderator/* tRPC procedures
|
||||
* (moderatorProcedure) require. The mod fixture is also onboarding=15, so it
|
||||
* passes guardedProcedure and can self-seed a post + report.
|
||||
*
|
||||
* Two render tests (the reports + images queues render behind the gate) and one
|
||||
* end-to-end ACTION test (a mod self-seeds an isolated post -> reports it ->
|
||||
* actions the report), kept as separate test()s so a render-selector miss can't
|
||||
* mask the action coverage and vice-versa.
|
||||
*
|
||||
* Only runs under playwright.preview.config.ts (needs PREVIEW_URL + minted states).
|
||||
*
|
||||
* Verified tRPC shapes (civitai repo, paths relative to civitai/src):
|
||||
* - post.create guardedProcedure, input postCreateSchema
|
||||
* (server/schema/post.schema.ts:postCreateSchema) ->
|
||||
* { title?: string|null, detail?: string|null, ... } ;
|
||||
* returns the created Post incl. `id`
|
||||
* (server/controllers/post.controller.ts createPostHandler `return post`).
|
||||
* - report.create guardedProcedure, input createReportInputSchema
|
||||
* (server/schema/report.schema.ts:104) — a discriminatedUnion
|
||||
* on `reason`. The `Spam` variant (reportSpamSchema, :92) is the
|
||||
* minimal shape: { type: ReportEntity, id: number,
|
||||
* reason: 'Spam', details: {} }. `type` is z.enum(ReportEntity)
|
||||
* and the Post entity string is 'post'
|
||||
* (shared/utils/report-helpers.ts:8 `Post = 'post'`).
|
||||
* Returns the created Report row incl. `id` + `status`
|
||||
* (server/services/report.service.ts createReport `return createdReport`).
|
||||
* - report.getAll moderatorProcedure, input getReportsSchema
|
||||
* (server/schema/report.schema.ts:128) = getAllQuerySchema
|
||||
* (page/limit) + { type: ReportEntity, filters?, sort? }.
|
||||
* Row shape selects post.post.id
|
||||
* (server/controllers/report.controller.ts:180) so a Post
|
||||
* report row is matched via row.post?.post?.id.
|
||||
* - report.setStatus moderatorProcedure, input setReportStatusSchema
|
||||
* (server/schema/report.schema.ts:116) = { id: number,
|
||||
* status: ReportStatus }. ReportStatus ∈
|
||||
* Pending|Processing|Actioned|Unactioned (prisma/schema.prisma:1114).
|
||||
*/
|
||||
|
||||
const ROLE = 'mod' as const;
|
||||
|
||||
// Mirror preview-smoke.spec.ts: assert we cleared the preview gate.
|
||||
function assertGatePassed(page: import('@playwright/test').Page, path: string) {
|
||||
expect(page.url(), `${path}: should not redirect to /login`).not.toContain('/login');
|
||||
expect(page.url(), `${path}: should not redirect to /preview-restricted`).not.toContain(
|
||||
'/preview-restricted'
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('moderation surface (mod)', () => {
|
||||
test.use({ storageState: storageStatePath(ROLE) });
|
||||
|
||||
test('/moderator/reports renders the report queue', async ({ page }) => {
|
||||
const resp = await page.goto('/moderator/reports', { waitUntil: 'domcontentloaded' });
|
||||
expect(resp?.status(), 'HTTP status for /moderator/reports').toBeLessThan(400);
|
||||
assertGatePassed(page, '/moderator/reports');
|
||||
|
||||
// Page-loaded anchor: reports.tsx renders <Meta title="Reports" /> (line 219),
|
||||
// so the document <title> is the most stable "the mod page rendered (not an
|
||||
// error/redirect)" signal, independent of how many rows the prod-clone DB has.
|
||||
await expect(page).toHaveTitle(/Reports/i, { timeout: 30_000 });
|
||||
|
||||
// Structural presence of the queue UI. reports.tsx renders a MantineReactTable
|
||||
// (import line 30, JSX line 231), which emits a <table> with role="table".
|
||||
// Be tolerant of 0..N rows on the prod clone — assert the grid scaffold exists,
|
||||
// not any specific row.
|
||||
// NOTE: if MantineReactTable's role/markup changes, widen this — the intent is
|
||||
// "a table/grid structure is on the page". A visible table OR a non-empty main
|
||||
// region both satisfy "the queue rendered".
|
||||
const table = page.getByRole('table').first();
|
||||
await expect(table).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('/moderator/images renders the image review queue', async ({ page }) => {
|
||||
const resp = await page.goto('/moderator/images', { waitUntil: 'domcontentloaded' });
|
||||
expect(resp?.status(), 'HTTP status for /moderator/images').toBeLessThan(400);
|
||||
assertGatePassed(page, '/moderator/images');
|
||||
|
||||
// images.tsx is an infinite list off trpc.image.getModeratorReviewQueue. It
|
||||
// renders either image <Card>s (Mantine Card import line 5, usage line 366) OR,
|
||||
// when the queue is empty, <NoContent message="There are no images that need
|
||||
// review" /> (line 343). Tolerate BOTH branches: assert at least one of the
|
||||
// known structural anchors is present so the test passes on a full or empty
|
||||
// prod-clone queue.
|
||||
// NOTE: broad OR over the empty-state copy and the card/list region. If a
|
||||
// preview shows different empty copy, widen the regex — the structural intent
|
||||
// is "the review queue surface rendered, not an error/redirect".
|
||||
const queueRendered = page
|
||||
.getByText(/no images that need review|need review|review queue/i)
|
||||
.first()
|
||||
.or(page.locator('.mantine-Card-root, [class*="Card-root"]').first());
|
||||
await expect(queueRendered).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('mod can self-seed a post, report it, and action the report', async ({ page }) => {
|
||||
const token = uniqueToken('mod');
|
||||
|
||||
// Warm the context (cookies + an allowlisted Origin host) before hitting tRPC.
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
assertGatePassed(page, '/');
|
||||
|
||||
// 1) Self-seed an isolated post (title+detail carry the unique token). post.create
|
||||
// returns the created Post incl. id.
|
||||
const post = await trpcMutation<{ id: number }>(page.request, 'post.create', {
|
||||
title: token,
|
||||
detail: token,
|
||||
});
|
||||
expect(post?.id, 'post.create should return a numeric post id').toEqual(expect.any(Number));
|
||||
|
||||
// 2) Report that post. Minimal valid variant of the createReportInputSchema
|
||||
// discriminatedUnion is reason:'Spam' (reportSpamSchema → just baseDetailSchema).
|
||||
// type:'post' is ReportEntity.Post. createReport returns the row incl. id+status.
|
||||
// NOTE: 'Spam' / type:'post' verified in report.schema.ts:92 + report-helpers.ts:8.
|
||||
// If the CSRF/origin gate (createContext.ts) rejects this direct tRPC POST with
|
||||
// 403 live, the UI-driven fallback would be: open the post page → use the report
|
||||
// menu → then drive setStatus from /moderator/reports' row status Badge → Menu.
|
||||
const report = await trpcMutation<{ id: number; status: string }>(
|
||||
page.request,
|
||||
'report.create',
|
||||
{ type: 'post', id: post.id, reason: 'Spam', details: {} }
|
||||
);
|
||||
|
||||
// 3) Resolve the report id. Prefer it straight from report.create's return; fall
|
||||
// back to report.getAll (moderatorProcedure) matching on the seeded post id.
|
||||
let reportId: number | undefined = report?.id;
|
||||
if (typeof reportId !== 'number') {
|
||||
// getReportsSchema = page/limit (getAllQuerySchema) + type (ReportEntity).
|
||||
// Newest-first isn't guaranteed by default, so request a generous page and
|
||||
// match on the post relation's id (handler selects post.post.id).
|
||||
const all = await trpcQuery<{
|
||||
items: Array<{ id: number; post?: { post?: { id?: number } | null } | null }>;
|
||||
}>(page.request, 'report.getAll', { page: 1, limit: 100, type: 'post' });
|
||||
const mine = all?.items?.find((r) => r.post?.post?.id === post.id);
|
||||
reportId = mine?.id;
|
||||
}
|
||||
expect(reportId, 'should resolve the seeded report id').toEqual(expect.any(Number));
|
||||
|
||||
// 4) Action the report. setReportStatusSchema = { id, status } with status ∈
|
||||
// ReportStatus; 'Actioned' is a valid enum member.
|
||||
// NOTE: setStatus's handler returns void (controller setReportStatusHandler), so
|
||||
// we assert the mutation resolves without throwing — trpcMutation throws on any
|
||||
// tRPC error envelope or non-2xx, so a clean resolve == success.
|
||||
await expect(
|
||||
trpcMutation(page.request, 'report.setStatus', { id: reportId, status: 'Actioned' })
|
||||
).resolves.toBeDefined();
|
||||
|
||||
// Best-effort confirmation: re-query and assert our row now reads 'Actioned'.
|
||||
// Kept non-fatal-shaped (still an assertion, but only runs if getAll is reachable
|
||||
// for a mod, which it is) — the setStatus resolve above is the primary signal.
|
||||
const after = await trpcQuery<{
|
||||
items: Array<{ id: number; status?: string; post?: { post?: { id?: number } | null } | null }>;
|
||||
}>(page.request, 'report.getAll', { page: 1, limit: 100, type: 'post' });
|
||||
const updated = after?.items?.find((r) => r.id === reportId);
|
||||
if (updated) {
|
||||
expect(updated.status, 'seeded report should now be Actioned').toBe('Actioned');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { storageStatePath } from './preview-fixtures';
|
||||
import { trpcMutation, uniqueToken } from './preview-trpc';
|
||||
|
||||
/**
|
||||
* Mutation smoke: a normal (free) user self-seeds a Post and reports it, fully
|
||||
* API-driven via tRPC so the flow is isolated per run by a unique token (no
|
||||
* collision on the shared dev DB across concurrent previews, no flaky UI modal).
|
||||
*
|
||||
* Runs as `tester` (free member that PASSES the preview gate). Both mutations are
|
||||
* guardedProcedures; the ci-smoke `tester` fixture is seeded with onboarding=15 so
|
||||
* it clears `guardedProcedure`.
|
||||
*
|
||||
* Verified input shapes (against the worktree's schema files):
|
||||
* - post.create (src/server/routers/post.router.ts:105 .input(postCreateSchema);
|
||||
* src/server/schema/post.schema.ts:53 postCreateSchema) — guarded mutation, all
|
||||
* fields optional: { title: z.string().trim().nullish(), detail: z.string().nullish() }.
|
||||
* Returns the new post; createPostHandler (post.controller.ts:124) returns the
|
||||
* post object, so `.id` is a number.
|
||||
* - report.create (src/server/routers/report.router.ts:27 .input(createReportInputSchema);
|
||||
* src/server/schema/report.schema.ts:104 createReportInputSchema) — guarded mutation,
|
||||
* a discriminatedUnion('reason') over baseSchema { type: z.enum(ReportEntity);
|
||||
* id: z.number(); details } extended per reason.
|
||||
* * ReportEntity for a Post = 'post' (lowercase) — src/shared/utils/report-helpers.ts:8.
|
||||
* * reason TOSViolation = 'TOSViolation' — src/shared/utils/prisma/enums.ts:333.
|
||||
* * reportTOSViolationSchema (report.schema.ts:68) sets
|
||||
* details: reportTosViolationDetailsSchema, which REQUIRES `violation: z.string()`
|
||||
* (report.schema.ts:24). So `details: {}` would FAIL zod for a TOS report — we
|
||||
* pass `details: { violation: <token> }`. (`comment` is optional.)
|
||||
* createReportHandler (report.controller.ts:60) returns `result` (truthy on success).
|
||||
*/
|
||||
|
||||
test.describe('tester self-seeds a post and reports it (mutation flow)', () => {
|
||||
test.use({ storageState: storageStatePath('tester') });
|
||||
|
||||
test('post.create then report.create round-trips', async ({ page }) => {
|
||||
// Warm the request context against the preview origin so page.request shares
|
||||
// the auth cookie + a real navigated origin (the helper stamps Origin/Referer,
|
||||
// but navigating once is the safe baseline).
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const token = uniqueToken('report');
|
||||
|
||||
// 1. Self-seed a Post carrying the unique token in its free-text fields.
|
||||
const post = await trpcMutation<{ id: number } | null>(page.request, 'post.create', {
|
||||
title: token,
|
||||
detail: token,
|
||||
});
|
||||
expect(typeof post?.id, 'post.create should return a numeric post id').toBe('number');
|
||||
|
||||
// 2. Report that exact post for a TOS violation.
|
||||
// NOTE: ReportEntity.Post is the lowercase string 'post' (report-helpers.ts:8),
|
||||
// NOT 'Post'. reason 'TOSViolation' requires details.violation (a string), so we
|
||||
// supply the token there — `details: {}` alone would fail zod for this reason.
|
||||
const report = await trpcMutation(page.request, 'report.create', {
|
||||
type: 'post',
|
||||
id: post!.id,
|
||||
reason: 'TOSViolation',
|
||||
details: { violation: token },
|
||||
});
|
||||
|
||||
// createReportHandler returns the created report row (truthy) on success; the
|
||||
// tRPC helper already throws on any tRPC-level error, so reaching here means the
|
||||
// report was accepted.
|
||||
expect(report, 'report.create should resolve to a truthy result').toBeTruthy();
|
||||
});
|
||||
|
||||
// A UI report-affordance check was dropped: the report action lives behind an
|
||||
// entity action menu (not a top-level button), so a DOM-presence assertion on
|
||||
// /images is fragile. The API-driven flow above already proves report.create.
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Minimal tRPC client for the preview e2e tests, used to SELF-SEED uniquely-tagged
|
||||
* fixtures (e.g. a Post to report) so mutation tests don't collide on the shared
|
||||
* dev-DB across concurrent previews. NOT a test file (excluded from testMatch).
|
||||
*
|
||||
* civitai is tRPC v11 with the **superjson** transformer and batching (src/server/
|
||||
* trpc.ts, src/utils/trpc.ts). We mirror the real client's batched wire format:
|
||||
* POST /api/trpc/<proc>?batch=1 body {"0":{"json":<input>}}
|
||||
* GET /api/trpc/<proc>?batch=1&input=<urlenc({"0":{"json":<input>}})>
|
||||
* response: [{ result: { data: { json: <output> } } }]
|
||||
*
|
||||
* Two server-side gotchas the recon surfaced:
|
||||
* - CSRF/origin gate (src/server/createContext.ts): a cookie-authed request is
|
||||
* rejected unless its Origin/Referer host is allowlisted. The preview's own
|
||||
* host is (NEXTAUTH_URL = the preview URL), so we stamp Origin+Referer.
|
||||
* - guardedProcedure (post/report create) needs onboarding complete + not muted;
|
||||
* the ci-smoke fixtures are seeded with onboarding=15, so they pass.
|
||||
*
|
||||
* Pass `page.request` (which carries the test's storageState auth cookie) as the
|
||||
* `request` arg from inside a test that set `storageState`.
|
||||
*/
|
||||
|
||||
const PREVIEW_URL = process.env.PREVIEW_URL ?? '';
|
||||
|
||||
function csrfHeaders(): Record<string, string> {
|
||||
return { origin: PREVIEW_URL, referer: `${PREVIEW_URL}/` };
|
||||
}
|
||||
|
||||
function unwrap(body: unknown, proc: string): unknown {
|
||||
const entry = Array.isArray(body) ? (body as unknown[])[0] : body;
|
||||
const e = entry as { error?: unknown; result?: { data?: { json?: unknown } } };
|
||||
if (e?.error) {
|
||||
throw new Error(`tRPC ${proc} returned error: ${JSON.stringify(e.error).slice(0, 400)}`);
|
||||
}
|
||||
return e?.result?.data?.json;
|
||||
}
|
||||
|
||||
export async function trpcMutation<T = unknown>(
|
||||
request: APIRequestContext,
|
||||
proc: string,
|
||||
input: unknown
|
||||
): Promise<T> {
|
||||
const res = await request.post(`/api/trpc/${proc}?batch=1`, {
|
||||
headers: { 'content-type': 'application/json', ...csrfHeaders() },
|
||||
data: { '0': { json: input } },
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok()) {
|
||||
throw new Error(`tRPC mutation ${proc} -> HTTP ${res.status()}: ${JSON.stringify(body).slice(0, 400)}`);
|
||||
}
|
||||
return unwrap(body, proc) as T;
|
||||
}
|
||||
|
||||
export async function trpcQuery<T = unknown>(
|
||||
request: APIRequestContext,
|
||||
proc: string,
|
||||
input?: unknown
|
||||
): Promise<T> {
|
||||
const enc = encodeURIComponent(JSON.stringify({ '0': { json: input ?? {} } }));
|
||||
const res = await request.get(`/api/trpc/${proc}?batch=1&input=${enc}`, {
|
||||
headers: csrfHeaders(),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok()) {
|
||||
throw new Error(`tRPC query ${proc} -> HTTP ${res.status()}: ${JSON.stringify(body).slice(0, 400)}`);
|
||||
}
|
||||
return unwrap(body, proc) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-run unique tag, stamped into a seeded entity's free-text field so the test
|
||||
* can find exactly its own fixture (and concurrent previews never collide).
|
||||
*/
|
||||
export function uniqueToken(label: string): string {
|
||||
const rand = Math.random().toString(36).slice(2, 10);
|
||||
return `e2e-${label}-${Date.now()}-${rand}`;
|
||||
}
|
||||
Reference in New Issue
Block a user