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 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/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]/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/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/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/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/hooks/queries/environment.ts b/apps/sim/hooks/queries/environment.ts index a37d274cee9..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' +} 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' 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/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/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.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/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..a0a980fbad3 100644 --- a/apps/sim/lib/copilot/tool-executor/router.test.ts +++ b/apps/sim/lib/copilot/tool-executor/router.test.ts @@ -2,14 +2,60 @@ * @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 + * 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: () => + 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/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', () => { @@ -21,11 +67,11 @@ 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) + expect(executeCancelWorkflowRun).toBeTypeOf('function') + expect(buildHandlerMap().cancel_workflow_run).toBe(executeCancelWorkflowRun) + }) }) 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/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/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/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/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/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/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/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/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/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/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/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 * diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 10991a66bf7..211f6dca14c 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -5105,14 +5105,30 @@ 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() mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '93.184.216.34', }) }) + afterEach(() => { + vi.useRealTimers() + }) + + /** 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() + return pending + } + function makeJsonResponse( status: number, body: unknown, @@ -5132,11 +5148,11 @@ 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, - retryDelayMs: 0, + retryDelayMs: 1, retryMaxDelayMs: 0, }) @@ -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,11 +5178,11 @@ 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, - retryDelayMs: 0, + retryDelayMs: 1, retryMaxDelayMs: 0, }) @@ -5177,11 +5193,11 @@ 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, - retryDelayMs: 0, + retryDelayMs: 1, retryMaxDelayMs: 0, }) @@ -5194,11 +5210,11 @@ 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, - retryDelayMs: 0, + retryDelayMs: 1, retryMaxDelayMs: 0, }) @@ -5211,12 +5227,12 @@ 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, retryNonIdempotent: true, - retryDelayMs: 0, + retryDelayMs: 1, retryMaxDelayMs: 0, }) @@ -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,11 +5284,11 @@ 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, - retryDelayMs: 0, + retryDelayMs: 1, retryMaxDelayMs: 5000, }) @@ -5288,11 +5304,11 @@ 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, - 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/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", 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 () => { 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) + ) +} 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": {