diff --git a/src/tests/pages/apps/review/review-detail-page.browser.test.tsx b/src/tests/pages/apps/review/review-detail-page.browser.test.tsx index a8ccdd5dd6..b3557eb6a0 100644 --- a/src/tests/pages/apps/review/review-detail-page.browser.test.tsx +++ b/src/tests/pages/apps/review/review-detail-page.browser.test.tsx @@ -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
body:{props.selection.request.id}:{props.selection.mode}
; }, +})); +vi.mock('~/components/Apps/OnsiteReviewModal', () => ({ OnsiteReviewModalTitle: ({ selection }: { selection: any }) => (
{selection.request.slug}
), diff --git a/test/stubs/sharp.ts b/test/stubs/sharp.ts new file mode 100644 index 0000000000..197b2536f8 --- /dev/null +++ b/test/stubs/sharp.ts @@ -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, { + get: () => notInBrowser, +}); diff --git a/tests/preview-apps-external-approve.spec.ts b/tests/preview-apps-external-approve.spec.ts index 1d0ba6f3ee..7a30192e44 100644 --- a/tests/preview-apps-external-approve.spec.ts +++ b/tests/preview-apps-external-approve.spec.ts @@ -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 { + 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 { + 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( 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( 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); } }); }); diff --git a/tests/preview-apps-external-delist.spec.ts b/tests/preview-apps-external-delist.spec.ts index c16e568f30..ae6e6b6abc 100644 --- a/tests/preview-apps-external-delist.spec.ts +++ b/tests/preview-apps-external-delist.spec.ts @@ -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 { + 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 { + 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( 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( 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); } }); }); diff --git a/tests/preview-apps-external-submit.spec.ts b/tests/preview-apps-external-submit.spec.ts index 35d2c346b8..12409c8885 100644 --- a/tests/preview-apps-external-submit.spec.ts +++ b/tests/preview-apps-external-submit.spec.ts @@ -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 { + 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 { + 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( 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(); } }); diff --git a/vitest.config.mts b/vitest.config.mts index fe1e051f6c..dfd973468c 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -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).