From 16fba0accc5c3b6dfe53fa563dfb9fb37527f356 Mon Sep 17 00:00:00 2001 From: Brad Hefta-Gaub Date: Wed, 3 Jun 2026 15:43:04 -0700 Subject: [PATCH 1/4] Merge pull request #398 from Artificer-Innovations/fix/deps-react-router-6.30.4-open-redirect fix(deps): upgrade react-router-dom to 6.30.4 (CVE-2026-40181) --- apps/web/package.json | 2 +- package-lock.json | 32 ++++++++++++++++---------------- packages/admin/package.json | 2 +- packages/articles/package.json | 4 ++-- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 1f24bc39..3a418912 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,7 +31,7 @@ "lucide-react": "^0.460.0", "react": "18.2.0", "react-dom": "18.2.0", - "react-router-dom": "^6.30.3", + "react-router-dom": "^6.30.4", "zod": "^3.22.0" }, "devDependencies": { diff --git a/package-lock.json b/package-lock.json index 05c85b4d..81156c13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -134,7 +134,7 @@ "lucide-react": "^0.460.0", "react": "18.2.0", "react-dom": "18.2.0", - "react-router-dom": "^6.30.3", + "react-router-dom": "^6.30.4", "zod": "^3.22.0" }, "devDependencies": { @@ -9653,9 +9653,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -25246,12 +25246,12 @@ } }, "node_modules/react-router": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2" + "@remix-run/router": "1.23.3" }, "engines": { "node": ">=14.0.0" @@ -25261,13 +25261,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", - "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2", - "react-router": "6.30.3" + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" }, "engines": { "node": ">=14.0.0" @@ -29719,7 +29719,7 @@ "jsdom": "^26.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.30.3", + "react-router-dom": "^6.30.4", "typescript": "^5.3.0", "vitest": "^4.1.8" }, @@ -29769,7 +29769,7 @@ "prettier": "^3.1.0", "react": "18.2.0", "react-dom": "18.2.0", - "react-router-dom": "^6.30.3", + "react-router-dom": "^6.30.4", "tsx": "^4.19.0", "typescript": "^5.3.0", "vitest": "^4.1.8", @@ -29778,7 +29778,7 @@ "peerDependencies": { "react": "18.2.0", "react-dom": "18.2.0", - "react-router-dom": "^6.30.3" + "react-router-dom": "^6.30.4" }, "peerDependenciesMeta": { "react-dom": { diff --git a/packages/admin/package.json b/packages/admin/package.json index 690a9a91..59370f8f 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -37,7 +37,7 @@ "jsdom": "^26.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.30.3", + "react-router-dom": "^6.30.4", "typescript": "^5.3.0", "@vitest/coverage-v8": "^4.1.8", "vitest": "^4.1.8" diff --git a/packages/articles/package.json b/packages/articles/package.json index 4f218551..77f42b09 100644 --- a/packages/articles/package.json +++ b/packages/articles/package.json @@ -27,7 +27,7 @@ "peerDependencies": { "react": "18.2.0", "react-dom": "18.2.0", - "react-router-dom": "^6.30.3" + "react-router-dom": "^6.30.4" }, "peerDependenciesMeta": { "react-dom": { @@ -50,7 +50,7 @@ "prettier": "^3.1.0", "react": "18.2.0", "react-dom": "18.2.0", - "react-router-dom": "^6.30.3", + "react-router-dom": "^6.30.4", "tsx": "^4.19.0", "typescript": "^5.3.0", "vitest": "^4.1.8", From f6478769b8c63a5cbc8c51afdaef5b5b1a69744a Mon Sep 17 00:00:00 2001 From: Brad Hefta-Gaub Date: Wed, 3 Jun 2026 16:12:12 -0700 Subject: [PATCH 2/4] Fix AI code quality findings in setup, help, and mobile profile. (#399) Remove stale JSDoc, use path-based main-module detection, parse help markdown synchronously, and scope lazy ProfileEditor state to the component. --- apps/mobile/src/screens/ProfileScreen.tsx | 17 ++++++++--------- packages/help/scripts/build-help.ts | 2 +- scripts/setup-full.mjs | 17 ++++++++--------- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/screens/ProfileScreen.tsx b/apps/mobile/src/screens/ProfileScreen.tsx index 0d0ce25a..1d251284 100644 --- a/apps/mobile/src/screens/ProfileScreen.tsx +++ b/apps/mobile/src/screens/ProfileScreen.tsx @@ -24,7 +24,6 @@ import { ProfileStats } from '@beakerstack/shared/components/profile/ProfileStat // @ts-ignore - Dynamic imports are supported by Metro, TypeScript error is a false positive import type { ProfileEditorProps } from '@beakerstack/shared/components/profile/ProfileEditor.native'; import { loadProfileEditorModule } from './profileEditorLoader'; -let ProfileEditor: React.ComponentType | null = null; type RootStackParamList = { Home: undefined; @@ -78,28 +77,28 @@ export default function ProfileScreen({ navigation }: Props) { } // Render protected content if authenticated - return ; + return ; } -function ProfileScreenContent({ navigation: _navigation }: Props) { +function ProfileScreenContent() { const [isEditing, setIsEditing] = useState(false); - const [componentsLoaded, setComponentsLoaded] = useState(false); + const [ProfileEditor, setProfileEditor] = + useState | null>(null); const auth = useAuthContext(); const profile = useProfileContext(); // Lazy load ProfileEditor only when editing useEffect(() => { - if (isEditing && !componentsLoaded) { + if (isEditing && !ProfileEditor) { loadProfileEditorModule() .then(module => { - ProfileEditor = module.ProfileEditor; - setComponentsLoaded(true); + setProfileEditor(() => module.ProfileEditor); }) .catch(err => { Logger.error('[ProfileScreen] Failed to load ProfileEditor:', err); }); } - }, [isEditing, componentsLoaded]); + }, [isEditing, ProfileEditor]); return ( @@ -158,7 +157,7 @@ function ProfileScreenContent({ navigation: _navigation }: Props) { {isEditing && ( - {componentsLoaded && ProfileEditor ? ( + {ProfileEditor ? ( { // Refresh profile data after successful update diff --git a/packages/help/scripts/build-help.ts b/packages/help/scripts/build-help.ts index 9cb61734..fced05fe 100644 --- a/packages/help/scripts/build-help.ts +++ b/packages/help/scripts/build-help.ts @@ -111,7 +111,7 @@ export function parseHelpMarkdown(raw: string, legal: Legal): HelpContent { sections: sectionBlocks.map(({ title: sectionTitle, body }) => ({ id: slugify(sectionTitle), title: sectionTitle, - html: sanitizeHelpHtml(marked.parse(body) as string), + html: sanitizeHelpHtml(marked.parser(marked.lexer(body))), searchText: markdownToPlainText(body), })), }; diff --git a/scripts/setup-full.mjs b/scripts/setup-full.mjs index 4661d598..68b5f35b 100644 --- a/scripts/setup-full.mjs +++ b/scripts/setup-full.mjs @@ -87,7 +87,10 @@ import { runCmd, } from './lib/setup-supabase.mjs'; import { buildNameVariants } from './rename-project.mjs'; -import { detectRepoIdentity, readBrandingSource } from './lib/detect-repo-identity.mjs'; +import { + detectRepoIdentity, + readBrandingSource, +} from './lib/detect-repo-identity.mjs'; import { ACM_CLOUDFRONT_REGION, discoverIssuedCertsCoveringApexWildcard, @@ -457,7 +460,9 @@ export function slugBaseFromAppConfigText(text) { * @returns {Promise} lowercase alnum segment, e.g. poststack or beakerstack */ async function readSupabaseProjectSlugBase() { - const brandingResult = slugBaseFromBrandingText(await readBrandingSource(REPO_ROOT)); + const brandingResult = slugBaseFromBrandingText( + await readBrandingSource(REPO_ROOT) + ); if (brandingResult) return brandingResult; try { const appConfigResult = slugBaseFromAppConfigText( @@ -1208,9 +1213,6 @@ async function resolveAwsPreflightConflictsInteractive( } } -/** - * @param {CliFlags} flags - */ function ghAuthOk() { const r = spawnSync('gh', ['auth', 'status'], { cwd: REPO_ROOT, @@ -1220,9 +1222,6 @@ function ghAuthOk() { return r.status === 0; } -/** - * @param {CliFlags} flags - */ function easWhoamiOk() { const r = spawnSync('npx', ['--yes', 'eas-cli', 'whoami'], { cwd: MOBILE_DIR, @@ -2894,7 +2893,7 @@ async function main() { } } -if (import.meta.url === url.pathToFileURL(process.argv[1] || '').href) { +if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { main().catch(err => { console.error( '[setup] Fatal:', From 15e4828066bb072957b3111cfcbe2216d8529cbe Mon Sep 17 00:00:00 2001 From: Brad Hefta-Gaub Date: Wed, 3 Jun 2026 16:50:29 -0700 Subject: [PATCH 3/4] fix: restore 100% package coverage after Vitest 4 upgrade (#400) * Restore 100% package coverage after Vitest 4 upgrade. Vitest 4's v8 coverage counts branch paths more strictly; add targeted tests for async cancellation, dynamic import fallbacks, and edge-case branches across billing, admin, connections, observability, and shared packages. * Address Copilot review feedback on coverage tests. Use a call-through setTimeout spy to exercise the debounce cancellation guard without stubbing clearTimeout, and assert no unmounted-component warnings when Sentry resolves after ObservabilityProvider unmounts. --- .../components/AdminDetailDrawer.web.test.tsx | 15 +++ .../src/components/AdminTable.web.test.tsx | 17 +++ packages/billing/src/BillingProvider.test.tsx | 103 +++++++++++++++++- .../SubscriptionStatusBadge.native.test.tsx | 9 ++ .../src/hooks/useBillingState.test.tsx | 9 ++ .../billing/src/hooks/useInvoices.test.tsx | 37 +++++++ packages/billing/src/hooks/useUsage.ts | 4 +- .../utils/parseBillingFunctionError.test.ts | 8 ++ .../src/hooks/useConnections.test.tsx | 51 +++++++++ .../src/__tests__/kitClient.test.ts | 18 +++ .../ObservabilityProvider.web.test.tsx | 36 ++++++ .../sentryDynamicImport.coverage.test.ts | 72 ++++++++++++ .../components/navigation/UserMenu.test.tsx | 17 +++ .../waitlist/src/hooks/useSignupMode.test.tsx | 23 ++++ 14 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 packages/observability/src/__tests__/sentryDynamicImport.coverage.test.ts diff --git a/packages/admin/src/components/AdminDetailDrawer.web.test.tsx b/packages/admin/src/components/AdminDetailDrawer.web.test.tsx index 6d29c052..faadee70 100644 --- a/packages/admin/src/components/AdminDetailDrawer.web.test.tsx +++ b/packages/admin/src/components/AdminDetailDrawer.web.test.tsx @@ -307,4 +307,19 @@ describe('AdminDetailDrawer', () => { await user.click(screen.getByLabelText('Close')); expect(onClose).toHaveBeenCalledTimes(1); }); + + it('does not trap Tab when focus is not on the last element', () => { + render( + + + + + ); + const notes = screen.getByLabelText('Notes'); + notes.focus(); + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }) + ); + expect(document.activeElement).toBe(notes); + }); }); diff --git a/packages/admin/src/components/AdminTable.web.test.tsx b/packages/admin/src/components/AdminTable.web.test.tsx index 51e088ae..a38b01e6 100644 --- a/packages/admin/src/components/AdminTable.web.test.tsx +++ b/packages/admin/src/components/AdminTable.web.test.tsx @@ -123,4 +123,21 @@ describe('AdminTable', () => { await user.click(screen.getByText('Ada')); expect(onRowClick).toHaveBeenCalledWith({ id: '1', name: 'Ada' }); }); + + it('ignores non-activation keys on clickable rows', async () => { + const onRowClick = vi.fn(); + const user = userEvent.setup(); + render( + r.id} + onRowClick={onRowClick} + /> + ); + const row = screen.getByRole('button', { name: 'View details for 1' }); + row.focus(); + await user.keyboard('{ArrowDown}'); + expect(onRowClick).not.toHaveBeenCalled(); + }); }); diff --git a/packages/billing/src/BillingProvider.test.tsx b/packages/billing/src/BillingProvider.test.tsx index 18c0b0e5..41eed02b 100644 --- a/packages/billing/src/BillingProvider.test.tsx +++ b/packages/billing/src/BillingProvider.test.tsx @@ -569,7 +569,7 @@ describe('BillingProvider', () => { let resolveSession: (value: { data: { session: { user: { id: string } } | null }; }) => void = () => {}; - auth.getSession.mockImplementation( + auth.getSession.mockImplementationOnce( () => new Promise(resolve => { resolveSession = resolve; @@ -585,4 +585,105 @@ describe('BillingProvider', () => { await Promise.resolve(); expect(db.maybeSingle).not.toHaveBeenCalled(); }); + + it('ignores plan query result after unmount', async () => { + let resolvePlan: (value: { + data: ReturnType | null; + error: null; + }) => void = () => {}; + auth.state.session = { user: { id: 'u-plan-unmount' } }; + const row = { + ...testSubscription(), + user_id: 'u-plan-unmount', + plan_id: 'plan_free', + }; + db.maybeSingle.mockResolvedValue({ data: row, error: null }); + db.planMaybeSingle.mockImplementationOnce( + () => + new Promise(resolve => { + resolvePlan = resolve; + }) + ); + const { unmount } = render( + + + + ); + await waitFor(() => expect(db.planMaybeSingle).toHaveBeenCalled()); + unmount(); + resolvePlan({ data: testPlan({ display_name: 'Late plan' }), error: null }); + await Promise.resolve(); + }); + + it('ignores ensure_billing_subscription result after unmount', async () => { + let resolveRpc: (value: { error: null }) => void = () => {}; + auth.state.session = { user: { id: 'u-rpc-unmount' } }; + db.rpc.mockImplementationOnce( + () => + new Promise(resolve => { + resolveRpc = resolve; + }) + ); + const { unmount } = render( + + + + ); + await waitFor(() => + expect(screen.getByTestId('uid').textContent).toBe('u-rpc-unmount') + ); + await waitFor(() => expect(db.rpc).toHaveBeenCalled()); + unmount(); + resolveRpc({ error: null }); + await Promise.resolve(); + }); + + it('ignores plan query errors after unmount', async () => { + let rejectPlan: (error: Error) => void = () => {}; + auth.state.session = { user: { id: 'u-plan-err-unmount' } }; + const row = { + ...testSubscription(), + user_id: 'u-plan-err-unmount', + plan_id: 'plan_free', + }; + db.maybeSingle.mockResolvedValue({ data: row, error: null }); + db.planMaybeSingle.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectPlan = reject; + }) + ); + const { unmount } = render( + + + + ); + await waitFor(() => expect(db.planMaybeSingle).toHaveBeenCalled()); + unmount(); + rejectPlan(new Error('late plan fail')); + await Promise.resolve(); + }); + + it('ignores ensure_billing_subscription errors after unmount', async () => { + let rejectRpc: (error: Error) => void = () => {}; + auth.state.session = { user: { id: 'u-rpc-err-unmount' } }; + db.rpc.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectRpc = reject; + }) + ); + const { unmount } = render( + + + + ); + await waitFor(() => + expect(screen.getByTestId('uid').textContent).toBe('u-rpc-err-unmount') + ); + await waitFor(() => expect(db.rpc).toHaveBeenCalled()); + unmount(); + rejectRpc(new Error('late rpc fail')); + await Promise.resolve(); + }); }); diff --git a/packages/billing/src/components/SubscriptionStatusBadge.native.test.tsx b/packages/billing/src/components/SubscriptionStatusBadge.native.test.tsx index ba4dec50..fed927d2 100644 --- a/packages/billing/src/components/SubscriptionStatusBadge.native.test.tsx +++ b/packages/billing/src/components/SubscriptionStatusBadge.native.test.tsx @@ -72,6 +72,15 @@ describe('SubscriptionStatusBadge (native)', () => { expect(screen.getAllByLabelText('Active').length).toBeGreaterThan(0); }); + it('keeps raw status label for unrecognized statuses', () => { + render( + + ); + expect(screen.getByLabelText('canceled')).toBeInTheDocument(); + }); + it('shows em dash in cancelling label when period end is missing', () => { render( { expect(result.current.kind).toBe('trialing'); }); + it('classifies trialing without trial_end as trialing', () => { + hp.subscription = testSubscription({ + status: 'trialing', + trial_end: null, + }); + const { result } = renderHook(() => useBillingState()); + expect(result.current.kind).toBe('trialing'); + }); + it('classifies cancel_at_period_end without pending target as cancelled_pending', () => { hp.subscription = testSubscription({ status: 'active', diff --git a/packages/billing/src/hooks/useInvoices.test.tsx b/packages/billing/src/hooks/useInvoices.test.tsx index 9070bc8b..f5bbb3fc 100644 --- a/packages/billing/src/hooks/useInvoices.test.tsx +++ b/packages/billing/src/hooks/useInvoices.test.tsx @@ -190,4 +190,41 @@ describe('useInvoices', () => { expect(result.current.items).toEqual([]); expect(result.current.hasMore).toBe(false); }); + + it('preserves existing items when loadMore fails', async () => { + const inv: BillingInvoiceRow = { + id: 'inv1', + user_id: 'user-1', + stripe_invoice_id: 'in_1', + stripe_customer_id: 'cus', + stripe_subscription_id: null, + amount_due: 100, + amount_paid: 0, + currency: 'usd', + status: 'open', + description: null, + hosted_invoice_url: null, + invoice_pdf_url: null, + period_start: null, + period_end: null, + created_at: '2026-01-02T00:00:00.000Z', + finalized_at: null, + paid_at: null, + }; + range + .mockResolvedValueOnce({ + data: Array.from({ length: 20 }, (_, i) => ({ ...inv, id: `i${i}` })), + error: null, + }) + .mockResolvedValueOnce({ + data: null, + error: new Error('page two failed'), + }); + const { result } = renderHook(() => useInvoices({ pageSize: 20 })); + await waitFor(() => expect(result.current.items.length).toBe(20)); + await result.current.loadMore(); + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.items).toHaveLength(20); + expect(result.current.hasMore).toBe(false); + }); }); diff --git a/packages/billing/src/hooks/useUsage.ts b/packages/billing/src/hooks/useUsage.ts index ecbf0e9d..8b0da7ae 100644 --- a/packages/billing/src/hooks/useUsage.ts +++ b/packages/billing/src/hooks/useUsage.ts @@ -33,7 +33,7 @@ export function useUsage< const [snap, setSnap] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const fetchUsageRef = useRef<() => Promise>(async () => {}); + const fetchUsageRef = useRef<(() => Promise) | undefined>(undefined); const fetchUsage = useCallback(async () => { setLoading(true); @@ -112,7 +112,7 @@ export function useUsage< row?.product_id === config.productId && row?.event_type === meterKey ) { - void fetchUsageRef.current(); + void fetchUsageRef.current?.(); } } ).subscribe(); diff --git a/packages/billing/src/utils/parseBillingFunctionError.test.ts b/packages/billing/src/utils/parseBillingFunctionError.test.ts index df550ac5..1e3d16a8 100644 --- a/packages/billing/src/utils/parseBillingFunctionError.test.ts +++ b/packages/billing/src/utils/parseBillingFunctionError.test.ts @@ -34,4 +34,12 @@ describe('parseBillingFunctionError', () => { expect(err.kind).toBe('stripe'); expect(err.message).toBe('Billing request failed'); }); + + it('ignores whitespace-only error strings in the body', () => { + const err = parseBillingFunctionError( + { error: ' ' }, + new Error('invoke') + ); + expect(err.message).toContain('invoke'); + }); }); diff --git a/packages/connections/src/hooks/useConnections.test.tsx b/packages/connections/src/hooks/useConnections.test.tsx index e93bea70..e314c562 100644 --- a/packages/connections/src/hooks/useConnections.test.tsx +++ b/packages/connections/src/hooks/useConnections.test.tsx @@ -249,4 +249,55 @@ describe('useConnections', () => { expect(channel.on).not.toHaveBeenCalled(); expect(channel.subscribe).not.toHaveBeenCalled(); }); + + it('refresh reloads connections', async () => { + let calls = 0; + const supabase = createMockSupabase(async () => { + calls += 1; + return { data: calls === 1 ? [row] : [], error: null }; + }); + const { result } = renderHook(() => + useConnections({ supabase, userId: UUID_A }) + ); + await waitFor(() => expect(result.current.rows).toHaveLength(1)); + await result.current.refresh(); + await waitFor(() => expect(result.current.rows).toHaveLength(0)); + expect(calls).toBe(2); + }); + + it('ignores debounced reload after unmount', async () => { + let debouncedCallback: (() => void) | undefined; + const originalSetTimeout = globalThis.setTimeout.bind(globalThis); + const setTimeoutSpy = vi + .spyOn(globalThis, 'setTimeout') + .mockImplementation((handler, delay, ...args) => { + if (typeof handler === 'function' && delay === 400) { + debouncedCallback = handler as () => void; + } + return originalSetTimeout(handler, delay, ...args); + }); + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); + + try { + let calls = 0; + const supabase = createMockSupabase(async () => { + calls += 1; + return { data: [row], error: null }; + }); + const { unmount } = renderHook(() => + useConnections({ supabase, userId: UUID_A }) + ); + await waitFor(() => expect(calls).toBe(1)); + emitConnectionChange(supabase); + expect(debouncedCallback).toBeDefined(); + unmount(); + debouncedCallback?.(); + await Promise.resolve(); + expect(calls).toBe(1); + expect(clearTimeoutSpy).toHaveBeenCalled(); + } finally { + setTimeoutSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); + } + }); }); diff --git a/packages/marketing-email/src/__tests__/kitClient.test.ts b/packages/marketing-email/src/__tests__/kitClient.test.ts index e2a12455..f2cda7ac 100644 --- a/packages/marketing-email/src/__tests__/kitClient.test.ts +++ b/packages/marketing-email/src/__tests__/kitClient.test.ts @@ -123,6 +123,24 @@ describe('KitClient', () => { 'Kit API POST /forms/f/subscribers → 400 Bad Request' ); }); + + it('handles response.text() rejection on HTTP error', async () => { + fetchMock.mockReturnValue( + Promise.resolve({ + ok: false, + status: 502, + statusText: 'Bad Gateway', + text: () => Promise.reject(new Error('body unreadable')), + } as Response) + ); + const err = await client + .subscribeToForm('u@e.com', 'f') + .catch(e => e as KitClientError); + expect(err.code).toBe('kit_api_502'); + expect(err.message).toBe( + 'Kit API POST /forms/f/subscribers → 502 Bad Gateway' + ); + }); }); describe('applyTag', () => { diff --git a/packages/observability/src/__tests__/ObservabilityProvider.web.test.tsx b/packages/observability/src/__tests__/ObservabilityProvider.web.test.tsx index 08bbf2ee..3e503bc0 100644 --- a/packages/observability/src/__tests__/ObservabilityProvider.web.test.tsx +++ b/packages/observability/src/__tests__/ObservabilityProvider.web.test.tsx @@ -142,4 +142,40 @@ describe('ObservabilityProvider (web)', () => { expect.any(Function) ); }); + + it('ignores Sentry load after unmount', async () => { + vi.resetModules(); + + let resolvePendingSentry!: (value: typeof SentryMock) => void; + const pendingSentry = new Promise(resolve => { + resolvePendingSentry = resolve; + }); + vi.doMock('@sentry/react', () => pendingSentry); + + const { ObservabilityProvider } = + await import('../components/ObservabilityProvider.web.js'); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { unmount } = render( + +
child
+
+ ); + unmount(); + resolvePendingSentry(SentryMock); + await act(async () => { + await Promise.resolve(); + }); + + const unmountedWarnings = consoleSpy.mock.calls.filter(([message]) => + String(message).toLowerCase().includes('unmounted') + ); + expect(unmountedWarnings).toHaveLength(0); + } finally { + consoleSpy.mockRestore(); + vi.resetModules(); + vi.doUnmock('@sentry/react'); + } + }); }); diff --git a/packages/observability/src/__tests__/sentryDynamicImport.coverage.test.ts b/packages/observability/src/__tests__/sentryDynamicImport.coverage.test.ts new file mode 100644 index 00000000..f22f432b --- /dev/null +++ b/packages/observability/src/__tests__/sentryDynamicImport.coverage.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +describe('Sentry dynamic import fallbacks', () => { + afterEach(() => { + vi.resetModules(); + vi.doUnmock('@sentry/react'); + vi.doUnmock('@sentry/react-native'); + }); + + it('ObservabilityProvider survives a rejected @sentry/react import', async () => { + vi.doMock('@sentry/react', () => Promise.reject(new Error('chunk failed'))); + const { ObservabilityProvider } = + await import('../components/ObservabilityProvider.web.js'); + const React = await import('react'); + const { render, screen } = await import('@testing-library/react'); + + render( + React.createElement( + ObservabilityProvider, + { config: { project: 'test', environment: 'test' } }, + React.createElement('div', null, 'child') + ) + ); + expect(screen.getByText('child')).toBeInTheDocument(); + }); + + it('withErrorBoundary.web survives a rejected @sentry/react import', async () => { + vi.doMock('@sentry/react', () => Promise.reject(new Error('chunk failed'))); + const { ErrorBoundary } = + await import('../components/withErrorBoundary.web.js'); + const React = await import('react'); + const { render, screen } = await import('@testing-library/react'); + + function Thrower() { + throw new Error('boom'); + } + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + render( + React.createElement(ErrorBoundary, null, React.createElement(Thrower)) + ); + expect(screen.getByRole('alert')).toBeInTheDocument(); + } finally { + consoleSpy.mockRestore(); + } + }); + + it('withErrorBoundary.native survives a rejected @sentry/react-native import', async () => { + vi.doMock('@sentry/react-native', () => + Promise.reject(new Error('chunk failed')) + ); + const { ErrorBoundary } = + await import('../components/withErrorBoundary.native.js'); + const React = await import('react'); + const { render, screen } = await import('@testing-library/react'); + + function Thrower() { + throw new Error('boom'); + } + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + render( + React.createElement(ErrorBoundary, null, React.createElement(Thrower)) + ); + expect(screen.getByRole('alert')).toBeInTheDocument(); + } finally { + consoleSpy.mockRestore(); + } + }); +}); diff --git a/packages/shared-tests/__tests__/components/navigation/UserMenu.test.tsx b/packages/shared-tests/__tests__/components/navigation/UserMenu.test.tsx index 38e1b4f6..2f86d5ae 100644 --- a/packages/shared-tests/__tests__/components/navigation/UserMenu.test.tsx +++ b/packages/shared-tests/__tests__/components/navigation/UserMenu.test.tsx @@ -345,6 +345,23 @@ describe('UserMenu (Web)', () => { }); }); + it('should close menu when Connections link is clicked', async () => { + renderWithProviders(); + const menuButton = screen.getByLabelText('User menu'); + fireEvent.click(menuButton); + await waitFor(() => + expect( + screen.getByRole('link', { name: 'Connections' }) + ).toBeInTheDocument() + ); + + fireEvent.click(screen.getByRole('link', { name: 'Connections' })); + + await waitFor(() => { + expect(menuButton).toHaveAttribute('aria-expanded', 'false'); + }); + }); + it('should not render email row when user has no email', async () => { const userWithoutEmail: User = { ...mockUser, email: undefined }; renderWithProviders( diff --git a/packages/waitlist/src/hooks/useSignupMode.test.tsx b/packages/waitlist/src/hooks/useSignupMode.test.tsx index 077e3ccb..76640cf8 100644 --- a/packages/waitlist/src/hooks/useSignupMode.test.tsx +++ b/packages/waitlist/src/hooks/useSignupMode.test.tsx @@ -18,4 +18,27 @@ describe('useSignupMode', () => { expect(result.current.isOpen).toBe(false); vi.restoreAllMocks(); }); + + it('ignores settings result after unmount', async () => { + let resolveSettings: (value: { + signup_mode: 'open'; + copy: Record; + metadata_schema: never[]; + }) => void = () => {}; + vi.spyOn(client, 'getPublicWaitlistSettings').mockImplementation( + () => + new Promise(resolve => { + resolveSettings = resolve; + }) + ); + const supabase = {} as never; + const { unmount } = renderHook(() => useSignupMode(supabase)); + await waitFor(() => + expect(client.getPublicWaitlistSettings).toHaveBeenCalled() + ); + unmount(); + resolveSettings({ signup_mode: 'open', copy: {}, metadata_schema: [] }); + await Promise.resolve(); + vi.restoreAllMocks(); + }); }); From 255860f99442053d6c7fd9cbbe2c285b1cc96c07 Mon Sep 17 00:00:00 2001 From: Brad Hefta-Gaub Date: Wed, 3 Jun 2026 17:15:53 -0700 Subject: [PATCH 4/4] fix(test): address Copilot coverage test quality on #401 (#402) * fix(test): strengthen unmount and Tab-trap coverage assertions Address Copilot review on #401: use userEvent.tab() to verify focus advances when Tab is not trapped, and assert no React unmounted warnings in post-unmount async tests for billing, waitlist, and admin packages. * fix(test): harden console.error unmount warning assertions Wrap console.error spies in try/finally so failed assertions cannot leak mocks into later tests, and scan all console.error arguments when detecting React unmounted-component warnings. --- .../components/AdminDetailDrawer.web.test.tsx | 11 +- packages/billing/src/BillingProvider.test.tsx | 212 +++++++++++------- .../waitlist/src/hooks/useSignupMode.test.tsx | 34 ++- 3 files changed, 161 insertions(+), 96 deletions(-) diff --git a/packages/admin/src/components/AdminDetailDrawer.web.test.tsx b/packages/admin/src/components/AdminDetailDrawer.web.test.tsx index faadee70..59bec11f 100644 --- a/packages/admin/src/components/AdminDetailDrawer.web.test.tsx +++ b/packages/admin/src/components/AdminDetailDrawer.web.test.tsx @@ -308,7 +308,8 @@ describe('AdminDetailDrawer', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it('does not trap Tab when focus is not on the last element', () => { + it('does not trap Tab when focus is not on the last element', async () => { + const user = userEvent.setup(); render( @@ -316,10 +317,10 @@ describe('AdminDetailDrawer', () => { ); const notes = screen.getByLabelText('Notes'); - notes.focus(); - document.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }) - ); + const save = screen.getByRole('button', { name: 'Save' }); + await user.click(notes); expect(document.activeElement).toBe(notes); + await user.tab(); + expect(document.activeElement).toBe(save); }); }); diff --git a/packages/billing/src/BillingProvider.test.tsx b/packages/billing/src/BillingProvider.test.tsx index 41eed02b..51f15e8e 100644 --- a/packages/billing/src/BillingProvider.test.tsx +++ b/packages/billing/src/BillingProvider.test.tsx @@ -11,6 +11,15 @@ import { testSubscription, } from './test/billingFixtures.js'; +type ConsoleErrorSpy = ReturnType>; + +function expectNoUnmountedConsoleWarnings(consoleSpy: ConsoleErrorSpy) { + const unmountedWarnings = consoleSpy.mock.calls.filter(args => + args.some(arg => String(arg).toLowerCase().includes('unmounted')) + ); + expect(unmountedWarnings).toHaveLength(0); +} + function Reader() { const { userId, subscription } = useBillingContext(); return ( @@ -591,99 +600,138 @@ describe('BillingProvider', () => { data: ReturnType | null; error: null; }) => void = () => {}; - auth.state.session = { user: { id: 'u-plan-unmount' } }; - const row = { - ...testSubscription(), - user_id: 'u-plan-unmount', - plan_id: 'plan_free', - }; - db.maybeSingle.mockResolvedValue({ data: row, error: null }); - db.planMaybeSingle.mockImplementationOnce( - () => - new Promise(resolve => { - resolvePlan = resolve; - }) - ); - const { unmount } = render( - - - - ); - await waitFor(() => expect(db.planMaybeSingle).toHaveBeenCalled()); - unmount(); - resolvePlan({ data: testPlan({ display_name: 'Late plan' }), error: null }); - await Promise.resolve(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + auth.state.session = { user: { id: 'u-plan-unmount' } }; + const row = { + ...testSubscription(), + user_id: 'u-plan-unmount', + plan_id: 'plan_free', + }; + db.maybeSingle.mockResolvedValue({ data: row, error: null }); + db.planMaybeSingle.mockImplementationOnce( + () => + new Promise(resolve => { + resolvePlan = resolve; + }) + ); + const { unmount } = render( + + + + ); + await waitFor(() => expect(db.planMaybeSingle).toHaveBeenCalled()); + unmount(); + resolvePlan({ + data: testPlan({ display_name: 'Late plan' }), + error: null, + }); + await act(async () => { + await Promise.resolve(); + }); + + expectNoUnmountedConsoleWarnings(consoleSpy); + } finally { + consoleSpy.mockRestore(); + } }); it('ignores ensure_billing_subscription result after unmount', async () => { let resolveRpc: (value: { error: null }) => void = () => {}; - auth.state.session = { user: { id: 'u-rpc-unmount' } }; - db.rpc.mockImplementationOnce( - () => - new Promise(resolve => { - resolveRpc = resolve; - }) - ); - const { unmount } = render( - - - - ); - await waitFor(() => - expect(screen.getByTestId('uid').textContent).toBe('u-rpc-unmount') - ); - await waitFor(() => expect(db.rpc).toHaveBeenCalled()); - unmount(); - resolveRpc({ error: null }); - await Promise.resolve(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + auth.state.session = { user: { id: 'u-rpc-unmount' } }; + db.rpc.mockImplementationOnce( + () => + new Promise(resolve => { + resolveRpc = resolve; + }) + ); + const { unmount } = render( + + + + ); + await waitFor(() => + expect(screen.getByTestId('uid').textContent).toBe('u-rpc-unmount') + ); + await waitFor(() => expect(db.rpc).toHaveBeenCalled()); + unmount(); + resolveRpc({ error: null }); + await act(async () => { + await Promise.resolve(); + }); + + expectNoUnmountedConsoleWarnings(consoleSpy); + } finally { + consoleSpy.mockRestore(); + } }); it('ignores plan query errors after unmount', async () => { let rejectPlan: (error: Error) => void = () => {}; - auth.state.session = { user: { id: 'u-plan-err-unmount' } }; - const row = { - ...testSubscription(), - user_id: 'u-plan-err-unmount', - plan_id: 'plan_free', - }; - db.maybeSingle.mockResolvedValue({ data: row, error: null }); - db.planMaybeSingle.mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - rejectPlan = reject; - }) - ); - const { unmount } = render( - - - - ); - await waitFor(() => expect(db.planMaybeSingle).toHaveBeenCalled()); - unmount(); - rejectPlan(new Error('late plan fail')); - await Promise.resolve(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + auth.state.session = { user: { id: 'u-plan-err-unmount' } }; + const row = { + ...testSubscription(), + user_id: 'u-plan-err-unmount', + plan_id: 'plan_free', + }; + db.maybeSingle.mockResolvedValue({ data: row, error: null }); + db.planMaybeSingle.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectPlan = reject; + }) + ); + const { unmount } = render( + + + + ); + await waitFor(() => expect(db.planMaybeSingle).toHaveBeenCalled()); + unmount(); + rejectPlan(new Error('late plan fail')); + await act(async () => { + await Promise.resolve(); + }); + + expectNoUnmountedConsoleWarnings(consoleSpy); + } finally { + consoleSpy.mockRestore(); + } }); it('ignores ensure_billing_subscription errors after unmount', async () => { let rejectRpc: (error: Error) => void = () => {}; - auth.state.session = { user: { id: 'u-rpc-err-unmount' } }; - db.rpc.mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - rejectRpc = reject; - }) - ); - const { unmount } = render( - - - - ); - await waitFor(() => - expect(screen.getByTestId('uid').textContent).toBe('u-rpc-err-unmount') - ); - await waitFor(() => expect(db.rpc).toHaveBeenCalled()); - unmount(); - rejectRpc(new Error('late rpc fail')); - await Promise.resolve(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + auth.state.session = { user: { id: 'u-rpc-err-unmount' } }; + db.rpc.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectRpc = reject; + }) + ); + const { unmount } = render( + + + + ); + await waitFor(() => + expect(screen.getByTestId('uid').textContent).toBe('u-rpc-err-unmount') + ); + await waitFor(() => expect(db.rpc).toHaveBeenCalled()); + unmount(); + rejectRpc(new Error('late rpc fail')); + await act(async () => { + await Promise.resolve(); + }); + + expectNoUnmountedConsoleWarnings(consoleSpy); + } finally { + consoleSpy.mockRestore(); + } }); }); diff --git a/packages/waitlist/src/hooks/useSignupMode.test.tsx b/packages/waitlist/src/hooks/useSignupMode.test.tsx index 76640cf8..3c53bc90 100644 --- a/packages/waitlist/src/hooks/useSignupMode.test.tsx +++ b/packages/waitlist/src/hooks/useSignupMode.test.tsx @@ -3,6 +3,15 @@ import { renderHook, waitFor } from '@testing-library/react'; import { useSignupMode } from './useSignupMode.js'; import * as client from '../waitlistClient.js'; +type ConsoleErrorSpy = ReturnType>; + +function expectNoUnmountedConsoleWarnings(consoleSpy: ConsoleErrorSpy) { + const unmountedWarnings = consoleSpy.mock.calls.filter(args => + args.some(arg => String(arg).toLowerCase().includes('unmounted')) + ); + expect(unmountedWarnings).toHaveLength(0); +} + describe('useSignupMode', () => { it('loads public settings and exposes mode flags', async () => { vi.spyOn(client, 'getPublicWaitlistSettings').mockResolvedValue({ @@ -31,14 +40,21 @@ describe('useSignupMode', () => { resolveSettings = resolve; }) ); - const supabase = {} as never; - const { unmount } = renderHook(() => useSignupMode(supabase)); - await waitFor(() => - expect(client.getPublicWaitlistSettings).toHaveBeenCalled() - ); - unmount(); - resolveSettings({ signup_mode: 'open', copy: {}, metadata_schema: [] }); - await Promise.resolve(); - vi.restoreAllMocks(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const supabase = {} as never; + const { unmount } = renderHook(() => useSignupMode(supabase)); + await waitFor(() => + expect(client.getPublicWaitlistSettings).toHaveBeenCalled() + ); + unmount(); + resolveSettings({ signup_mode: 'open', copy: {}, metadata_schema: [] }); + await Promise.resolve(); + + expectNoUnmountedConsoleWarnings(consoleSpy); + } finally { + consoleSpy.mockRestore(); + vi.restoreAllMocks(); + } }); });