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:
Zachary Lowden
2026-07-23 15:44:05 -05:00
committed by GitHub
parent c3d924fc20
commit 76706c367d
6 changed files with 176 additions and 20 deletions
@@ -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>
),
+32
View File
@@ -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,
});
+38 -3
View File
@@ -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);
}
});
});
+38 -3
View File
@@ -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);
}
});
});
+44 -10
View File
@@ -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
View File
@@ -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).