mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
fix(tests): repair component-suite build death (sharp) + smoke external-listing OAuth payloads (#3317)
Two independent baseline breaks that fail on every PR preview.
COMPONENT (build death): two page-importing browser tests
(review-detail-page / review-queue-nav) drag the full server router graph
(server-side-helpers -> routers/index -> creator-shop.router ->
creator-shop.service -> `import sharp from 'sharp'`) into the browser
bundle. Next strips the server-only getServerSideProps graph from real
client builds; Vitest's browser build does not, so esbuild's optimizeDeps
scan follows the import into sharp and dies bundling its native
`require('../build/Release/sharp-*.node')` -- killing the WHOLE component
suite before any test runs. (The tests already `vi.mock`
server-side-helpers, but that is a runtime interception and can't stop the
build-time static scan.) Fix at the build level: alias `sharp` to a trivial
stub for the `component` project only (test/stubs/sharp.ts); the `unit`
project keeps real sharp. Verified: the sharp `.node` esbuild error is gone
and the affected tests build + execute.
Also unmasked by the build fix: review-detail-page.browser.test.tsx was
stale vs a page refactor -- the page now renders the review body via
`ReviewDetailView` (which owns the real trpc-backed `ReviewActionBar`),
not the old `OnsiteReviewModalBody` the test stubbed, so 2 tests threw
`trpc.useUtils is not a function`. Re-point the body stub to
`ReviewDetailView` (matches the test's stated intent -- assert shell
wiring, not re-run the action bar's own covered behaviour). 5/5 pass.
SMOKE (400 on submitExternalListing): the three preview-apps-external-*
specs predate #3227, which MERGED OAuth-connect into the single external-app
submit flow ("every external app IS an OAuth app"). `connectClientId` +
`requestedScopes` + `scopeJustifications` are now unconditionally required
for ALL external listings BY DESIGN (well-documented, well-reasoned in the
schema) -- this is intended, NOT a regression, so the source is left
untouched and the stale smoke payloads are updated. Each submit-bearing spec
now creates a throwaway owned OAuth client (`oauthClient.create`), passes its
id as `connectClientId` with an empty scope disclosure (`requestedScopes: 0`
+ `scopeJustifications: {}` -- 0 is a subset of any client ceiling; no scopes
=> no justifications), and deletes the client on cleanup (self-cleaning like
the draft + slug). Verified the new payload against the real zod schema
(passes; the old payload still fails); the end-to-end preview run
(connectClientId ownership is a service/DB check) is CI-to-confirm.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,12 +36,17 @@ vi.mock('~/providers/FeatureFlagsProvider', () => ({
|
||||
}));
|
||||
|
||||
// Stub the extracted body/title so we assert the SHELL wiring (props + gate),
|
||||
// not re-run the body's own covered behaviour.
|
||||
vi.mock('~/components/Apps/OnsiteReviewModal', () => ({
|
||||
OnsiteReviewModalBody: (props: { selection: any; onClose: () => void }) => {
|
||||
// not re-run the body's own covered behaviour. The page renders the review body
|
||||
// via `ReviewDetailView` (which owns the real approve/reject `ReviewActionBar`,
|
||||
// covered by its own tests); stub it so this shell test doesn't pull that live
|
||||
// trpc-backed action bar. The title still comes from `OnsiteReviewModal`.
|
||||
vi.mock('~/components/Apps/ReviewDetailView', () => ({
|
||||
ReviewDetailView: (props: { selection: any; onClose: () => void }) => {
|
||||
state.bodyProps.last = props;
|
||||
return <div data-testid="review-body">body:{props.selection.request.id}:{props.selection.mode}</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock('~/components/Apps/OnsiteReviewModal', () => ({
|
||||
OnsiteReviewModalTitle: ({ selection }: { selection: any }) => (
|
||||
<div data-testid="review-title">{selection.request.slug}</div>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Browser-mode (`component` project) stub for the native `sharp` module.
|
||||
*
|
||||
* A couple of `.browser.test.tsx` tests import a Next *page* to render its CLIENT
|
||||
* shell (e.g. `src/pages/apps/review/[publishRequestId].tsx`). Those pages also
|
||||
* declare `getServerSideProps`, whose top-level import pulls the full server
|
||||
* router graph (`server-side-helpers` → `routers/index` → `creator-shop.router`
|
||||
* → `creator-shop.service`), and that service `import sharp from 'sharp'`.
|
||||
*
|
||||
* In a real Next build the server-only `getServerSideProps` graph is stripped
|
||||
* from the client bundle, so `sharp` never reaches the browser. Vitest's
|
||||
* browser build has no such stripping, so esbuild's optimizeDeps scan follows
|
||||
* the import into `sharp` and dies trying to bundle its native
|
||||
* `require('../build/Release/sharp-*.node')` — killing the ENTIRE component
|
||||
* suite before any test runs (the tests themselves already `vi.mock`
|
||||
* server-side-helpers, but that is a RUNTIME interception that can't stop the
|
||||
* BUILD-time static scan).
|
||||
*
|
||||
* Aliasing `sharp` to this trivial stub for the `component` project only lets
|
||||
* esbuild bundle a no-op instead of the native binary. `sharp` is never actually
|
||||
* exercised in browser tests (server-side code never runs), so the stub is never
|
||||
* called. The `unit` (node) project keeps the real `sharp`.
|
||||
*/
|
||||
const notInBrowser = () => {
|
||||
throw new Error('sharp is not available in browser-mode (component) tests');
|
||||
};
|
||||
|
||||
export default new Proxy(function sharp() {
|
||||
return notInBrowser();
|
||||
} as unknown as Record<string, unknown>, {
|
||||
get: () => notInBrowser,
|
||||
});
|
||||
@@ -65,7 +65,7 @@ type SubmitResult = { listingId: string; publishRequestId: string; slug: string
|
||||
type PendingItem = { id: string; slug: string; appListingId: string | null };
|
||||
type PendingList = { items: PendingItem[]; nextCursor: string | null };
|
||||
|
||||
function submitInput(slug: string) {
|
||||
function submitInput(slug: string, connectClientId: string) {
|
||||
return {
|
||||
slug,
|
||||
name: 'CI Smoke — external approve/reject (P3a PR-b)',
|
||||
@@ -74,9 +74,38 @@ function submitInput(slug: string) {
|
||||
category: 'utility',
|
||||
contentRating: 'g',
|
||||
changelog: 'ci-smoke approve/reject',
|
||||
// W13 merged external+connect model (#3227): every external listing links the
|
||||
// caller's OWN OAuth client. This pure external-link app discloses NO scopes,
|
||||
// so an empty requested-scope mask (0) + empty justifications is the minimal
|
||||
// valid connect shape (0 ⊆ any client ceiling; no scopes → no justifications).
|
||||
connectClientId,
|
||||
requestedScopes: 0,
|
||||
scopeJustifications: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The W13 merged external+connect model requires every external listing to link
|
||||
* the caller's OWN OAuth client (existence/ownership/not-app-block checked in the
|
||||
* service). Create a throwaway client per test and delete it on cleanup —
|
||||
* self-cleaning like the draft listing + slug.
|
||||
*/
|
||||
async function createConnectClient(request: APIRequestContext): Promise<string> {
|
||||
const res = await trpcMutation<{ clientId: string }>(request, 'oauthClient.create', {
|
||||
name: 'CI Smoke — external-listing connect client',
|
||||
redirectUris: ['https://example.com/ci-smoke-oauth-callback'],
|
||||
});
|
||||
return res.clientId;
|
||||
}
|
||||
|
||||
async function deleteConnectClient(
|
||||
request: APIRequestContext,
|
||||
clientId: string | null
|
||||
): Promise<void> {
|
||||
if (!clientId) return;
|
||||
await trpcMutation(request, 'oauthClient.delete', { id: clientId }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Page the oldest-first pending queue to find our row by slug. */
|
||||
async function findPendingBySlug(
|
||||
request: APIRequestContext,
|
||||
@@ -119,13 +148,15 @@ test.describe('App Blocks P3a PR-b: off-site approve/reject (mod, self-cleaning)
|
||||
const request = page.request;
|
||||
|
||||
let publishRequestId: string | null = null;
|
||||
let clientId: string | null = null;
|
||||
try {
|
||||
await withdrawPendingForSlug(request, SLUG);
|
||||
clientId = await createConnectClient(request);
|
||||
|
||||
const result = await trpcMutation<SubmitResult>(
|
||||
request,
|
||||
'appListings.submitExternalListing',
|
||||
submitInput(SLUG)
|
||||
submitInput(SLUG, clientId)
|
||||
);
|
||||
publishRequestId = result.publishRequestId;
|
||||
expect(result.slug, 'slug echoes the submission').toBe(SLUG);
|
||||
@@ -152,6 +183,7 @@ test.describe('App Blocks P3a PR-b: off-site approve/reject (mod, self-cleaning)
|
||||
} else {
|
||||
await withdrawPendingForSlug(request, SLUG);
|
||||
}
|
||||
await deleteConnectClient(request, clientId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -163,13 +195,15 @@ test.describe('App Blocks P3a PR-b: off-site approve/reject (mod, self-cleaning)
|
||||
const request = page.request;
|
||||
|
||||
let publishRequestId: string | null = null;
|
||||
let clientId: string | null = null;
|
||||
try {
|
||||
await withdrawPendingForSlug(request, SLUG);
|
||||
clientId = await createConnectClient(request);
|
||||
|
||||
const result = await trpcMutation<SubmitResult>(
|
||||
request,
|
||||
'appListings.submitExternalListing',
|
||||
submitInput(SLUG)
|
||||
submitInput(SLUG, clientId)
|
||||
);
|
||||
publishRequestId = result.publishRequestId;
|
||||
|
||||
@@ -209,6 +243,7 @@ test.describe('App Blocks P3a PR-b: off-site approve/reject (mod, self-cleaning)
|
||||
} else {
|
||||
await withdrawPendingForSlug(request, SLUG);
|
||||
}
|
||||
await deleteConnectClient(request, clientId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ const EXTERNAL_URL = 'https://example.com/ci-smoke-external-delist';
|
||||
type SubmitResult = { listingId: string; publishRequestId: string; slug: string };
|
||||
type ModEventList = { items: Array<{ id: string; action: string }>; nextCursor: string | null };
|
||||
|
||||
function submitInput(slug: string) {
|
||||
function submitInput(slug: string, connectClientId: string) {
|
||||
return {
|
||||
slug,
|
||||
name: 'CI Smoke — external delist/purge (P3b PR3)',
|
||||
@@ -71,9 +71,38 @@ function submitInput(slug: string) {
|
||||
category: 'utility',
|
||||
contentRating: 'g',
|
||||
changelog: 'ci-smoke delist/purge',
|
||||
// W13 merged external+connect model (#3227): every external listing links the
|
||||
// caller's OWN OAuth client. This pure external-link app discloses NO scopes,
|
||||
// so an empty requested-scope mask (0) + empty justifications is the minimal
|
||||
// valid connect shape (0 ⊆ any client ceiling; no scopes → no justifications).
|
||||
connectClientId,
|
||||
requestedScopes: 0,
|
||||
scopeJustifications: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The W13 merged external+connect model requires every external listing to link
|
||||
* the caller's OWN OAuth client (existence/ownership/not-app-block checked in the
|
||||
* service). Create a throwaway client per test and delete it on cleanup —
|
||||
* self-cleaning like the draft listing + slug.
|
||||
*/
|
||||
async function createConnectClient(request: APIRequestContext): Promise<string> {
|
||||
const res = await trpcMutation<{ clientId: string }>(request, 'oauthClient.create', {
|
||||
name: 'CI Smoke — external-listing connect client',
|
||||
redirectUris: ['https://example.com/ci-smoke-oauth-callback'],
|
||||
});
|
||||
return res.clientId;
|
||||
}
|
||||
|
||||
async function deleteConnectClient(
|
||||
request: APIRequestContext,
|
||||
clientId: string | null
|
||||
): Promise<void> {
|
||||
if (!clientId) return;
|
||||
await trpcMutation(request, 'oauthClient.delete', { id: clientId }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Best-effort: flip any leftover pending request for this preview's request id. */
|
||||
async function withdrawQuietly(request: APIRequestContext, publishRequestId: string | null) {
|
||||
if (!publishRequestId) return;
|
||||
@@ -103,11 +132,13 @@ test.describe('App Blocks P3b PR3: off-site moderation actions (mod, self-cleani
|
||||
const request = page.request;
|
||||
|
||||
let publishRequestId: string | null = null;
|
||||
let clientId: string | null = null;
|
||||
try {
|
||||
clientId = await createConnectClient(request);
|
||||
const result = await trpcMutation<SubmitResult>(
|
||||
request,
|
||||
'appListings.submitExternalListing',
|
||||
submitInput(SLUG)
|
||||
submitInput(SLUG, clientId)
|
||||
);
|
||||
publishRequestId = result.publishRequestId;
|
||||
const listingId = result.listingId;
|
||||
@@ -159,6 +190,7 @@ test.describe('App Blocks P3b PR3: off-site moderation actions (mod, self-cleani
|
||||
expect(history.items, 'a guarded/rejected mutation writes no audit event').toHaveLength(0);
|
||||
} finally {
|
||||
await withdrawQuietly(request, publishRequestId);
|
||||
await deleteConnectClient(request, clientId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -168,11 +200,13 @@ test.describe('App Blocks P3b PR3: off-site moderation actions (mod, self-cleani
|
||||
const request = page.request;
|
||||
|
||||
let publishRequestId: string | null = null;
|
||||
let clientId: string | null = null;
|
||||
try {
|
||||
clientId = await createConnectClient(request);
|
||||
const result = await trpcMutation<SubmitResult>(
|
||||
request,
|
||||
'appListings.submitExternalListing',
|
||||
submitInput(SLUG)
|
||||
submitInput(SLUG, clientId)
|
||||
);
|
||||
publishRequestId = result.publishRequestId;
|
||||
const listingId = result.listingId;
|
||||
@@ -198,6 +232,7 @@ test.describe('App Blocks P3b PR3: off-site moderation actions (mod, self-cleani
|
||||
// The submit's publish request is now an orphan (appListingId SET NULL by the
|
||||
// purge). Flip it out of the pending queue so nothing lingers.
|
||||
await withdrawQuietly(request, publishRequestId);
|
||||
await deleteConnectClient(request, clientId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,15 +67,46 @@ type PendingItem = {
|
||||
};
|
||||
type PendingList = { items: PendingItem[]; nextCursor: string | null };
|
||||
|
||||
const submitInput = {
|
||||
slug: SLUG,
|
||||
name: 'CI Smoke — external app (P3a)',
|
||||
externalUrl: EXTERNAL_URL,
|
||||
tagline: 'a pure external-link app',
|
||||
category: 'utility',
|
||||
contentRating: 'g',
|
||||
changelog: 'ci-smoke submit',
|
||||
};
|
||||
function submitInput(connectClientId: string) {
|
||||
return {
|
||||
slug: SLUG,
|
||||
name: 'CI Smoke — external app (P3a)',
|
||||
externalUrl: EXTERNAL_URL,
|
||||
tagline: 'a pure external-link app',
|
||||
category: 'utility',
|
||||
contentRating: 'g',
|
||||
changelog: 'ci-smoke submit',
|
||||
// W13 merged external+connect model (#3227): every external listing links the
|
||||
// caller's OWN OAuth client. This pure external-link app discloses NO scopes,
|
||||
// so an empty requested-scope mask (0) + empty justifications is the minimal
|
||||
// valid connect shape (0 ⊆ any client ceiling; no scopes → no justifications).
|
||||
connectClientId,
|
||||
requestedScopes: 0,
|
||||
scopeJustifications: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The W13 merged external+connect model requires every external listing to link
|
||||
* the caller's OWN OAuth client (existence/ownership/not-app-block checked in the
|
||||
* service). Create a throwaway client per test and delete it on cleanup —
|
||||
* self-cleaning like the draft listing + slug.
|
||||
*/
|
||||
async function createConnectClient(request: APIRequestContext): Promise<string> {
|
||||
const res = await trpcMutation<{ clientId: string }>(request, 'oauthClient.create', {
|
||||
name: 'CI Smoke — external-listing connect client',
|
||||
redirectUris: ['https://example.com/ci-smoke-oauth-callback'],
|
||||
});
|
||||
return res.clientId;
|
||||
}
|
||||
|
||||
async function deleteConnectClient(
|
||||
request: APIRequestContext,
|
||||
clientId: string | null
|
||||
): Promise<void> {
|
||||
if (!clientId) return;
|
||||
await trpcMutation(request, 'oauthClient.delete', { id: clientId }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Page the oldest-first pending queue to find our row by slug (it's the newest). */
|
||||
async function findPendingBySlug(
|
||||
@@ -129,15 +160,17 @@ test.describe('App Blocks P3a: off-site submit → mod queue → withdraw (mod,
|
||||
});
|
||||
|
||||
let publishRequestId: string | null = null;
|
||||
let clientId: string | null = null;
|
||||
try {
|
||||
// Pre-clean any leftover pending row for this preview's slug (prior crashed run).
|
||||
await withdrawPendingForSlug(authorRequest, modRequest, SLUG);
|
||||
clientId = await createConnectClient(authorRequest);
|
||||
|
||||
// SUBMIT as mod (author via the app-blocks-author mod floor) — creates a draft AppListing + pending request.
|
||||
const result = await trpcMutation<SubmitResult>(
|
||||
authorRequest,
|
||||
'appListings.submitExternalListing',
|
||||
submitInput
|
||||
submitInput(clientId)
|
||||
);
|
||||
publishRequestId = result.publishRequestId;
|
||||
expect(typeof result.publishRequestId, 'submit returns a publishRequestId').toBe('string');
|
||||
@@ -170,6 +203,7 @@ test.describe('App Blocks P3a: off-site submit → mod queue → withdraw (mod,
|
||||
} else {
|
||||
await withdrawPendingForSlug(authorRequest, modRequest, SLUG);
|
||||
}
|
||||
await deleteConnectClient(authorRequest, clientId);
|
||||
await modRequest.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
+16
-1
@@ -36,6 +36,21 @@ const civitaiAlias = civitaiWorkspacePkgs.flatMap((p) => {
|
||||
|
||||
const alias = [{ find: '~', replacement: path.resolve(__dirname, './src') }, ...civitaiAlias];
|
||||
|
||||
// Browser-mode (`component` project) alias: stub the native `sharp` module.
|
||||
// A few `.browser.test.tsx` tests import a Next *page* to render its client shell;
|
||||
// the page's `getServerSideProps` transitively pulls a server service that does
|
||||
// `import sharp from 'sharp'`. Next strips that server-only graph from real client
|
||||
// builds, but Vitest's browser build does not — so esbuild's optimizeDeps scan
|
||||
// follows the import into sharp and dies bundling its native
|
||||
// `require('../build/Release/sharp-*.node')`, killing the WHOLE component suite
|
||||
// before any test runs. (The tests `vi.mock` server-side-helpers, but that is a
|
||||
// runtime interception and can't stop the build-time static scan.) The `unit`
|
||||
// (node) project keeps the real sharp. Must precede the `~` entry so it wins.
|
||||
const componentAlias = [
|
||||
{ find: /^sharp$/, replacement: path.resolve(__dirname, 'test/stubs/sharp.ts') },
|
||||
...alias,
|
||||
];
|
||||
|
||||
// Two Vitest projects sharing one config/runner:
|
||||
// - `unit` = the existing node-env suite, unchanged.
|
||||
// - `component` = browser-mode (real Chromium via Playwright) for React
|
||||
@@ -79,7 +94,7 @@ export default defineConfig({
|
||||
// components (e.g. @mantine/dropzone) in browser mode, notably on a COLD
|
||||
// optimizeDeps cache (fresh CI runs). Canonical fix; protects every
|
||||
// component test from this class of dual-React crash.
|
||||
resolve: { alias, dedupe: ['react', 'react-dom'] },
|
||||
resolve: { alias: componentAlias, dedupe: ['react', 'react-dom'] },
|
||||
// Pre-bundle deps the component setup mocks/imports so Vitest doesn't
|
||||
// discover them mid-run and trigger a "Vite unexpectedly reloaded a
|
||||
// test" warning (a flake vector).
|
||||
|
||||
Reference in New Issue
Block a user