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/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 - 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.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(() => { 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() 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 c411569..c2c164c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,52 @@ import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' +import { existsSync } from 'node:fs' import path from 'path' +import { resolveVitestMaxWorkers } from './vitest.workerPolicy' + +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 hasRValidationDependencies = hasPrivateE2eDependencyClosure( + 'r-validation.mjs', + 'categorical-stat-map.mjs', + 'group5-stat-map.mjs', +) +const hasValidationPathAliasDependencies = hasPrivateE2eDependencyClosure( + 'r-validation.mjs', + 'categorical-stat-map.mjs', + 'group5-stat-map.mjs', + 'fixtures.mjs', + 'manifest.mjs', +) + +const privateE2eContractTests = [ + ...(hasDeviceApprovalDependencies + ? [] + : ['src/services/__tests__/deviceApprovalHelper.test.ts']), + ...(hasRValidationDependencies + ? [] + : [ + 'src/utils/__tests__/rValidation.compareToRBaseline.test.ts', + 'src/utils/__tests__/rValidation.extractStatsFromUI.test.ts', + 'src/utils/__tests__/rValidation.lmmInferentialReport.test.ts', + ]), + ...(hasValidationPathAliasDependencies + ? [] + : ['src/utils/__tests__/validationPathAliases.test.ts']), +] + +const maxWorkers = resolveVitestMaxWorkers({ + ci: process.env.CI === 'true', +}) + export default defineConfig({ plugins: [react()], test: { @@ -9,6 +54,11 @@ 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. + // 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', 'dist', @@ -16,6 +66,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', 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))