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).