From f4c2586ca54526d77587ec94f838b22bb3544527 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 16:35:03 -0700 Subject: [PATCH 1/9] perf(ci): run apps/sim as three test shards --- .github/workflows/test-build.yml | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index f258c716310..b4d87d8425e 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -182,9 +182,9 @@ jobs: # without `--coverage`. See the Codecov note below. # # apps/sim runs only its first shard here; `test-shard` below runs the - # other. That suite is bound by the single Vite server thread that feeds + # others. That suite is bound by the single Vite server thread that feeds # every worker — wall time is flat from 4 to 13 workers — so a bigger - # runner buys nothing and a second runner halves it. + # runner buys nothing and each extra runner takes a proportional slice. - name: Run tests env: NODE_OPTIONS: '--no-warnings --max-old-space-size=8192' @@ -192,7 +192,7 @@ jobs: DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/simstudio' ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' # dummy key for CI only TURBO_CACHE_DIR: .turbo - SIM_TEST_SHARD: 1/2 + SIM_TEST_SHARD: 1/3 run: bun run test - name: Check schema and migrations are in sync @@ -208,15 +208,20 @@ jobs: fi echo "✅ Schema and migrations are in sync" - # The second half of apps/sim's Vitest suite. Everything else — lint, the - # audits, type-check, the other workspaces' suites — lives in `test-build` - # with shard 1; this job exists only because that suite cannot go faster on - # one machine (see the "Run tests" note there). The Turbo cache disk gets - # its own key so the two shards' entries do not evict each other. + # The remaining shards of apps/sim's Vitest suite. Everything else — lint, + # the audits, type-check, the other workspaces' suites — lives in + # `test-build` with shard 1; these jobs exist only because that suite cannot + # go faster on one machine (see the "Run tests" note there). Three shards + # put each runner at roughly the fixed cost of checkout + install. The Turbo + # cache disk gets its own key so the shards' entries do not evict each other. test-shard: - name: Test (shard 2) + name: Test (shard ${{ matrix.shard }}) runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: [2, 3] steps: - name: Checkout code @@ -250,7 +255,7 @@ jobs: uses: ./.github/actions/cache-mount with: provider: ${{ vars.CI_PROVIDER }} - key: ${{ github.repository }}-turbo-cache-shard-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} + key: ${{ github.repository }}-turbo-cache-shard-${{ matrix.shard }}-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} path: ./.turbo - name: Install dependencies @@ -259,14 +264,14 @@ jobs: - name: Install ripgrep run: command -v rg || (sudo apt-get update && sudo apt-get install -y ripgrep) - - name: Run tests (apps/sim shard 2/2) + - name: Run tests (apps/sim shard ${{ matrix.shard }}/3) env: NODE_OPTIONS: '--no-warnings --max-old-space-size=8192' NEXT_PUBLIC_APP_URL: 'https://www.sim.ai' DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/simstudio' ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' # dummy key for CI only TURBO_CACHE_DIR: .turbo - SIM_TEST_SHARD: 2/2 + SIM_TEST_SHARD: ${{ matrix.shard }}/3 run: bunx turbo run test --filter=@sim/app # Next.js production build, in parallel with lint + tests. Sticky disks are From a7e42cc0a1376ce98420b05b751d119bfd1ddf43 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 16:44:34 -0700 Subject: [PATCH 2/9] chore(deps): drop unused @types/mdx devDependency from apps/docs Nothing in apps/docs imports mdx/types or a bare *.mdx module; the generated .source imports carry a ?collection= suffix that the *.mdx wildcard does not match, and @types/mdx still resolves transitively via @mdx-js/mdx for fumadocs. The docs type-check passes without the direct edge. --- apps/docs/package.json | 1 - bun.lock | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/docs/package.json b/apps/docs/package.json index 9cbd8b31d9b..66ba9567c52 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -42,7 +42,6 @@ "devDependencies": { "@sim/tsconfig": "workspace:*", "@tailwindcss/postcss": "^4.0.12", - "@types/mdx": "^2.0.13", "@types/node": "24.2.1", "@types/react": "^19.1.2", "@types/react-dom": "^19.0.4", diff --git a/bun.lock b/bun.lock index 410e0f3d581..0be7c69ae51 100644 --- a/bun.lock +++ b/bun.lock @@ -96,7 +96,6 @@ "devDependencies": { "@sim/tsconfig": "workspace:*", "@tailwindcss/postcss": "^4.0.12", - "@types/mdx": "^2.0.13", "@types/node": "24.2.1", "@types/react": "^19.1.2", "@types/react-dom": "^19.0.4", From a38c0bf63d7162fa3f169f9d953f4863a04c6a8a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 16:46:47 -0700 Subject: [PATCH 3/9] perf(tests): move DOM-free jsdom suites to the node environment Audited the 229 test files declaring @vitest-environment jsdom. 217 need a real DOM (React rendering, TipTap/ProseMirror, window/document/localStorage, DOMParser, ProgressEvent) and stay on jsdom; 12 exercise pure logic (yjs collab-doc conversion, http tool URL building, panel editor store, log utils, attachment upload, headless TipTap table commands, terminal-session tooltip helpers) and now run on node, skipping the jsdom boot. Dropped one redundant test in apply-streamed-markdown.test.ts: the hand-rolled one-shot peer case is a strict subset of the NON-OVERLAPPING two-peer test in apply-streamed-markdown.concurrent.test.ts, which also asserts CRDT convergence and exactly-once survival of the peer edit. --- .../code-languages.test.ts | 2 +- .../apply-streamed-markdown.test.ts | 35 ------------------- .../image-drag-move.test.ts | 2 +- .../rich-markdown-editor/image.test.ts | 2 +- .../terminal-session/terminal-session.test.ts | 2 +- .../[workspaceId]/logs/utils.test.ts | 2 +- .../utils/workflow-attachment-upload.test.ts | 2 +- apps/sim/lib/collab-doc/merge.test.ts | 2 +- apps/sim/lib/collab-doc/persist.test.ts | 2 +- apps/sim/lib/collab-doc/seed.test.ts | 2 +- apps/sim/stores/panel/editor/store.test.ts | 2 +- apps/sim/tools/http/request.test.ts | 2 +- 12 files changed, 11 insertions(+), 46 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts index d3f830e2ee8..fd738c9fa25 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts @@ -1,5 +1,5 @@ /** - * @vitest-environment jsdom + * @vitest-environment node * * Guards against drift between the code-block language picker and the Prism grammars actually * registered by CodeBlockHighlight: every selectable language must have a registered grammar, or it diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.test.ts index d0019198c8b..303552e1174 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.test.ts @@ -157,41 +157,6 @@ describe('agent-stream applier', () => { expect(freshText).toContain('Gamma paragraph') }) - it('preserves a concurrent peer edit to a region the agent snapshot does not include', () => { - // This is the core "AI as a CRDT peer" guarantee: the agent relays only its OWN delta (computed - // against a private shadow), never a whole-document reconcile that would revert a peer's edit. - const { editor, doc } = track(makeCollabEditor()) - - const session = beginAgentStream(editor)! - applyAgentStreamFrame(editor, session, 'Alpha paragraph.\n\nBeta paragraph.') - - // A peer edits the FIRST paragraph directly on the shared doc — the agent's later snapshot still - // carries the ORIGINAL first paragraph (it was built from the base, before this edit). - const peer = new Y.Doc() - Y.applyUpdate(peer, Y.encodeStateAsUpdate(doc)) - const peerFrag = peer.getXmlFragment('default') - peer.transact(() => { - const firstPara = peerFrag.get(0) as Y.XmlElement - const textNode = firstPara.get(0) as Y.XmlText - textNode.insert(textNode.toString().length, ' EDITED') - }) - Y.applyUpdate(doc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(doc))) - peer.destroy() - - // The agent appends a third paragraph. Its snapshot's first paragraph is the stale original, but the - // shadow-relayed delta only inserts the new paragraph — so the peer's " EDITED" must survive. - applyAgentStreamFrame( - editor, - session, - 'Alpha paragraph.\n\nBeta paragraph.\n\nGamma paragraph.' - ) - endAgentStream(session) - - const live = doc.getXmlFragment('default').toString() - expect(live).toContain('EDITED') - expect(live).toContain('Gamma paragraph') - }) - it('reuses cached binding metadata across frames, still emitting minimal per-frame deltas', () => { // The binding `meta` is built ONCE (first frame) and reused — `updateYFragment` maintains it in place, // so we skip an O(doc) `initProseMirrorDoc` rebuild per frame. This guards that caching preserves the diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.test.ts index bc00c7b827f..d8a51be247e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.test.ts @@ -1,5 +1,5 @@ /** - * @vitest-environment jsdom + * @vitest-environment node * * Dragging an image to reposition it inside a document must MOVE it, not import it again. * diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts index 78c1662dc38..950cd6650b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts @@ -1,5 +1,5 @@ /** - * @vitest-environment jsdom + * @vitest-environment node */ import { describe, expect, it } from 'vitest' import { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.test.ts index 58769251e92..345b4b86dff 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.test.ts @@ -1,5 +1,5 @@ /** - * @vitest-environment jsdom + * @vitest-environment node */ import { resolveDesktopZoom } from '@sim/desktop-bridge' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.test.ts index 4d4cff37ec7..54cf3ffac2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.test.ts @@ -1,5 +1,5 @@ /** - * @vitest-environment jsdom + * @vitest-environment node */ import { describe, expect, it } from 'vitest' import { resolveLogWorkflowId, workflowEditorPath } from './utils' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.test.ts index 069de739ede..16d4c2a92fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.test.ts @@ -1,5 +1,5 @@ /** - * @vitest-environment jsdom + * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/apps/sim/lib/collab-doc/merge.test.ts b/apps/sim/lib/collab-doc/merge.test.ts index bb4b28196c5..27e99c6fd4f 100644 --- a/apps/sim/lib/collab-doc/merge.test.ts +++ b/apps/sim/lib/collab-doc/merge.test.ts @@ -1,5 +1,5 @@ /** - * @vitest-environment jsdom + * @vitest-environment node */ import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc' import { describe, expect, it } from 'vitest' diff --git a/apps/sim/lib/collab-doc/persist.test.ts b/apps/sim/lib/collab-doc/persist.test.ts index 96f1f4af931..735bcd94849 100644 --- a/apps/sim/lib/collab-doc/persist.test.ts +++ b/apps/sim/lib/collab-doc/persist.test.ts @@ -1,5 +1,5 @@ /** - * @vitest-environment jsdom + * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' import * as Y from 'yjs' diff --git a/apps/sim/lib/collab-doc/seed.test.ts b/apps/sim/lib/collab-doc/seed.test.ts index be67bab3f73..1e0449204b1 100644 --- a/apps/sim/lib/collab-doc/seed.test.ts +++ b/apps/sim/lib/collab-doc/seed.test.ts @@ -1,5 +1,5 @@ /** - * @vitest-environment jsdom + * @vitest-environment node */ import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc' import { getSchema } from '@tiptap/core' diff --git a/apps/sim/stores/panel/editor/store.test.ts b/apps/sim/stores/panel/editor/store.test.ts index 91050a52a22..6346d3faea1 100644 --- a/apps/sim/stores/panel/editor/store.test.ts +++ b/apps/sim/stores/panel/editor/store.test.ts @@ -1,5 +1,5 @@ /** - * @vitest-environment jsdom + * @vitest-environment node * * Deselecting a block must not end a workflow search. * diff --git a/apps/sim/tools/http/request.test.ts b/apps/sim/tools/http/request.test.ts index 3dcbf3bba3d..661239c7cbd 100644 --- a/apps/sim/tools/http/request.test.ts +++ b/apps/sim/tools/http/request.test.ts @@ -1,5 +1,5 @@ /** - * @vitest-environment jsdom + * @vitest-environment node * * HTTP Request Tool Unit Tests * From 7c93bbecef61a252b9551e808cb89c507cbbfbd6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 16:48:03 -0700 Subject: [PATCH 4/9] perf(tests): drive retry backoff and scheduled flushes with fake timers Real sleeps and repeated setup were the run-phase cost in seven files: - tools/index.test.ts: `retryDelayMs: 0` falls through to the 500ms default, so every retry test slept for real. Fake the clock for the retry describe. - embeddings/client.test.ts: two retry tests waited the 1s initial backoff. - hosted-key-rate-limiter.test.ts: the queue-head test slept two 200ms polls; the abort test slept 20ms wall clock and asserted on Date.now. - event-buffer.test.ts: sleep(60)/sleep(5) waited on the 15ms flush timer. - events.attribution.test.ts: walked and read the whole app twice, serially. Walk once in a beforeAll with batched concurrent reads. - sso-trust.test.ts: imported the Better Auth graph in each of three tests. Import once in a beforeAll that carries the 30s budget. - markdown-parse.test.ts: the one-shot helper built and destroyed a tiptap Editor per document; reuse one across the 400-seed property corpus. No production timing constants, corpus sizes, or assertions changed. --- .../markdown-parse.test.ts | 17 ++--- apps/sim/lib/auth/sso-trust.test.ts | 31 ++++---- .../hosted-key-rate-limiter.test.ts | 70 ++++++++++-------- apps/sim/lib/embeddings/client.test.ts | 10 ++- apps/sim/lib/execution/event-buffer.test.ts | 16 +++-- apps/sim/lib/table/events.attribution.test.ts | 71 +++++++++++++------ apps/sim/tools/index.test.ts | 36 +++++++--- 7 files changed, 163 insertions(+), 88 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts index 94c645fc2e2..92e2e1fafd9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ import { Editor } from '@tiptap/core' -import { afterEach, describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it } from 'vitest' import { createMarkdownContentExtensions } from './extensions' import { parseMarkdownToDoc, serializeMarkdownBody, splitMarkdownBlocks } from './markdown-parse' import { isRoundTripSafe } from './round-trip-safety' @@ -12,19 +12,20 @@ const isEmptyPara = (n: { type?: string; content?: unknown[] }): boolean => n.type === 'paragraph' && !n.content?.length let editor: Editor | null = null -afterEach(() => { +afterAll(() => { editor?.destroy() editor = null }) -/** The current whole-document path: parse markdown in one shot, serialize back. */ +/** + * The current whole-document path: parse markdown in one shot, serialize back. One editor serves + * every call — `setContent` replaces the document wholesale, so a fresh instance per call only adds + * the cost of building the view, which the property tests below paid hundreds of times over. + */ function oneShot(body: string): string { - editor = new Editor({ extensions: createMarkdownContentExtensions() }) + editor ??= new Editor({ extensions: createMarkdownContentExtensions() }) editor.commands.setContent(body, { contentType: 'markdown' }) - const out = editor.getMarkdown() - editor.destroy() - editor = null - return out + return editor.getMarkdown() } /** diff --git a/apps/sim/lib/auth/sso-trust.test.ts b/apps/sim/lib/auth/sso-trust.test.ts index 02b3f44a339..04bd99a943e 100644 --- a/apps/sim/lib/auth/sso-trust.test.ts +++ b/apps/sim/lib/auth/sso-trust.test.ts @@ -8,12 +8,7 @@ * domain-verification proof entirely. */ import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' -import { afterAll, expect, it, vi } from 'vitest' - -// Structurally slow — it imports the entire Better Auth module graph — so under a fully-parallel local run this file -// blows the default timeout while passing in isolation and on CI. Give it a -// real budget instead of letting machine load decide the verdict. -vi.setConfig({ testTimeout: 30_000 }) +import { afterAll, beforeAll, expect, it, vi } from 'vitest' const { ssoOptions } = vi.hoisted(() => ({ ssoOptions: { current: undefined as Record | undefined }, @@ -28,24 +23,30 @@ vi.mock('@better-auth/sso', () => ({ setEnvFlags({ isSsoEnabled: true }) -afterAll(resetEnvFlagsMock) - -it('never trusts the IdP-supplied email_verified claim for SSO linking', async () => { +/** + * Structurally slow — it imports the entire Better Auth module graph — so under + * a fully-parallel local run this import blows the default budget while passing + * in isolation and on CI. The plugin options are captured once at module + * evaluation, so every assertion reads the same object: import once, outside + * any per-test budget, with a real budget of its own instead of letting machine + * load decide the verdict. + */ +beforeAll(async () => { await import('@/lib/auth/auth') +}, 30_000) +afterAll(resetEnvFlagsMock) + +it('never trusts the IdP-supplied email_verified claim for SSO linking', () => { expect(ssoOptions.current).toBeDefined() expect(ssoOptions.current?.trustEmailVerified).toBe(false) }) -it('keeps domain verification as the sole SSO linking trust source', async () => { - await import('@/lib/auth/auth') - +it('keeps domain verification as the sole SSO linking trust source', () => { expect(ssoOptions.current?.domainVerification).toEqual({ enabled: true }) }) -it('disables Better Auth membership writes so Sim owns JIT admission', async () => { - await import('@/lib/auth/auth') - +it('disables Better Auth membership writes so Sim owns JIT admission', () => { expect(ssoOptions.current?.organizationProvisioning).toEqual({ disabled: true, defaultRole: 'member', diff --git a/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.test.ts b/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.test.ts index 9676a30f195..5a81f1d65ff 100644 --- a/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.test.ts +++ b/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.test.ts @@ -1,4 +1,3 @@ -import { sleep } from '@sim/utils/helpers' import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import type { ConsumeResult, @@ -314,17 +313,25 @@ describe('HostedKeyRateLimiter', () => { .mockResolvedValueOnce('waiting') .mockResolvedValueOnce('head') - const result = await rateLimiter.acquireKey( - testProvider, - envKeyPrefix, - perRequestRateLimit, - 'workspace-1' - ) + // Each "waiting" answer sleeps one real poll period; drive those with fake timers. + vi.useFakeTimers() + try { + const pending = rateLimiter.acquireKey( + testProvider, + envKeyPrefix, + perRequestRateLimit, + 'workspace-1' + ) + await vi.runAllTimersAsync() + const result = await pending - expect(result.success).toBe(true) - expect(mockQueue.checkHead).toHaveBeenCalledTimes(3) - // Bucket is only consumed once we reach the head. - expect(mockAdapter.consumeTokens).toHaveBeenCalledTimes(1) + expect(result.success).toBe(true) + expect(mockQueue.checkHead).toHaveBeenCalledTimes(3) + // Bucket is only consumed once we reach the head. + expect(mockAdapter.consumeTokens).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } }) it('refreshes the heartbeat while waiting at the head of the queue', async () => { @@ -428,24 +435,31 @@ describe('HostedKeyRateLimiter', () => { } mockAdapter.consumeTokens.mockResolvedValue(blocked) - const controller = new AbortController() - const start = Date.now() - const promise = rateLimiter.acquireKey( - testProvider, - envKeyPrefix, - perRequestRateLimit, - 'workspace-1', - controller.signal - ) - // Let the first bucket check run and the sleep begin, then abort. - await sleep(20) - controller.abort() - const result = await promise + vi.useFakeTimers() + try { + const controller = new AbortController() + const start = Date.now() + const promise = rateLimiter.acquireKey( + testProvider, + envKeyPrefix, + perRequestRateLimit, + 'workspace-1', + controller.signal + ) + // Let the first bucket check run and the sleep begin, then abort. No timer + // advances after the abort, so the wait can only settle by waking on it — + // a sleep that ran to its cap would leave the promise pending. + await vi.advanceTimersByTimeAsync(20) + controller.abort() + const result = await promise - expect(result.success).toBe(false) - expect(result.billingActorRateLimited).toBe(true) - // Resolved well before the 10s capped sleep would otherwise have elapsed. - expect(Date.now() - start).toBeLessThan(2000) + expect(result.success).toBe(false) + expect(result.billingActorRateLimited).toBe(true) + // Resolved well before the 10s capped sleep would otherwise have elapsed. + expect(Date.now() - start).toBeLessThan(HEARTBEAT_REFRESH_INTERVAL_MS) + } finally { + vi.useRealTimers() + } }) it('keeps waiting past the no-signal fallback cap while the signal is live', async () => { diff --git a/apps/sim/lib/embeddings/client.test.ts b/apps/sim/lib/embeddings/client.test.ts index cd19f32f156..054a83c8568 100644 --- a/apps/sim/lib/embeddings/client.test.ts +++ b/apps/sim/lib/embeddings/client.test.ts @@ -459,14 +459,17 @@ describe('embed', () => { }) it('retries a rate-limited request and succeeds on a later attempt', async () => { + vi.useFakeTimers() fetchMock .mockResolvedValueOnce(jsonResponse({ error: 'slow down' }, 429)) .mockResolvedValueOnce(jsonResponse(openAIBody([[7, 8]]))) - const result = await embed(['hello'], { + const pending = embed(['hello'], { model: 'text-embedding-3-small', apiKey: 'sk-test', }) + await vi.runAllTimersAsync() + const result = await pending expect(fetchMock).toHaveBeenCalledTimes(2) expect(result.embeddings[0].slice(0, 2)).toEqual([7, 8]) @@ -620,16 +623,19 @@ describe('embed', () => { }) it('projects once even when the request is retried', async () => { + vi.useFakeTimers() const projectInputs = vi.fn((values: readonly string[]) => values.map(() => 'projected')) fetchMock .mockResolvedValueOnce(jsonResponse({ error: 'rate limited' }, 429)) .mockResolvedValueOnce(jsonResponse(openAIBody([[1]]))) - await embed(['secret'], { + const pending = embed(['secret'], { model: 'text-embedding-3-small', apiKey: 'sk-test', projectInputs, }) + await vi.runAllTimersAsync() + await pending expect(fetchMock).toHaveBeenCalledTimes(2) expect(projectInputs).toHaveBeenCalledTimes(1) diff --git a/apps/sim/lib/execution/event-buffer.test.ts b/apps/sim/lib/execution/event-buffer.test.ts index 1659c4e675f..75d568c30f7 100644 --- a/apps/sim/lib/execution/event-buffer.test.ts +++ b/apps/sim/lib/execution/event-buffer.test.ts @@ -2,8 +2,7 @@ * @vitest-environment node */ import { redisConfigMockFns, resetEnvMock, resetRedisConfigMock, setEnv } from '@sim/testing' -import { sleep } from '@sim/utils/helpers' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionEventEntry } from '@/lib/execution/event-buffer' import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' import { LARGE_VALUE_REF_MARKER } from '@/lib/execution/payloads/large-value-ref' @@ -51,6 +50,10 @@ afterAll(() => { resetRedisConfigMock() }) +afterEach(() => { + vi.useRealTimers() +}) + import { createExecutionEventWriter, flushExecutionStreamReplayBuffer, @@ -654,15 +657,18 @@ describe('execution event buffer', () => { }) }) + vi.useFakeTimers() const writer = createExecutionEventWriter('exec-1') await writer.write(makeEvent('first')) + // The write only arms the flush timer; fire it so the flush is in flight. + await vi.runOnlyPendingTimersAsync() await firstFlushStarted const terminalWrite = writer.writeTerminal(makeEvent('terminal'), 'complete') // Let writeTerminal's queued body actually enqueue its entry before the // in-flight flush resolves — otherwise the scheduled loop finds nothing left // to drain and the race under test never forms. - await sleep(5) + await vi.advanceTimersByTimeAsync(5) releaseFirstFlush?.() await terminalWrite @@ -776,10 +782,12 @@ describe('execution event buffer', () => { return [1, 'ok', 0, 0] }) + vi.useFakeTimers() const writer = createExecutionEventWriter('exec-1') await writer.write(makeEvent('a')) - await sleep(60) + // Fire the scheduled flush (and any backoff it arms) before the caller's own. + await vi.runAllTimersAsync() await expect(writer.flush()).resolves.toBeUndefined() }) diff --git a/apps/sim/lib/table/events.attribution.test.ts b/apps/sim/lib/table/events.attribution.test.ts index 3ed09ce7135..eb47880f4cc 100644 --- a/apps/sim/lib/table/events.attribution.test.ts +++ b/apps/sim/lib/table/events.attribution.test.ts @@ -3,12 +3,7 @@ */ import { readdir, readFile } from 'node:fs/promises' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' - -// Structurally slow — it scans call sites across the repo — so under a fully-parallel local run this file -// blows the default timeout while passing in isolation and on CI. Give it a -// real budget instead of letting machine load decide the verdict. -vi.setConfig({ testTimeout: 30_000 }) +import { beforeAll, describe, expect, it } from 'vitest' /** * `signalTableRowsChangedByActor` lets the acting tab skip its own refetch, which is only sound @@ -51,24 +46,49 @@ const FORWARDING_MODULE = 'lib/table/application/rows.ts' */ const SUPPLIER_PATTERNS = [/actorClientId:/, /signalTableRowsChangedByActor\([^)]*,/] as const -async function* walk(dir: string): AsyncGenerator { - for (const entry of await readdir(dir, { withFileTypes: true })) { - if (entry.name === 'node_modules' || entry.name === '.next') continue - const full = join(dir, entry.name) - if (entry.isDirectory()) yield* walk(full) - else if (entry.name.endsWith('.ts') && !entry.name.includes('.test.')) yield full +/** Files read per batch; bounds open descriptors while keeping the disk busy. */ +const READ_BATCH_SIZE = 64 + +async function walk(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const nested = await Promise.all( + entries.map(async (entry) => { + if (entry.name === 'node_modules' || entry.name === '.next') return [] + const full = join(dir, entry.name) + if (entry.isDirectory()) return walk(full) + return entry.name.endsWith('.ts') && !entry.name.includes('.test.') ? [full] : [] + }) + ) + return nested.flat() +} + +/** + * Every source file under the app root, keyed by its relative path. Read once + * for the file: both sweeps scan the same tree, and walking it per test was the + * whole cost of this file. + */ +let sources: Map + +async function readSources(): Promise> { + const files = await walk(APP_ROOT) + const found = new Map() + for (let start = 0; start < files.length; start += READ_BATCH_SIZE) { + const batch = files.slice(start, start + READ_BATCH_SIZE) + const contents = await Promise.all(batch.map((file) => readFile(file, 'utf8'))) + batch.forEach((file, index) => { + found.set(file.slice(APP_ROOT.length + 1), contents[index]) + }) } + return found } -async function filesMatching( +function filesMatching( matches: (source: string) => boolean, skip: (relative: string) => boolean = () => false -) { +): string[] { const found: string[] = [] - for await (const file of walk(APP_ROOT)) { - const source = await readFile(file, 'utf8') + for (const [relative, source] of sources) { if (!matches(source)) continue - const relative = file.slice(APP_ROOT.length + 1) if (skip(relative)) continue found.push(relative) } @@ -76,8 +96,17 @@ async function filesMatching( } describe('signalTableRowsChangedByActor call sites', () => { - it('is called only where the acting tab reconciles the write locally', async () => { - const callers = await filesMatching( + /** + * Structurally slow — it reads every source file in the app — so under a + * fully-parallel local run the scan blows the default budget while passing in + * isolation and on CI. Give it a real budget of its own, outside any test's. + */ + beforeAll(async () => { + sources = await readSources() + }, 30_000) + + it('is called only where the acting tab reconciles the write locally', () => { + const callers = filesMatching( (source) => source.includes('signalTableRowsChangedByActor('), (relative) => relative === DECLARING_MODULE ) @@ -85,8 +114,8 @@ describe('signalTableRowsChangedByActor call sites', () => { expect(callers).toEqual([...ATTRIBUTED_CALL_SITES].sort()) }) - it('is given an actor only by surfaces whose client hook reconciles locally', async () => { - const suppliers = await filesMatching( + it('is given an actor only by surfaces whose client hook reconciles locally', () => { + const suppliers = filesMatching( (source) => SUPPLIER_PATTERNS.some((pattern) => pattern.test(source)), (relative) => relative === DECLARING_MODULE || relative === FORWARDING_MODULE ) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 10991a66bf7..720515fcff1 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -5107,12 +5107,28 @@ describe('MCP Tool Execution', () => { describe('Tool request retries', () => { beforeEach(() => { + vi.useFakeTimers() mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '93.184.216.34', }) }) + afterEach(() => { + vi.useRealTimers() + }) + + /** + * Runs the request with every retry backoff elapsed on the fake clock. A + * `retryDelayMs` of 0 falls through to the 500ms default, so the sleeps + * between attempts are real unless the clock is faked. + */ + async function executeWithRetries(params: Record) { + const pending = executeTool('http_request', params) + await vi.runAllTimersAsync() + return pending + } + function makeJsonResponse( status: number, body: unknown, @@ -5132,7 +5148,7 @@ describe('MCP Tool Execution', () => { .mockResolvedValueOnce(makeJsonResponse(500, { error: 'nope' })) .mockResolvedValueOnce(makeJsonResponse(200, { ok: true })) - const result = await executeTool('http_request', { + const result = await executeWithRetries({ url: 'https://api.example.com/test', method: 'GET', retries: 2, @@ -5150,7 +5166,7 @@ describe('MCP Tool Execution', () => { makeJsonResponse(500, { error: 'server error' }) ) - const result = await executeTool('http_request', { + const result = await executeWithRetries({ url: 'https://api.example.com/test', method: 'GET', }) @@ -5162,7 +5178,7 @@ describe('MCP Tool Execution', () => { it('stops retrying after max attempts for http_request', async () => { mockSecureFetchWithPinnedIP.mockResolvedValue(makeJsonResponse(502, { error: 'bad gateway' })) - const result = await executeTool('http_request', { + const result = await executeWithRetries({ url: 'https://api.example.com/test', method: 'GET', retries: 2, @@ -5177,7 +5193,7 @@ describe('MCP Tool Execution', () => { it('does not retry on 4xx responses for http_request', async () => { mockSecureFetchWithPinnedIP.mockResolvedValue(makeJsonResponse(400, { error: 'bad request' })) - const result = await executeTool('http_request', { + const result = await executeWithRetries({ url: 'https://api.example.com/test', method: 'GET', retries: 5, @@ -5194,7 +5210,7 @@ describe('MCP Tool Execution', () => { .mockResolvedValueOnce(makeJsonResponse(500, { error: 'nope' })) .mockResolvedValueOnce(makeJsonResponse(200, { ok: true })) - const result = await executeTool('http_request', { + const result = await executeWithRetries({ url: 'https://api.example.com/test', method: 'POST', retries: 2, @@ -5211,7 +5227,7 @@ describe('MCP Tool Execution', () => { .mockResolvedValueOnce(makeJsonResponse(500, { error: 'nope' })) .mockResolvedValueOnce(makeJsonResponse(200, { ok: true })) - const result = await executeTool('http_request', { + const result = await executeWithRetries({ url: 'https://api.example.com/test', method: 'POST', retries: 1, @@ -5232,7 +5248,7 @@ describe('MCP Tool Execution', () => { ) .mockResolvedValueOnce(makeJsonResponse(200, { ok: true })) - const result = await executeTool('http_request', { + const result = await executeWithRetries({ url: 'https://api.example.com/test', method: 'GET', retries: 3, @@ -5250,7 +5266,7 @@ describe('MCP Tool Execution', () => { ) .mockResolvedValueOnce(makeJsonResponse(200, { ok: true })) - const result = await executeTool('http_request', { + const result = await executeWithRetries({ url: 'https://api.example.com/test', method: 'GET', retries: 3, @@ -5268,7 +5284,7 @@ describe('MCP Tool Execution', () => { ) .mockResolvedValueOnce(makeJsonResponse(200, { ok: true })) - const result = await executeTool('http_request', { + const result = await executeWithRetries({ url: 'https://api.example.com/test', method: 'GET', retries: 2, @@ -5288,7 +5304,7 @@ describe('MCP Tool Execution', () => { .mockRejectedValueOnce(etimedoutError) .mockResolvedValueOnce(makeJsonResponse(200, { ok: true })) - const result = await executeTool('http_request', { + const result = await executeWithRetries({ url: 'https://api.example.com/test', method: 'GET', retries: 1, From aa19e68c6d23bcfa60d1d4ed2ec3a68c980b612e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 16:54:39 -0700 Subject: [PATCH 5/9] perf(tests): drop the executable tool registry and full block map from non-sweep tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vitest suite is bounded by module loading, and the biggest single cost is a test file opting out of the global `@/tools/registry` or `@/blocks/registry` mock. Trim that where the test never needed the whole thing: - `lib/internal/tool-operations/registry.server.test.ts` checked registration against `getToolIds()` but still imported the executable registry for one sweep. That sweep — every operation-backed tool has no `request` and a registered handler — is the in-process half of the transport partition, so it moves next to the external-URL sweep in `tools/request-transport.test.ts`, which already pays for the registry. Handler loads now run in parallel. Collect 12.5s -> 0.1s, run 6s -> 4.3s; request-transport unchanged (~8s). - New `partialBlockRegistry` in `@sim/testing/mocks/block-registry.mock` builds the `@/blocks/registry-maps` shape from the block modules a test actually reads, so the real registry code runs over those blocks alone. Applied to the six non-sweep tests that unmocked the block registry for one or two blocks (webhook, slack, whatsapp, condition/function, knowledge/start_trigger): collect 3-8s -> 0.05-2s each. Registry sweeps keep the real map. - `tools/index.test.ts` retry cases set `retryDelayMs: 0`, which the retry config replaces with the 500ms default; `1` is the smallest delay honored. Run 4.0s -> 0.45s. - `tools/metadata.test.ts` collects sweep offenders into one assertion instead of one `expect` per registry entry, and indexes ids once. Run 1.1s -> 0.03s. - The `vi.unmock('@/tools/registry')` in the search-replace tests was dead: nothing in their graph imports the registry any more, and all 106 tests pass without it, contrary to the comment that claimed eight would fail. --- apps/sim/executor/utils/block-data.test.ts | 7 ++- .../get-blocks-metadata-projection.test.ts | 9 +++- .../tool-operations/registry.server.test.ts | 35 +++++--------- .../workflows/canonical/reported-bug.test.ts | 7 ++- .../whatsapp-interactive-type.test.ts | 8 ++++ .../operations/import-export.test.ts | 12 +++++ .../workflows/sanitization/subblocks.test.ts | 11 +++++ .../workflows/search-replace/indexer.test.ts | 11 ----- .../search-replace/replacements.test.ts | 11 ----- apps/sim/tools/index.test.ts | 24 +++++----- apps/sim/tools/metadata.test.ts | 14 ++++-- apps/sim/tools/request-transport.test.ts | 31 +++++++++++- .../testing/src/mocks/block-registry.mock.ts | 47 +++++++++++++++++++ 13 files changed, 159 insertions(+), 68 deletions(-) create mode 100644 packages/testing/src/mocks/block-registry.mock.ts diff --git a/apps/sim/executor/utils/block-data.test.ts b/apps/sim/executor/utils/block-data.test.ts index 8acf7cf63e1..2dfbe9ec539 100644 --- a/apps/sim/executor/utils/block-data.test.ts +++ b/apps/sim/executor/utils/block-data.test.ts @@ -8,9 +8,14 @@ import type { SerializedBlock } from '@/serializer/types' /** * These assertions are about what the real block registry publishes, so the global stub — which - * returns one mock block with no outputs — would make every case here pass vacuously. + * returns one mock block with no outputs — would make every case here pass vacuously. Only the + * generic webhook block is read, so only it is registered. */ vi.unmock('@/blocks/registry') +vi.mock('@/blocks/registry-maps', async () => { + const { partialBlockRegistry } = await import('@sim/testing/mocks/block-registry.mock') + return partialBlockRegistry(await import('@/blocks/blocks/generic_webhook')) +}) function triggerBlock(type: string, params: Record = {}): SerializedBlock { return { diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts index a99a8090b63..a4e5ecc4297 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts @@ -9,11 +9,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' * off `@/tools/registry`. * * The sibling suite exercises this tool's gating against a mocked registry; this - * one runs it against the real block registry and the real generated tool + * one runs it against the real Slack block config and the real generated tool * metadata, because the thing worth proving is exactly that the metadata - * artifacts can answer everything the executable registry used to. + * artifacts can answer everything the executable registry used to. Only the + * Slack block is read, so only it is registered. */ vi.unmock('@/blocks/registry') +vi.mock('@/blocks/registry-maps', async () => { + const { partialBlockRegistry } = await import('@sim/testing/mocks/block-registry.mock') + return partialBlockRegistry(await import('@/blocks/blocks/slack')) +}) const mocks = vi.hoisted(() => ({ getUserPermissionConfig: vi.fn(), diff --git a/apps/sim/lib/internal/tool-operations/registry.server.test.ts b/apps/sim/lib/internal/tool-operations/registry.server.test.ts index 978bdc8c84e..04b446272c6 100644 --- a/apps/sim/lib/internal/tool-operations/registry.server.test.ts +++ b/apps/sim/lib/internal/tool-operations/registry.server.test.ts @@ -1,18 +1,21 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { getInternalToolOperationHandler, getRegisteredInternalToolOperationIds, isInternalToolOperationRegistered, } from '@/lib/internal/tool-operations/registry.server' -import { tools } from '@/tools/registry' import { getToolIds } from '@/tools/tool-ids' -import { isInternalToolConfig } from '@/tools/types' - -vi.unmock('@/tools/registry') +/** + * Registration is checked against the generated tool ids rather than the + * executable registry, whose import costs more than every handler load below + * combined. The converse — that every operation-backed tool in the registry has + * a handler here — is the in-process half of the transport partition sweep in + * `tools/request-transport.test.ts`, which already pays for that registry. + */ describe('internal tool operation registry', () => { it('registers only canonical internal tool definitions with loadable handlers', async () => { const registeredIds = getRegisteredInternalToolOperationIds() @@ -22,29 +25,15 @@ describe('internal tool operation registry', () => { for (const toolId of registeredIds) { expect(canonicalIds.has(toolId), `Missing canonical tool definition for ${toolId}`).toBe(true) - expect(await getInternalToolOperationHandler(toolId)).toBeTypeOf('function') + } + const handlers = await Promise.all(registeredIds.map(getInternalToolOperationHandler)) + for (const [index, handler] of handlers.entries()) { + expect(handler, `${registeredIds[index]} has no loadable handler`).toBeTypeOf('function') } // Cost scales with the number of registered internal tools, so this budget has to grow // with the registry rather than sit just above the current total. }, 90_000) - it('registers every operation-backed tool and keeps it free of HTTP request metadata', async () => { - const operationTools = Object.entries(tools).filter(([, tool]) => isInternalToolConfig(tool)) - - expect(operationTools.length).toBeGreaterThan(0) - for (const [toolId, tool] of operationTools) { - expect(tool.request, `${toolId} must not declare an HTTP request`).toBeUndefined() - expect(tool.operation.input, `${toolId} must materialize its operation input`).toBeTypeOf( - 'function' - ) - if (toolId === 'function_execute' || toolId === 'workflow_executor') continue - expect( - isInternalToolOperationRegistered(toolId), - `${toolId} is missing its in-process operation handler` - ).toBe(true) - } - }) - it('loads dynamic MCP operations without an HTTP route', async () => { expect(isInternalToolOperationRegistered('mcp-server-id-tool-name')).toBe(true) expect(await getInternalToolOperationHandler('mcp-server-id-tool-name')).toBeTypeOf('function') diff --git a/apps/sim/lib/workflows/canonical/reported-bug.test.ts b/apps/sim/lib/workflows/canonical/reported-bug.test.ts index 8232f244a56..0cae18ab05d 100644 --- a/apps/sim/lib/workflows/canonical/reported-bug.test.ts +++ b/apps/sim/lib/workflows/canonical/reported-bug.test.ts @@ -16,9 +16,14 @@ import { describe, expect, it, vi } from 'vitest' /** * The canonical form reads declared defaults, so the globally-mocked registry - * (every block reduced to `subBlocks: []`) would make this pass vacuously. + * (every block reduced to `subBlocks: []`) would make this pass vacuously. Only + * the webhook block is read, so only it is registered. */ vi.unmock('@/blocks/registry') +vi.mock('@/blocks/registry-maps', async () => { + const { partialBlockRegistry } = await import('@sim/testing/mocks/block-registry.mock') + return partialBlockRegistry(await import('@/blocks/blocks/generic_webhook')) +}) import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison/compare' import type { WorkflowState } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.test.ts b/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.test.ts index 87056804a6b..0b24fe2e408 100644 --- a/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.test.ts +++ b/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.test.ts @@ -4,7 +4,15 @@ import { afterAll, describe, expect, it, vi } from 'vitest' import type { BlockState } from '@/stores/workflows/workflow/types' +/** + * The backfill reads the WhatsApp block's declared sub-blocks, which the global + * registry stub empties. Only that block is registered. + */ vi.unmock('@/blocks/registry') +vi.mock('@/blocks/registry-maps', async () => { + const { partialBlockRegistry } = await import('@sim/testing/mocks/block-registry.mock') + return partialBlockRegistry(await import('@/blocks/blocks/whatsapp')) +}) import * as blocksBarrel from '@/blocks' import { getBlock as getRealBlock } from '@/blocks/registry' diff --git a/apps/sim/lib/workflows/operations/import-export.test.ts b/apps/sim/lib/workflows/operations/import-export.test.ts index d6c0ed0e2f9..26fa1968ad8 100644 --- a/apps/sim/lib/workflows/operations/import-export.test.ts +++ b/apps/sim/lib/workflows/operations/import-export.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it, vi } from 'vitest' +/** + * Import parsing migrates sub-block ids against each block's declared config, + * which the global registry stub empties. Only the blocks the fixtures name are + * registered. + */ vi.unmock('@/blocks/registry') +vi.mock('@/blocks/registry-maps', async () => { + const { partialBlockRegistry } = await import('@sim/testing/mocks/block-registry.mock') + return partialBlockRegistry( + await import('@/blocks/blocks/knowledge'), + await import('@/blocks/blocks/start_trigger') + ) +}) vi.mock('@/lib/api/client/request', () => ({ requestJson: vi.fn().mockResolvedValue({}), diff --git a/apps/sim/lib/workflows/sanitization/subblocks.test.ts b/apps/sim/lib/workflows/sanitization/subblocks.test.ts index 5dd15da3801..61f0e043bb0 100644 --- a/apps/sim/lib/workflows/sanitization/subblocks.test.ts +++ b/apps/sim/lib/workflows/sanitization/subblocks.test.ts @@ -3,7 +3,18 @@ */ import { describe, expect, it, vi } from 'vitest' +/** + * Sanitization reads each block's declared sub-block types, which the global + * registry stub empties. Only the blocks the cases below name are registered. + */ vi.unmock('@/blocks/registry') +vi.mock('@/blocks/registry-maps', async () => { + const { partialBlockRegistry } = await import('@sim/testing/mocks/block-registry.mock') + return partialBlockRegistry( + await import('@/blocks/blocks/condition'), + await import('@/blocks/blocks/function') + ) +}) import { migrateSubblockIds } from '@/lib/workflows/migrations/subblock-migrations' import { sanitizeMalformedSubBlocks } from '@/lib/workflows/sanitization/subblocks' diff --git a/apps/sim/lib/workflows/search-replace/indexer.test.ts b/apps/sim/lib/workflows/search-replace/indexer.test.ts index bc396bf4024..43e247c32f9 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.test.ts @@ -21,17 +21,6 @@ import { NoteBlock } from '@/blocks/blocks/note' vi.unmock('@/tools/metadata') vi.unmock('@/tools/metadata-outputs') -/** - * Uses the real tool registry. Nothing here imports it directly — the dependency - * is transitive: the search-replace planner resolves tool input params through - * real subblock configs, so the global `@/tools/registry` mock in - * vitest.setup.ts empties the data these assertions read. - * - * Not a no-op, despite the lack of a direct import. Dropping this opt-out fails - * 8 tests across this file and its sibling suite. - */ -vi.unmock('@/tools/registry') - describe('indexWorkflowSearchMatches', () => { it('marks generic tool-param fallbacks as non-authoritative', () => { expect( diff --git a/apps/sim/lib/workflows/search-replace/replacements.test.ts b/apps/sim/lib/workflows/search-replace/replacements.test.ts index 224ff18c628..752a0e86d44 100644 --- a/apps/sim/lib/workflows/search-replace/replacements.test.ts +++ b/apps/sim/lib/workflows/search-replace/replacements.test.ts @@ -17,17 +17,6 @@ import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replac vi.unmock('@/tools/metadata') vi.unmock('@/tools/metadata-outputs') -/** - * Uses the real tool registry. Nothing here imports it directly — the dependency - * is transitive: the search-replace planner resolves tool input params through - * real subblock configs, so the global `@/tools/registry` mock in - * vitest.setup.ts empties the data these assertions read. - * - * Not a no-op, despite the lack of a direct import. Dropping this opt-out fails - * 8 tests across this file and its sibling suite. - */ -vi.unmock('@/tools/registry') - describe('buildWorkflowSearchReplacePlan', () => { it('replaces selected text ranges across blocks without touching unselected matches', () => { const workflow = createSearchReplaceWorkflowFixture() diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 720515fcff1..211f6dca14c 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -5105,6 +5105,10 @@ describe('MCP Tool Execution', () => { expect(fetchSpy).not.toHaveBeenCalled() }) + /** + * `retryDelayMs: 1` rather than `0`: the retry config falls back to the 500 ms + * default for a falsy delay, so 1 ms is the smallest delay the tool honors. + */ describe('Tool request retries', () => { beforeEach(() => { vi.useFakeTimers() @@ -5118,11 +5122,7 @@ describe('MCP Tool Execution', () => { vi.useRealTimers() }) - /** - * Runs the request with every retry backoff elapsed on the fake clock. A - * `retryDelayMs` of 0 falls through to the 500ms default, so the sleeps - * between attempts are real unless the clock is faked. - */ + /** Runs the request with every retry backoff elapsed on the fake clock. */ async function executeWithRetries(params: Record) { const pending = executeTool('http_request', params) await vi.runAllTimersAsync() @@ -5152,7 +5152,7 @@ describe('MCP Tool Execution', () => { url: 'https://api.example.com/test', method: 'GET', retries: 2, - retryDelayMs: 0, + retryDelayMs: 1, retryMaxDelayMs: 0, }) @@ -5182,7 +5182,7 @@ describe('MCP Tool Execution', () => { url: 'https://api.example.com/test', method: 'GET', retries: 2, - retryDelayMs: 0, + retryDelayMs: 1, retryMaxDelayMs: 0, }) @@ -5197,7 +5197,7 @@ describe('MCP Tool Execution', () => { url: 'https://api.example.com/test', method: 'GET', retries: 5, - retryDelayMs: 0, + retryDelayMs: 1, retryMaxDelayMs: 0, }) @@ -5214,7 +5214,7 @@ describe('MCP Tool Execution', () => { url: 'https://api.example.com/test', method: 'POST', retries: 2, - retryDelayMs: 0, + retryDelayMs: 1, retryMaxDelayMs: 0, }) @@ -5232,7 +5232,7 @@ describe('MCP Tool Execution', () => { method: 'POST', retries: 1, retryNonIdempotent: true, - retryDelayMs: 0, + retryDelayMs: 1, retryMaxDelayMs: 0, }) @@ -5288,7 +5288,7 @@ describe('MCP Tool Execution', () => { url: 'https://api.example.com/test', method: 'GET', retries: 2, - retryDelayMs: 0, + retryDelayMs: 1, retryMaxDelayMs: 5000, }) @@ -5308,7 +5308,7 @@ describe('MCP Tool Execution', () => { url: 'https://api.example.com/test', method: 'GET', retries: 1, - retryDelayMs: 0, + retryDelayMs: 1, retryMaxDelayMs: 0, }) diff --git a/apps/sim/tools/metadata.test.ts b/apps/sim/tools/metadata.test.ts index 4f57faec179..56488179a8a 100644 --- a/apps/sim/tools/metadata.test.ts +++ b/apps/sim/tools/metadata.test.ts @@ -65,8 +65,9 @@ describe('generated tool metadata', () => { * Only a versioned id whose base name is *not* itself registered exercises * resolution — where both exist, the base name resolves to itself. */ + const toolIds = new Set(getToolIds()) const versionedId = getToolIds().find( - (id) => /_v[2-9]\d*$/.test(id) && !getToolIds().includes(id.replace(/_v\d+$/, '')) + (id) => /_v[2-9]\d*$/.test(id) && !toolIds.has(id.replace(/_v\d+$/, '')) ) it('has at least one versioned tool to exercise', () => { @@ -95,12 +96,13 @@ describe('generated tool metadata', () => { * consumers may iterate freely. */ it('contains no null param entries', () => { + const empty: string[] = [] for (const id of getToolIds()) { for (const [paramId, config] of Object.entries(getToolParams(id) ?? {})) { - expect(config, `${id}.${paramId} is empty`).not.toBeNull() - expect(config, `${id}.${paramId} is empty`).toBeDefined() + if (config === null || config === undefined) empty.push(`${id}.${paramId}`) } } + expect(empty).toEqual([]) }) /** @@ -108,16 +110,18 @@ describe('generated tool metadata', () => { * importing them cannot pull the tool implementations into a module graph. */ it('contains no function values', () => { + const functions: string[] = [] for (const id of getToolIds()) { const metadata = getToolMetadata(id) for (const [key, value] of Object.entries(metadata ?? {})) { - expect(typeof value, `${id}.${key} is a function`).not.toBe('function') + if (typeof value === 'function') functions.push(`${id}.${key}`) } for (const [paramId, config] of Object.entries(metadata?.params ?? {})) { for (const [key, value] of Object.entries(config ?? {})) { - expect(typeof value, `${id}.params.${paramId}.${key} is a function`).not.toBe('function') + if (typeof value === 'function') functions.push(`${id}.params.${paramId}.${key}`) } } } + expect(functions).toEqual([]) }) }) diff --git a/apps/sim/tools/request-transport.test.ts b/apps/sim/tools/request-transport.test.ts index 77a7c9ceefe..49645c56935 100644 --- a/apps/sim/tools/request-transport.test.ts +++ b/apps/sim/tools/request-transport.test.ts @@ -1,12 +1,22 @@ import { describe, expect, it, vi } from 'vitest' +import { isInternalToolOperationRegistered } from '@/lib/internal/tool-operations/registry.server' import { requestTool } from '@/tools/http/request' import { webhookRequestTool } from '@/tools/http/webhook_request' import { tools } from '@/tools/registry' import { prepareToolRequest } from '@/tools/request-transport' -import { isInternalToolConfig, type ToolConfig } from '@/tools/types' - +import { type InternalToolConfig, isInternalToolConfig, type ToolConfig } from '@/tools/types' + +/** + * Sweeps the executable registry, partitioned by transport: every tool either + * runs in-process through a registered operation handler or leaves through the + * external HTTP transport, and each half is checked against its own invariants. + * Both sweeps share this file because the registry import is the whole cost. + */ vi.unmock('@/tools/registry') +const operationTools = Object.entries(tools).filter( + (entry): entry is [string, InternalToolConfig] => isInternalToolConfig(entry[1]) +) const requestTools = Object.entries(tools).filter( (entry): entry is [string, ToolConfig] => !isInternalToolConfig(entry[1]) ) @@ -129,6 +139,23 @@ describe('external request transport', () => { }) }) +describe('in-process operation registry invariant', () => { + it('registers every operation-backed tool and keeps it free of HTTP request metadata', () => { + expect(operationTools.length).toBeGreaterThan(0) + for (const [toolId, tool] of operationTools) { + expect(tool.request, `${toolId} must not declare an HTTP request`).toBeUndefined() + expect(tool.operation.input, `${toolId} must materialize its operation input`).toBeTypeOf( + 'function' + ) + if (toolId === 'function_execute' || toolId === 'workflow_executor') continue + expect( + isInternalToolOperationRegistered(toolId), + `${toolId} is missing its in-process operation handler` + ).toBe(true) + } + }) +}) + describe('dynamic external request registry invariant', () => { it('covers every dynamic external request URL', () => { expect(dynamicRouteTools.length).toBeGreaterThan(0) diff --git a/packages/testing/src/mocks/block-registry.mock.ts b/packages/testing/src/mocks/block-registry.mock.ts new file mode 100644 index 00000000000..148dbf025db --- /dev/null +++ b/packages/testing/src/mocks/block-registry.mock.ts @@ -0,0 +1,47 @@ +/** + * Builds the `@/blocks/registry-maps` module shape from a handful of block modules. + * + * The real map imports every block module and, through them, the brand icon + * sheet and every tool the blocks reference — seconds of imports for each test + * file that unmocks `@/blocks/registry`. A test that exercises the real + * registry code (`getBlock`, version resolution, the overlay) against one or + * two specific blocks registers only those: + * + * @example + * ```ts + * vi.unmock('@/blocks/registry') + * vi.mock('@/blocks/registry-maps', async () => { + * const { partialBlockRegistry } = await import('@sim/testing/mocks/block-registry.mock') + * return partialBlockRegistry(await import('@/blocks/blocks/generic_webhook')) + * }) + * ``` + * + * Entries are keyed by `type`, which `blocks/blocks.test.ts` pins as equal to + * the real registry key. A `FooBlockMeta` export sitting next to `FooBlock` is + * registered under the same key. Sweeps over the whole registry must keep the + * real map: a partial one would pass vacuously over the blocks it omits. + */ +export function partialBlockRegistry( + ...modules: Array> +): { BLOCK_REGISTRY: Record; BLOCK_META_REGISTRY: Record } { + const BLOCK_REGISTRY: Record = {} + const BLOCK_META_REGISTRY: Record = {} + for (const mod of modules) { + for (const [exportName, value] of Object.entries(mod)) { + if (!isBlockLike(value)) continue + BLOCK_REGISTRY[value.type] = value as T + const meta = mod[`${exportName}Meta`] + if (meta !== undefined) BLOCK_META_REGISTRY[value.type] = meta + } + } + return { BLOCK_REGISTRY, BLOCK_META_REGISTRY } +} + +function isBlockLike(value: unknown): value is { type: string } { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { type?: unknown }).type === 'string' && + Array.isArray((value as { subBlocks?: unknown }).subBlocks) + ) +} From 15d9f2d972d7f0c15167a7aa2d82963752236f38 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 16:55:36 -0700 Subject: [PATCH 6/9] perf(tests): stop copilot handler tests from loading the executor, block, and trigger registries The router test stubs every handler implementation module so the handler map binds without loading the executor and block registry; the 90s timeout goes. Handler, VFS, payload, and request tests mock the execution-side leaves and catalog registries they never exercise. Source edges that dragged in barrels for a single symbol now import the defining module: the Mothership block icon from @sim/emcn/icons, contract schemas from their contract file, and autolayout constants from new @sim/workflow-renderer/dimensions and /note-content subpaths. lib/copilot: 141 files, per-file collect+run 74.2s -> 37.4s, wall 19.1s -> 15.0s router.test.ts: 15.1s -> 0.45s --- apps/sim/blocks/blocks/mothership.ts | 2 +- apps/sim/hooks/queries/environment.ts | 2 +- apps/sim/lib/copilot/chat/payload.test.ts | 3 ++ .../process-contents-log-projection.test.ts | 4 ++ .../sim/lib/copilot/request/go/stream.test.ts | 6 +++ .../copilot/request/handlers/handlers.test.ts | 6 +++ .../lifecycle/resume-leg-context.test.ts | 9 +++- apps/sim/lib/copilot/server/agent-url.ts | 2 +- .../lib/copilot/tool-executor/router.test.ts | 49 +++++++++++++++++-- .../tools/handlers/materialize-file.test.ts | 5 ++ .../tools/handlers/workflow/mutations.test.ts | 13 +++++ .../workflow/withheld-run-result.test.ts | 13 +++++ .../sim/lib/copilot/vfs/workspace-vfs.test.ts | 5 ++ apps/sim/lib/environment/api.ts | 2 +- .../application/execute-function.ts | 2 +- apps/sim/lib/guardrails/validation-client.ts | 5 +- .../lib/workflows/autolayout/containers.ts | 2 +- apps/sim/lib/workflows/autolayout/core.ts | 2 +- apps/sim/lib/workflows/autolayout/targeted.ts | 2 +- apps/sim/lib/workflows/autolayout/utils.ts | 4 +- .../blocks/deterministic-dimensions.ts | 2 +- packages/workflow-renderer/package.json | 8 +++ 22 files changed, 130 insertions(+), 18 deletions(-) diff --git a/apps/sim/blocks/blocks/mothership.ts b/apps/sim/blocks/blocks/mothership.ts index 6dfda04d027..e74c7f70f15 100644 --- a/apps/sim/blocks/blocks/mothership.ts +++ b/apps/sim/blocks/blocks/mothership.ts @@ -1,4 +1,4 @@ -import { Blimp } from '@sim/emcn' +import { Blimp } from '@sim/emcn/icons' import type { BlockConfig } from '@/blocks/types' import type { ToolResponse } from '@/tools/types' diff --git a/apps/sim/hooks/queries/environment.ts b/apps/sim/hooks/queries/environment.ts index a37d274cee9..5361a8a6328 100644 --- a/apps/sim/hooks/queries/environment.ts +++ b/apps/sim/hooks/queries/environment.ts @@ -6,7 +6,7 @@ import { removeWorkspaceEnvironmentContract, savePersonalEnvironmentContract, upsertWorkspaceEnvironmentContract, -} from '@/lib/api/contracts' +} from '@/lib/api/contracts/environment' import type { WorkspaceEnvironmentData } from '@/lib/environment/api' import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/environment/api' import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys' diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 4f7f3b7de8f..0906a56a5b3 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -59,6 +59,9 @@ vi.mock('@/tools/registry', () => ({ }, })) +/** Denied-operation projection walks the block map only for blocks the mocked tool list never names. */ +vi.mock('@/blocks/registry-maps', () => ({ BLOCK_REGISTRY: {}, BLOCK_META_REGISTRY: {} })) + vi.mock('@/tools/utils', () => ({ getLatestVersionTools: vi.fn((input) => input), stripVersionSuffix: vi.fn((toolId: string) => toolId), diff --git a/apps/sim/lib/copilot/chat/process-contents-log-projection.test.ts b/apps/sim/lib/copilot/chat/process-contents-log-projection.test.ts index e12f73bbde3..7c57beded3e 100644 --- a/apps/sim/lib/copilot/chat/process-contents-log-projection.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents-log-projection.test.ts @@ -21,12 +21,16 @@ import { permissionGroupScopeMockFns, resetPermissionGroupScopeMock, workflowAuthzMockFns, + workflowsUtilsMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ChatContext } from '@/stores/panel' vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +/** Folder listing is untouched by `@log` mentions; the real module drags in the block and trigger registries. */ +vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) + import { processContextsServer } from '@/lib/copilot/chat/process-contents' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index d27a1482891..7efe9457895 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -11,6 +11,12 @@ import { MothershipStreamV1ToolPhase, } from '@/lib/copilot/generated/mothership-stream-v1' +/** Table side effects are not exercised here, and the real module loads the table application layer. */ +vi.mock('@/lib/copilot/request/tools/tables', () => ({ + maybeWriteOutputToTable: vi.fn(async (_toolName, _params, result) => result), + maybeWriteReadCsvToTable: vi.fn(async (_toolName, _params, result) => result), +})) + vi.mock('@/lib/copilot/request/session', async () => { const actual = await vi.importActual( '@/lib/copilot/request/session' diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 8111871bb79..5e3b77b2427 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -65,6 +65,12 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ claimWorkflowToolExecution, })) +/** Table side effects are not exercised here, and the real module loads the table application layer. */ +vi.mock('@/lib/copilot/request/tools/tables', () => ({ + maybeWriteOutputToTable: vi.fn(async (_toolName, _params, result) => result), + maybeWriteReadCsvToTable: vi.fn(async (_toolName, _params, result) => result), +})) + vi.mock('@/lib/copilot/request/tools/client', () => ({ waitForClientToolCompletion, waitForToolCompletion, diff --git a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts index 37fff6f90c7..8ba55a24485 100644 --- a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts @@ -1,6 +1,13 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { MothershipStreamV1CompletionStatus } from '@/lib/copilot/generated/mothership-stream-v1' import { createStreamingContext } from '@/lib/copilot/request/context/request-context' + +/** Table side effects are not exercised here, and the real module loads the table application layer. */ +vi.mock('@/lib/copilot/request/tools/tables', () => ({ + maybeWriteOutputToTable: vi.fn(async (_toolName, _params, result) => result), + maybeWriteReadCsvToTable: vi.fn(async (_toolName, _params, result) => result), +})) + import { makeResumeLegContext, mergeResumeLegOutputs } from '@/lib/copilot/request/lifecycle/run' // Guards the makeResumeLegContext / mergeResumeLegOutputs contract: the two MUST diff --git a/apps/sim/lib/copilot/server/agent-url.ts b/apps/sim/lib/copilot/server/agent-url.ts index efcdfc01637..31264868d1a 100644 --- a/apps/sim/lib/copilot/server/agent-url.ts +++ b/apps/sim/lib/copilot/server/agent-url.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { settings, user } from '@sim/db/schema' import { eq } from 'drizzle-orm' -import { type MothershipEnvironment, mothershipEnvironmentSchema } from '@/lib/api/contracts' +import { type MothershipEnvironment, mothershipEnvironmentSchema } from '@/lib/api/contracts/user' import { SIM_AGENT_API_URL, SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants' import { env } from '@/lib/core/config/env' diff --git a/apps/sim/lib/copilot/tool-executor/router.test.ts b/apps/sim/lib/copilot/tool-executor/router.test.ts index 700ec190b6f..bf487d5d3fb 100644 --- a/apps/sim/lib/copilot/tool-executor/router.test.ts +++ b/apps/sim/lib/copilot/tool-executor/router.test.ts @@ -2,7 +2,50 @@ * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +/** + * The handler map is a wiring table from tool id to implementation. Only its + * shape is asserted here, so every implementation module it imports is stubbed + * — loading them for real reaches the block registry, the executor, and most + * of `lib/`. Every export resolves to a mock function, which is all the table + * needs to bind. + */ +const { stubHandlerModule } = vi.hoisted(() => ({ + stubHandlerModule: () => + new Proxy( + {}, + { + get: (_target, name) => (typeof name === 'string' && name !== 'then' ? vi.fn() : undefined), + has: (_target, name) => typeof name === 'string' && name !== 'then', + } + ), +})) + +vi.mock('@/lib/copilot/tools/handlers/deployment/custom-block', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/deployment/deploy', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/deployment/manage', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/function-execute', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/integration-tools', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/management/connect-slack-bot', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/management/manage-credential', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/management/manage-custom-tool', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/management/manage-mcp-tool', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/management/manage-sandbox', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/management/manage-skill', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/materialize-file', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/oauth', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/resources', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/restore-resource', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/run-code', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/vfs', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/vfs-mutate', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/workflow/mutations', stubHandlerModule) +vi.mock('@/lib/copilot/tools/handlers/workflow/queries', stubHandlerModule) + +/** Server-router tools are appended to the map from their own registry, which this test does not cover. */ +vi.mock('@/lib/copilot/tools/server/router', () => ({ getRegisteredServerToolNames: () => [] })) + import { hasHandler } from '@/lib/copilot/tool-executor/executor' import { ensureHandlersRegistered } from '@/lib/copilot/tool-executor/register-handlers' import { @@ -21,11 +64,9 @@ describe('workflow-run cancellation tool routing', () => { expect(toolRequiresApproval('cancel_workflow_run')).toBe(true) }) - // Registration loads the whole handler map on first use, which is most of - // `lib/` — well past the default 10s under a fully parallel run. it('registers the Sim cancellation handler', async () => { await ensureHandlersRegistered() expect(hasHandler('cancel_workflow_run')).toBe(true) - }, 90_000) + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts index ae1b44dab75..b84a7610b6a 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -108,6 +108,11 @@ vi.mock('@/lib/copilot/vfs/path-utils', () => ({ })) vi.mock('@/lib/workflows/operations/import-export', () => ({ parseWorkflowJson: vi.fn() })) +/** Only the import size cap is read from `import-workflow`; its orchestration dependency is the whole deploy graph. */ +vi.mock('@/lib/workflows/orchestration', () => ({ + performCreateWorkflow: vi.fn(), + performCreateWorkflowTransition: vi.fn(), +})) vi.mock('@/lib/workflows/persistence/utils', () => ({ saveWorkflowToNormalizedTables: vi.fn() })) vi.mock('@/lib/workflows/utils', () => ({ deduplicateWorkflowName: vi.fn() })) vi.mock('@/app/api/v1/admin/types', () => ({ extractWorkflowMetadata: vi.fn() })) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts index 289bed4d35b..f7f5b13be2d 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts @@ -29,6 +29,19 @@ vi.mock('@/lib/workflows/sanitization/json-sanitizer', () => ({ sanitizeForCopilot: vi.fn((state) => state), })) +/** + * The use cases these handlers dispatch are only passed through to the mocked + * use-case executor above, so their execution-side leaves — the workflow + * executor, the paused-run manager, and deployment orchestration — are stubbed + * rather than loaded. + */ +vi.mock('@/lib/workflows/executor/execute-workflow', () => ({ executeWorkflow: vi.fn() })) +vi.mock('@/lib/execution/cancel-workflow-execution', () => ({ + cancelWorkflowExecution: vi.fn(), + WorkflowExecutionNotFoundError: class WorkflowExecutionNotFoundError extends Error {}, +})) +vi.mock('@/lib/workflows/orchestration', () => ({ performCreateWorkflowTransition: vi.fn() })) + vi.mock('@/executor/utils/errors', () => ({ hasExecutionResult: mocks.hasExecutionResult, readAttemptedExecutionId: mocks.readAttemptedExecutionId, diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/withheld-run-result.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/withheld-run-result.test.ts index 793a3185a66..e11564513e6 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/withheld-run-result.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/withheld-run-result.test.ts @@ -37,6 +37,19 @@ vi.mock('@/lib/workflows/sanitization/json-sanitizer', () => ({ sanitizeForCopilot: vi.fn((state) => state), })) +/** + * The use cases these handlers dispatch are only passed through to the mocked + * use-case executor above, so their execution-side leaves — the workflow + * executor, the paused-run manager, and deployment orchestration — are stubbed + * rather than loaded. + */ +vi.mock('@/lib/workflows/executor/execute-workflow', () => ({ executeWorkflow: vi.fn() })) +vi.mock('@/lib/execution/cancel-workflow-execution', () => ({ + cancelWorkflowExecution: vi.fn(), + WorkflowExecutionNotFoundError: class WorkflowExecutionNotFoundError extends Error {}, +})) +vi.mock('@/lib/workflows/orchestration', () => ({ performCreateWorkflowTransition: vi.fn() })) + vi.mock('@/lib/core/telemetry', () => ({ PlatformEvents: { apiKeyGenerated: vi.fn() } })) import { executeRunWorkflow } from '@/lib/copilot/tools/handlers/workflow/mutations' diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts index 313d7bfecc6..1d71c652022 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts @@ -43,6 +43,11 @@ vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ listCustomBlocksWithInputsForWorkspace, })) +/** None of these suites list catalog entries, and each real registry loads every definition it holds. */ +vi.mock('@/blocks/registry-maps', () => ({ BLOCK_REGISTRY: {}, BLOCK_META_REGISTRY: {} })) +vi.mock('@/connectors/registry.server', () => ({ CONNECTOR_REGISTRY: {} })) +vi.mock('@/triggers/registry', () => ({ TRIGGER_REGISTRY: {} })) + import { WorkspaceVFS } from '@/lib/copilot/vfs/workspace-vfs' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' diff --git a/apps/sim/lib/environment/api.ts b/apps/sim/lib/environment/api.ts index af78dc71c26..3fe45abf5d4 100644 --- a/apps/sim/lib/environment/api.ts +++ b/apps/sim/lib/environment/api.ts @@ -5,7 +5,7 @@ import { getPersonalEnvironmentContract, getWorkspaceEnvironmentContract, type workspaceEnvironmentDataSchema, -} from '@/lib/api/contracts' +} from '@/lib/api/contracts/environment' export type EnvironmentVariable = z.output diff --git a/apps/sim/lib/function-execution/application/execute-function.ts b/apps/sim/lib/function-execution/application/execute-function.ts index da18f303abe..66b73058abe 100644 --- a/apps/sim/lib/function-execution/application/execute-function.ts +++ b/apps/sim/lib/function-execution/application/execute-function.ts @@ -1,5 +1,5 @@ import { resolvePrincipalAttribution, resolvePrincipalSubject } from '@sim/auth/principal' -import { type FunctionExecuteBody, functionExecuteBodySchema } from '@/lib/api/contracts' +import { type FunctionExecuteBody, functionExecuteBodySchema } from '@/lib/api/contracts/hotspots' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { functionExecutionDelegationPolicy } from '@/lib/function-execution/application/authorization' diff --git a/apps/sim/lib/guardrails/validation-client.ts b/apps/sim/lib/guardrails/validation-client.ts index 51e2a8c0f3b..2061040f7ee 100644 --- a/apps/sim/lib/guardrails/validation-client.ts +++ b/apps/sim/lib/guardrails/validation-client.ts @@ -1,9 +1,10 @@ -import type { GuardrailsPiiValidateBody, GuardrailsPiiValidateResult } from '@/lib/api/contracts' import { + type GuardrailsPiiValidateBody, + type GuardrailsPiiValidateResult, guardrailsPiiValidateBodySchema, guardrailsPiiValidateContract, guardrailsPiiValidateResponseSchema, -} from '@/lib/api/contracts' +} from '@/lib/api/contracts/hotspots' import { generateInternalToken } from '@/lib/auth/internal' import { DEFAULT_MAX_ERROR_BODY_BYTES, diff --git a/apps/sim/lib/workflows/autolayout/containers.ts b/apps/sim/lib/workflows/autolayout/containers.ts index f3b7a244199..f56302aca82 100644 --- a/apps/sim/lib/workflows/autolayout/containers.ts +++ b/apps/sim/lib/workflows/autolayout/containers.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { CONTAINER_DIMENSIONS } from '@sim/workflow-renderer' +import { CONTAINER_DIMENSIONS } from '@sim/workflow-renderer/dimensions' import { CONTAINER_PADDING_X, CONTAINER_PADDING_Y, diff --git a/apps/sim/lib/workflows/autolayout/core.ts b/apps/sim/lib/workflows/autolayout/core.ts index 5f827f76a7d..05cfb2a42a3 100644 --- a/apps/sim/lib/workflows/autolayout/core.ts +++ b/apps/sim/lib/workflows/autolayout/core.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { HANDLE_POSITIONS } from '@sim/workflow-renderer' +import { HANDLE_POSITIONS } from '@sim/workflow-renderer/dimensions' import { CONTAINER_LAYOUT_OPTIONS, DEFAULT_LAYOUT_OPTIONS, diff --git a/apps/sim/lib/workflows/autolayout/targeted.ts b/apps/sim/lib/workflows/autolayout/targeted.ts index bf09c74cfed..245cf4b54f8 100644 --- a/apps/sim/lib/workflows/autolayout/targeted.ts +++ b/apps/sim/lib/workflows/autolayout/targeted.ts @@ -1,4 +1,4 @@ -import { CONTAINER_DIMENSIONS } from '@sim/workflow-renderer' +import { CONTAINER_DIMENSIONS } from '@sim/workflow-renderer/dimensions' import { CONTAINER_PADDING, DEFAULT_HORIZONTAL_SPACING, diff --git a/apps/sim/lib/workflows/autolayout/utils.ts b/apps/sim/lib/workflows/autolayout/utils.ts index bccedd3cdd6..91e9eefba2d 100644 --- a/apps/sim/lib/workflows/autolayout/utils.ts +++ b/apps/sim/lib/workflows/autolayout/utils.ts @@ -3,8 +3,8 @@ import { CONTAINER_DIMENSIONS, clampNoteBlockTotalHeight, getNoteBlockHeight, - isNoteContentEmpty, -} from '@sim/workflow-renderer' +} from '@sim/workflow-renderer/dimensions' +import { isNoteContentEmpty } from '@sim/workflow-renderer/note-content' import { AUTO_LAYOUT_EXCLUDED_TYPES, CONTAINER_BLOCK_TYPES, diff --git a/apps/sim/lib/workflows/blocks/deterministic-dimensions.ts b/apps/sim/lib/workflows/blocks/deterministic-dimensions.ts index 87ea892767c..9af3b6fc178 100644 --- a/apps/sim/lib/workflows/blocks/deterministic-dimensions.ts +++ b/apps/sim/lib/workflows/blocks/deterministic-dimensions.ts @@ -1,4 +1,4 @@ -import { BLOCK_DIMENSIONS } from '@sim/workflow-renderer' +import { BLOCK_DIMENSIONS } from '@sim/workflow-renderer/dimensions' interface WorkflowBlockDimensionsInput { blockType: string diff --git a/packages/workflow-renderer/package.json b/packages/workflow-renderer/package.json index ce0d2462630..1b7105b2d51 100644 --- a/packages/workflow-renderer/package.json +++ b/packages/workflow-renderer/package.json @@ -17,9 +17,17 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, + "./dimensions": { + "types": "./src/dimensions.ts", + "default": "./src/dimensions.ts" + }, "./note-colors": { "types": "./src/note/note-colors.ts", "default": "./src/note/note-colors.ts" + }, + "./note-content": { + "types": "./src/note/note-content.ts", + "default": "./src/note/note-content.ts" } }, "scripts": { From 1c3707de4467d6257c27da94a10814aae23dd540 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 17:01:27 -0700 Subject: [PATCH 7/9] fix(tests): import ContractBodyInput from the contract types module --- apps/sim/hooks/queries/environment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/hooks/queries/environment.ts b/apps/sim/hooks/queries/environment.ts index 5361a8a6328..3152276b492 100644 --- a/apps/sim/hooks/queries/environment.ts +++ b/apps/sim/hooks/queries/environment.ts @@ -2,11 +2,11 @@ import { createLogger } from '@sim/logger' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { - type ContractBodyInput, removeWorkspaceEnvironmentContract, savePersonalEnvironmentContract, upsertWorkspaceEnvironmentContract, } from '@/lib/api/contracts/environment' +import type { ContractBodyInput } from '@/lib/api/contracts/types' import type { WorkspaceEnvironmentData } from '@/lib/environment/api' import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/environment/api' import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys' From ad61fffb641917d54add32d96a821a0175b5bc5f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 17:21:02 -0700 Subject: [PATCH 8/9] fix(tests): bind the real cancellation handler in the router test; mock the narrowed contract path --- apps/sim/lib/copilot/server/agent-url.test.ts | 2 +- apps/sim/lib/copilot/tool-executor/router.test.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/copilot/server/agent-url.test.ts b/apps/sim/lib/copilot/server/agent-url.test.ts index 5cdc9ce2f91..91e795d5f83 100644 --- a/apps/sim/lib/copilot/server/agent-url.test.ts +++ b/apps/sim/lib/copilot/server/agent-url.test.ts @@ -16,7 +16,7 @@ const { envMock } = vi.hoisted(() => ({ }, })) -vi.mock('@/lib/api/contracts', () => ({ +vi.mock('@/lib/api/contracts/user', () => ({ mothershipEnvironmentSchema: { safeParse: (value: unknown) => ['default', 'dev', 'staging', 'prod'].includes(String(value)) diff --git a/apps/sim/lib/copilot/tool-executor/router.test.ts b/apps/sim/lib/copilot/tool-executor/router.test.ts index bf487d5d3fb..a0a980fbad3 100644 --- a/apps/sim/lib/copilot/tool-executor/router.test.ts +++ b/apps/sim/lib/copilot/tool-executor/router.test.ts @@ -7,9 +7,11 @@ import { describe, expect, it, vi } from 'vitest' /** * The handler map is a wiring table from tool id to implementation. Only its * shape is asserted here, so every implementation module it imports is stubbed - * — loading them for real reaches the block registry, the executor, and most - * of `lib/`. Every export resolves to a mock function, which is all the table - * needs to bind. + * except `workflow/mutations`, which holds the cancellation handler under test + * and loads for real so a renamed or removed export fails at link time. + * Loading the rest reaches the block registry, the executor, and most of + * `lib/`; every stubbed export resolves to a mock function, which is all the + * table needs to bind. */ const { stubHandlerModule } = vi.hoisted(() => ({ stubHandlerModule: () => @@ -40,19 +42,20 @@ vi.mock('@/lib/copilot/tools/handlers/restore-resource', stubHandlerModule) vi.mock('@/lib/copilot/tools/handlers/run-code', stubHandlerModule) vi.mock('@/lib/copilot/tools/handlers/vfs', stubHandlerModule) vi.mock('@/lib/copilot/tools/handlers/vfs-mutate', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/workflow/mutations', stubHandlerModule) vi.mock('@/lib/copilot/tools/handlers/workflow/queries', stubHandlerModule) /** Server-router tools are appended to the map from their own registry, which this test does not cover. */ vi.mock('@/lib/copilot/tools/server/router', () => ({ getRegisteredServerToolNames: () => [] })) import { hasHandler } from '@/lib/copilot/tool-executor/executor' +import { buildHandlerMap } from '@/lib/copilot/tool-executor/handler-map' import { ensureHandlersRegistered } from '@/lib/copilot/tool-executor/register-handlers' import { getToolEntry, isSimExecuted, toolRequiresApproval, } from '@/lib/copilot/tool-executor/router' +import { executeCancelWorkflowRun } from '@/lib/copilot/tools/handlers/workflow/mutations' describe('workflow-run cancellation tool routing', () => { it('routes cancellation through Sim with write permission and explicit approval', () => { @@ -68,5 +71,7 @@ describe('workflow-run cancellation tool routing', () => { await ensureHandlersRegistered() expect(hasHandler('cancel_workflow_run')).toBe(true) + expect(executeCancelWorkflowRun).toBeTypeOf('function') + expect(buildHandlerMap().cancel_workflow_run).toBe(executeCancelWorkflowRun) }) }) From 59402a9f0961490c7c56b714ab57912d0df4c9eb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 17:27:14 -0700 Subject: [PATCH 9/9] fix(emcn): wait for focus restoration instead of a fixed 20ms sleep in the modal test --- packages/emcn/src/components/modal/modal.test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/emcn/src/components/modal/modal.test.tsx b/packages/emcn/src/components/modal/modal.test.tsx index 8f188267bbe..32762f6c1ef 100644 --- a/packages/emcn/src/components/modal/modal.test.tsx +++ b/packages/emcn/src/components/modal/modal.test.tsx @@ -2,7 +2,6 @@ * @vitest-environment jsdom */ import { act, type ReactNode, useState } from 'react' -import { sleep } from '@sim/utils/helpers' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -248,9 +247,8 @@ describe('native-surface modal preparation', () => { if (!close) throw new Error('Modal close button did not render') await act(async () => { close.click() - await sleep(20) }) - expect(document.activeElement).toBe(trigger) + await vi.waitFor(() => expect(document.activeElement).toBe(trigger)) }) it('fails closed when a registered preparation rejects', async () => {