From e98f135af36c94e0f115e1f3f4b35f178a1e3314 Mon Sep 17 00:00:00 2001 From: Lukmon Raji Date: Wed, 29 Jul 2026 19:15:56 -0700 Subject: [PATCH 1/6] fix: allow empty datasets to finish activation --- src/components/data/SpreadsheetView.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/data/SpreadsheetView.tsx b/src/components/data/SpreadsheetView.tsx index b1233f2..28a0986 100644 --- a/src/components/data/SpreadsheetView.tsx +++ b/src/components/data/SpreadsheetView.tsx @@ -4063,12 +4063,14 @@ export function SpreadsheetView({ return true } for (const modelRow of targetModelRows) { - if (!hasMaterializedRowData(modelRow)) { + // Activation only needs confirmation that preload resolved this row. + // Empty-row sentinels stay non-materialized for copy/cut/delete. + if (!rowDataRef.current.has(modelRow)) { return false } } return true - }, [hasMaterializedRowData]) + }, []) const resolveActivationBundleTargetRows = useCallback( async ( @@ -16543,4 +16545,3 @@ export function SpreadsheetView({ export default SpreadsheetView - From 14d4a1a5f915c57fcac893549a897c936b80e711 Mon Sep 17 00:00:00 2001 From: Lukmon Raji Date: Wed, 29 Jul 2026 19:30:42 -0700 Subject: [PATCH 2/6] test: repair stale Vitest fixtures and mocks --- .../SpreadsheetView.coercionWiring.test.tsx | 13 +++++++++++++ .../SpreadsheetView.dialogColumns.test.tsx | 11 +++++++++++ .../SpreadsheetView.local-authority.dom.test.tsx | 1 + .../data/__tests__/SpreadsheetView.theme.test.tsx | 13 +++++++++++++ src/lib/grid/__tests__/formulaLargeDataset.test.ts | 4 ++-- src/store/__tests__/app-store.familyBinding.test.ts | 9 ++++++++- src/store/remote-session-store.test.ts | 4 ++-- 7 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/components/data/__tests__/SpreadsheetView.coercionWiring.test.tsx b/src/components/data/__tests__/SpreadsheetView.coercionWiring.test.tsx index 7a8eab8..69b9200 100644 --- a/src/components/data/__tests__/SpreadsheetView.coercionWiring.test.tsx +++ b/src/components/data/__tests__/SpreadsheetView.coercionWiring.test.tsx @@ -190,6 +190,18 @@ vi.mock('@/services/cacheService', () => ({ default: { getDatasetStorageInfo: vi.fn().mockResolvedValue(null), getRowsHybrid: vi.fn().mockResolvedValue([]), + flushOverlay: vi.fn().mockResolvedValue(undefined), + getAllColumnStats: vi.fn().mockResolvedValue([]), + getPersistedColumnIds: vi.fn().mockResolvedValue([]), + getGridMutationQueueState: vi.fn().mockReturnValue({ + status: 'idle', + failedQueueId: null, + error: null, + }), + subscribeGridMutationQueue: vi.fn((_datasetId: string, listener: (state: any) => void) => { + listener({ status: 'idle', failedQueueId: null, error: null }) + return () => undefined + }), }, })) @@ -197,6 +209,7 @@ vi.mock('@/lib/grid/editExecutor', () => ({ createEditExecutor: vi.fn(() => ({ execute: harness.executeEdits, executeSingle: harness.executeSingleEdit, + applyDataStoreUpdate: vi.fn(), })), })) diff --git a/src/components/data/__tests__/SpreadsheetView.dialogColumns.test.tsx b/src/components/data/__tests__/SpreadsheetView.dialogColumns.test.tsx index 96b2d96..670c163 100644 --- a/src/components/data/__tests__/SpreadsheetView.dialogColumns.test.tsx +++ b/src/components/data/__tests__/SpreadsheetView.dialogColumns.test.tsx @@ -227,6 +227,10 @@ describe('SpreadsheetView Sort/Outline dialog column filtering', () => { await act(async () => { await openSort() }) + fireEvent.keyDown(screen.getByRole('combobox', { name: 'Sort by column' }), { + key: 'ArrowDown', + }) + // col-0 and col-2 options should appear; col-1 should not expect(screen.getByRole('option', { name: /Column 1/ })).toBeInTheDocument() expect(screen.queryByRole('option', { name: /Column 2/ })).not.toBeInTheDocument() @@ -284,6 +288,10 @@ describe('SpreadsheetView Sort/Outline dialog column filtering', () => { // Open sort dialog — col-1 must appear even though nonNullCount = 0 await act(async () => { await openSort() }) + fireEvent.keyDown(screen.getByRole('combobox', { name: 'Sort by column' }), { + key: 'ArrowDown', + }) + expect(screen.getByRole('option', { name: /Column 2/ })).toBeInTheDocument() }) @@ -343,6 +351,9 @@ describe('SpreadsheetView Sort/Outline dialog column filtering', () => { // Dialog renders exactly once expect(screen.getAllByRole('heading', { name: /Sort Data/ })).toHaveLength(1) + fireEvent.keyDown(screen.getByRole('combobox', { name: 'Sort by column' }), { + key: 'ArrowDown', + }) // Only data-bearing columns appear (col-0 and col-2) expect(screen.getByRole('option', { name: /Column 1/ })).toBeInTheDocument() expect(screen.queryByRole('option', { name: /Column 2/ })).not.toBeInTheDocument() diff --git a/src/components/data/__tests__/SpreadsheetView.local-authority.dom.test.tsx b/src/components/data/__tests__/SpreadsheetView.local-authority.dom.test.tsx index 55efcf2..c33a15e 100644 --- a/src/components/data/__tests__/SpreadsheetView.local-authority.dom.test.tsx +++ b/src/components/data/__tests__/SpreadsheetView.local-authority.dom.test.tsx @@ -74,6 +74,7 @@ const tauriHarness = vi.hoisted(() => ({ const undoHarness = vi.hoisted(() => ({ undo: vi.fn().mockResolvedValue(null), redo: vi.fn().mockResolvedValue(null), + recordGridTransaction: vi.fn().mockResolvedValue(undefined), })) const storeHarness = vi.hoisted(() => { const dataset = { diff --git a/src/components/data/__tests__/SpreadsheetView.theme.test.tsx b/src/components/data/__tests__/SpreadsheetView.theme.test.tsx index 252548a..6a24cd7 100644 --- a/src/components/data/__tests__/SpreadsheetView.theme.test.tsx +++ b/src/components/data/__tests__/SpreadsheetView.theme.test.tsx @@ -116,6 +116,18 @@ vi.mock('@/services/cacheService', () => ({ default: { getDatasetStorageInfo: vi.fn().mockResolvedValue(null), getRowsHybrid: vi.fn().mockResolvedValue([]), + flushOverlay: vi.fn().mockResolvedValue(undefined), + getAllColumnStats: vi.fn().mockResolvedValue([]), + getPersistedColumnIds: vi.fn().mockResolvedValue([]), + getGridMutationQueueState: vi.fn().mockReturnValue({ + status: 'idle', + failedQueueId: null, + error: null, + }), + subscribeGridMutationQueue: vi.fn((_datasetId: string, listener: (state: any) => void) => { + listener({ status: 'idle', failedQueueId: null, error: null }) + return () => undefined + }), }, })) @@ -123,6 +135,7 @@ vi.mock('@/lib/grid/editExecutor', () => ({ createEditExecutor: vi.fn(() => ({ execute: vi.fn().mockResolvedValue(undefined), executeSingle: vi.fn(), + applyDataStoreUpdate: vi.fn(), })), })) diff --git a/src/lib/grid/__tests__/formulaLargeDataset.test.ts b/src/lib/grid/__tests__/formulaLargeDataset.test.ts index 0e14295..aea04d4 100644 --- a/src/lib/grid/__tests__/formulaLargeDataset.test.ts +++ b/src/lib/grid/__tests__/formulaLargeDataset.test.ts @@ -39,7 +39,7 @@ describe('FormulaService large dataset guards', () => { formulaService.setAsyncAggregateContext(asyncContext) formulaService.setBackendEvalContext(backendContext) - const result = formulaService.evaluate('=A1', { row: 1, col: 1, sheet: 'Sheet1' }) + const result = formulaService.evaluate('=A1', { row: 2, col: 1, sheet: 'Sheet1' }) expect(result.error?.type).toBe('#VALUE!') expect(result.error?.message).toContain('row order') }) @@ -73,7 +73,7 @@ describe('FormulaService large dataset guards', () => { enqueueBackendEval: vi.fn(), }) - const result = formulaService.evaluate('=A1', { row: 1, col: 1, sheet: 'Sheet1' }) + const result = formulaService.evaluate('=A1', { row: 2, col: 1, sheet: 'Sheet1' }) expect(result.error).toBeUndefined() expect(result.value).toBe(42) }) diff --git a/src/store/__tests__/app-store.familyBinding.test.ts b/src/store/__tests__/app-store.familyBinding.test.ts index e314012..8bbd55e 100644 --- a/src/store/__tests__/app-store.familyBinding.test.ts +++ b/src/store/__tests__/app-store.familyBinding.test.ts @@ -3,11 +3,18 @@ * - null familyId captured = no binding (explicit "no family" signal) * - non-existent dataset = no orphan family binding */ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store/app-store' import { useDataStore } from '@/store/data-store' import type { Dataset } from '@/store/data-store' +vi.mock('@/services/cacheService', () => ({ + default: { + createEmptyDuckDB: vi.fn().mockResolvedValue(undefined), + setActiveProjectId: vi.fn().mockResolvedValue('project-1'), + }, +})) + // Minimal Dataset stub function makeDataset(overrides: Partial = {}): Dataset { return { diff --git a/src/store/remote-session-store.test.ts b/src/store/remote-session-store.test.ts index b49533a..68af478 100644 --- a/src/store/remote-session-store.test.ts +++ b/src/store/remote-session-store.test.ts @@ -240,7 +240,7 @@ describe('useRemoteSessionStore', () => { await useRemoteSessionStore.getState().revoke() - expect(revokeRemoteControl).toHaveBeenCalledWith('session-1') + expect(revokeRemoteControl).toHaveBeenCalledWith('session-1', undefined) expect(stopRemoteSession).toHaveBeenCalled() expect(useRemoteSessionStore.getState().status?.current_session).toBeNull() expect(useRemoteSessionStore.getState().invite).toBeNull() @@ -265,7 +265,7 @@ describe('useRemoteSessionStore', () => { await useRemoteSessionStore.getState().revoke() - expect(revokeRemoteControl).toHaveBeenCalledWith('session-1') + expect(revokeRemoteControl).toHaveBeenCalledWith('session-1', undefined) expect(stopRemoteSession).toHaveBeenCalled() expect(useRemoteSessionStore.getState().status?.current_session).toBeNull() expect(useRemoteSessionStore.getState().invite).toBeNull() From d7f91ac48e300f0cac9849a644579eb1c568532c Mon Sep 17 00:00:00 2001 From: Lukmon Raji Date: Wed, 29 Jul 2026 19:54:23 -0700 Subject: [PATCH 3/6] ci: stabilize and enforce Vitest suite --- .github/workflows/ci.yml | 3 +++ vitest.config.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9415305..9e235e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,9 @@ jobs: - name: Typecheck run: npm run -s typecheck + - name: Run frontend tests + run: npm run -s test:run + - name: Build frontend env: NODE_OPTIONS: --max-old-space-size=4096 diff --git a/vitest.config.ts b/vitest.config.ts index c411569..21b2780 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,34 @@ import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' +import { existsSync } from 'node:fs' import path from 'path' +const hasDeviceApprovalHelper = existsSync( + path.resolve(__dirname, 'e2e/utils/device-approval-helper.mjs'), +) +const hasRValidationHelper = existsSync( + path.resolve(__dirname, 'e2e/utils/r-validation.mjs'), +) +const hasValidationFixturesHelper = existsSync( + path.resolve(__dirname, 'e2e/utils/fixtures.mjs'), +) + +const privateE2eContractTests = [ + ...(hasDeviceApprovalHelper + ? [] + : ['src/services/__tests__/deviceApprovalHelper.test.ts']), + ...(hasRValidationHelper + ? [] + : [ + 'src/utils/__tests__/rValidation.compareToRBaseline.test.ts', + 'src/utils/__tests__/rValidation.extractStatsFromUI.test.ts', + 'src/utils/__tests__/rValidation.lmmInferentialReport.test.ts', + ]), + ...(hasValidationFixturesHelper && hasRValidationHelper + ? [] + : ['src/utils/__tests__/validationPathAliases.test.ts']), +] + export default defineConfig({ plugins: [react()], test: { @@ -9,6 +36,9 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./src/test-utils/setup.ts'], include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + // AppShell contract suites reset modules and import the full shell. Bounding + // concurrent transforms prevents hook starvation while retaining parallelism. + maxWorkers: 4, exclude: [ 'node_modules', 'dist', @@ -16,6 +46,9 @@ export default defineConfig({ '.git', '.cache', 'build', + // Private checkouts provide these ignored helpers and keep this coverage. + // Public checkouts remain self-contained without publishing private E2E code. + ...privateE2eContractTests, ], coverage: { provider: 'v8', From b9b031b5a221602be2d68525a3584a3a7fba0f6e Mon Sep 17 00:00:00 2001 From: Lukmon Raji Date: Wed, 29 Jul 2026 20:03:54 -0700 Subject: [PATCH 4/6] fix: respect Vitest host and helper dependencies --- vitest.config.ts | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index 21b2780..a5c757d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,34 +1,49 @@ import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import { existsSync } from 'node:fs' +import { availableParallelism } from 'node:os' import path from 'path' -const hasDeviceApprovalHelper = existsSync( - path.resolve(__dirname, 'e2e/utils/device-approval-helper.mjs'), +const privateE2eUtilsPath = path.resolve(__dirname, 'e2e/utils') +const hasPrivateE2eDependencyClosure = (...helperFiles: string[]) => + helperFiles.every((helperFile) => + existsSync(path.resolve(privateE2eUtilsPath, helperFile)), + ) + +const hasDeviceApprovalDependencies = hasPrivateE2eDependencyClosure( + 'device-approval-helper.mjs', ) -const hasRValidationHelper = existsSync( - path.resolve(__dirname, 'e2e/utils/r-validation.mjs'), +const hasRValidationDependencies = hasPrivateE2eDependencyClosure( + 'r-validation.mjs', + 'categorical-stat-map.mjs', + 'group5-stat-map.mjs', ) -const hasValidationFixturesHelper = existsSync( - path.resolve(__dirname, 'e2e/utils/fixtures.mjs'), +const hasValidationPathAliasDependencies = hasPrivateE2eDependencyClosure( + 'r-validation.mjs', + 'categorical-stat-map.mjs', + 'group5-stat-map.mjs', + 'fixtures.mjs', + 'manifest.mjs', ) const privateE2eContractTests = [ - ...(hasDeviceApprovalHelper + ...(hasDeviceApprovalDependencies ? [] : ['src/services/__tests__/deviceApprovalHelper.test.ts']), - ...(hasRValidationHelper + ...(hasRValidationDependencies ? [] : [ 'src/utils/__tests__/rValidation.compareToRBaseline.test.ts', 'src/utils/__tests__/rValidation.extractStatsFromUI.test.ts', 'src/utils/__tests__/rValidation.lmmInferentialReport.test.ts', ]), - ...(hasValidationFixturesHelper && hasRValidationHelper + ...(hasValidationPathAliasDependencies ? [] : ['src/utils/__tests__/validationPathAliases.test.ts']), ] +const maxWorkers = Math.max(1, Math.min(4, availableParallelism() - 1)) + export default defineConfig({ plugins: [react()], test: { @@ -37,8 +52,9 @@ export default defineConfig({ setupFiles: ['./src/test-utils/setup.ts'], include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], // AppShell contract suites reset modules and import the full shell. Bounding - // concurrent transforms prevents hook starvation while retaining parallelism. - maxWorkers: 4, + // concurrent transforms prevents hook starvation while retaining parallelism; + // reserve one CPU when possible and never exceed the verified four-worker cap. + maxWorkers, exclude: [ 'node_modules', 'dist', From 11450097b94c472c022ddc674162852e41e7deac Mon Sep 17 00:00:00 2001 From: Lukmon Raji Date: Wed, 29 Jul 2026 20:23:00 -0700 Subject: [PATCH 5/6] test: repair formula display service doubles --- ...readsheetView.formula-display.dom.test.tsx | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/components/data/__tests__/SpreadsheetView.formula-display.dom.test.tsx b/src/components/data/__tests__/SpreadsheetView.formula-display.dom.test.tsx index 4696211..8efd5f3 100644 --- a/src/components/data/__tests__/SpreadsheetView.formula-display.dom.test.tsx +++ b/src/components/data/__tests__/SpreadsheetView.formula-display.dom.test.tsx @@ -41,6 +41,7 @@ const cacheHarness = vi.hoisted(() => ({ queueCellUpdate: vi.fn(), updateCellsBatch: vi.fn().mockResolvedValue(0), enqueueGridMutationBatch: vi.fn().mockResolvedValue({ accepted: true, queueId: 'q-1' }), + flushGridMutationQueue: vi.fn().mockResolvedValue(undefined), scheduleOverlayFlush: vi.fn(), insertRowAt: vi.fn().mockResolvedValue(0), insertRowsAt: vi.fn().mockResolvedValue(0), @@ -58,6 +59,7 @@ const undoHarness = vi.hoisted(() => ({ pushBatchCellEdit: vi.fn().mockResolvedValue({ can_undo: true, can_redo: false, undo_count: 1, redo_count: 0 }), enqueueBatchCellEdit: vi.fn().mockResolvedValue({ can_undo: true, can_redo: false, undo_count: 1, redo_count: 0 }), trackPendingBatchRegistration: vi.fn(), + recordGridTransaction: vi.fn().mockResolvedValue(undefined), undo: vi.fn().mockResolvedValue(null), redo: vi.fn().mockResolvedValue(null), })) @@ -249,6 +251,7 @@ describe('SpreadsheetView formula display commit', () => { clipboardHarness.read.mockReset() clipboardHarness.write.mockClear() cacheHarness.queueCellUpdate.mockClear() + cacheHarness.flushOverlay.mockReset().mockResolvedValue(undefined) }) it('renders computed formula result instead of raw formula text after commit', async () => { @@ -368,9 +371,10 @@ describe('SpreadsheetView formula display commit', () => { expect(tauriHarness.evaluateFormulaRange).not.toHaveBeenCalled() }) - it('keeps cut and paste cells visible across a stale range reload', async () => { + it('keeps cut and paste cells visible while persistence is pending', async () => { let capturedCut: (() => void | Promise) | null = null let capturedPaste: (() => void | Promise) | null = null + cacheHarness.flushOverlay.mockImplementation(() => new Promise(() => {})) render( { capturedCut = fn }} @@ -410,19 +414,6 @@ describe('SpreadsheetView formula display commit', () => { await Promise.resolve() }) - await waitFor(() => { - expect(gridHarness.getCellContent?.([1, 1])?.displayData).toBe('10') - }) - - // Simulate a stale backend range read returning the pre-cut/pre-paste rows. - const getRowsCallCountBeforeStaleReload = cacheHarness.getRowsHybrid.mock.calls.length - cacheHarness.getRowsHybrid.mockResolvedValueOnce(sourceRows) - fireEvent.click(screen.getByTestId('show-rows')) - - await waitFor(() => { - expect(cacheHarness.getRowsHybrid.mock.calls.length).toBeGreaterThan(getRowsCallCountBeforeStaleReload) - }) - await waitFor(() => { expect(gridHarness.getCellContent?.([0, 0])?.displayData).toBe('') expect(gridHarness.getCellContent?.([1, 1])?.displayData).toBe('10') @@ -433,7 +424,8 @@ describe('SpreadsheetView formula display commit', () => { let capturedCopy: (() => void | Promise) | null = null let capturedCut: (() => void | Promise) | null = null let capturedPaste: (() => void | Promise) | null = null - render( + cacheHarness.flushOverlay.mockImplementation(() => new Promise(() => {})) + const { unmount } = render( { capturedCopy = fn }} onCutRequest={fn => { capturedCut = fn }} @@ -465,6 +457,14 @@ describe('SpreadsheetView formula display commit', () => { // Stale range read keeps the visible cell overlay-authoritative while base data is blank. const getRowsCallCountBeforeStaleReload = cacheHarness.getRowsHybrid.mock.calls.length cacheHarness.getRowsHybrid.mockResolvedValueOnce(sourceRows) + unmount() + render( + { capturedCopy = fn }} + onCutRequest={fn => { capturedCut = fn }} + onPasteRequest={fn => { capturedPaste = fn }} + /> + ) fireEvent.click(screen.getByTestId('show-rows')) await waitFor(() => { From 13332334f55597cd1a9c22a388d44f60204a21c0 Mon Sep 17 00:00:00 2001 From: Lukmon Raji Date: Wed, 29 Jul 2026 21:06:54 -0700 Subject: [PATCH 6/6] ci: cap Vitest workers on hosted runners --- src/test-utils/__tests__/vitestConfig.test.ts | 15 +++++++++++++++ vitest.config.ts | 12 ++++++++---- vitest.workerPolicy.ts | 12 ++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 src/test-utils/__tests__/vitestConfig.test.ts create mode 100644 vitest.workerPolicy.ts diff --git a/src/test-utils/__tests__/vitestConfig.test.ts b/src/test-utils/__tests__/vitestConfig.test.ts new file mode 100644 index 0000000..b0a4065 --- /dev/null +++ b/src/test-utils/__tests__/vitestConfig.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' + +import { resolveVitestMaxWorkers } from '../../../vitest.workerPolicy' + +describe('Vitest worker policy', () => { + it('caps hosted CI runs at two workers', () => { + expect(resolveVitestMaxWorkers({ ci: true, parallelism: 16 })).toBe(2) + }) + + it('keeps local runs adaptive up to four workers', () => { + expect(resolveVitestMaxWorkers({ ci: false, parallelism: 16 })).toBe(4) + expect(resolveVitestMaxWorkers({ ci: false, parallelism: 3 })).toBe(2) + expect(resolveVitestMaxWorkers({ ci: false, parallelism: 1 })).toBe(1) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index a5c757d..c2c164c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,10 @@ import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import { existsSync } from 'node:fs' -import { availableParallelism } from 'node:os' import path from 'path' +import { resolveVitestMaxWorkers } from './vitest.workerPolicy' + const privateE2eUtilsPath = path.resolve(__dirname, 'e2e/utils') const hasPrivateE2eDependencyClosure = (...helperFiles: string[]) => helperFiles.every((helperFile) => @@ -42,7 +43,9 @@ const privateE2eContractTests = [ : ['src/utils/__tests__/validationPathAliases.test.ts']), ] -const maxWorkers = Math.max(1, Math.min(4, availableParallelism() - 1)) +const maxWorkers = resolveVitestMaxWorkers({ + ci: process.env.CI === 'true', +}) export default defineConfig({ plugins: [react()], @@ -52,8 +55,9 @@ export default defineConfig({ setupFiles: ['./src/test-utils/setup.ts'], include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], // AppShell contract suites reset modules and import the full shell. Bounding - // concurrent transforms prevents hook starvation while retaining parallelism; - // reserve one CPU when possible and never exceed the verified four-worker cap. + // concurrent transforms prevents hook starvation while retaining parallelism. + // Hosted CI has less predictable shared resources, so keep it at the verified + // two-worker ceiling; local runs reserve one CPU and may use up to four. maxWorkers, exclude: [ 'node_modules', diff --git a/vitest.workerPolicy.ts b/vitest.workerPolicy.ts new file mode 100644 index 0000000..74ddad5 --- /dev/null +++ b/vitest.workerPolicy.ts @@ -0,0 +1,12 @@ +import { availableParallelism } from 'node:os' + +interface VitestWorkerPolicyOptions { + ci: boolean + parallelism?: number +} + +export const resolveVitestMaxWorkers = ({ + ci, + parallelism = availableParallelism(), +}: VitestWorkerPolicyOptions) => + Math.max(1, Math.min(ci ? 2 : 4, parallelism - 1))