chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,159 @@
import { expect } from 'vitest'
import type { ExecutionContext } from '../types'
/**
* Asserts that a block was executed.
*
* @example
* ```ts
* expectBlockExecuted(ctx, 'block-1')
* ```
*/
export function expectBlockExecuted(ctx: ExecutionContext, blockId: string): void {
expect(ctx.executedBlocks.has(blockId), `Block "${blockId}" should have been executed`).toBe(true)
}
/**
* Asserts that a block was NOT executed.
*
* @example
* ```ts
* expectBlockNotExecuted(ctx, 'skipped-block')
* ```
*/
export function expectBlockNotExecuted(ctx: ExecutionContext, blockId: string): void {
expect(ctx.executedBlocks.has(blockId), `Block "${blockId}" should not have been executed`).toBe(
false
)
}
/**
* Asserts that blocks were executed in a specific order.
*
* @example
* ```ts
* expectExecutionOrder(executionLog, ['start', 'step1', 'step2', 'end'])
* ```
*/
export function expectExecutionOrder(executedBlocks: string[], expectedOrder: string[]): void {
const actualOrder = executedBlocks.filter((id) => expectedOrder.includes(id))
expect(actualOrder, 'Blocks should be executed in expected order').toEqual(expectedOrder)
}
/**
* Asserts that a block has a specific output state.
*
* @example
* ```ts
* expectBlockOutput(ctx, 'agent-1', { response: 'Hello' })
* ```
*/
export function expectBlockOutput(
ctx: ExecutionContext,
blockId: string,
expectedOutput: Record<string, any>
): void {
const state = ctx.blockStates.get(blockId)
expect(state, `Block "${blockId}" should have state`).toBeDefined()
expect(state).toMatchObject(expectedOutput)
}
/**
* Asserts that execution has a specific number of logs.
*
* @example
* ```ts
* expectLogCount(ctx, 5)
* ```
*/
export function expectLogCount(ctx: ExecutionContext, expectedCount: number): void {
expect(ctx.blockLogs.length, `Should have ${expectedCount} logs`).toBe(expectedCount)
}
/**
* Asserts that a condition decision was made.
*
* @example
* ```ts
* expectConditionDecision(ctx, 'condition-1', true)
* ```
*/
export function expectConditionDecision(
ctx: ExecutionContext,
blockId: string,
expectedResult: boolean
): void {
const decision = ctx.decisions.condition.get(blockId)
expect(decision, `Condition "${blockId}" should have a decision`).toBeDefined()
expect(decision).toBe(expectedResult)
}
/**
* Asserts that a loop was completed.
*
* @example
* ```ts
* expectLoopCompleted(ctx, 'loop-1')
* ```
*/
export function expectLoopCompleted(ctx: ExecutionContext, loopId: string): void {
expect(ctx.completedLoops.has(loopId), `Loop "${loopId}" should be completed`).toBe(true)
}
/**
* Asserts that a block is in the active execution path.
*
* @example
* ```ts
* expectInActivePath(ctx, 'current-block')
* ```
*/
export function expectInActivePath(ctx: ExecutionContext, blockId: string): void {
expect(ctx.activeExecutionPath.has(blockId), `Block "${blockId}" should be in active path`).toBe(
true
)
}
/**
* Asserts that execution was cancelled.
*
* @example
* ```ts
* expectExecutionCancelled(ctx)
* ```
*/
export function expectExecutionCancelled(ctx: ExecutionContext): void {
expect(ctx.abortSignal?.aborted, 'Execution should be cancelled').toBe(true)
}
/**
* Asserts that execution was NOT cancelled.
*
* @example
* ```ts
* expectExecutionNotCancelled(ctx)
* ```
*/
export function expectExecutionNotCancelled(ctx: ExecutionContext): void {
expect(ctx.abortSignal?.aborted ?? false, 'Execution should not be cancelled').toBe(false)
}
/**
* Asserts that execution has specific environment variables.
*
* @example
* ```ts
* expectEnvironmentVariables(ctx, { API_KEY: 'test', MODE: 'production' })
* ```
*/
export function expectEnvironmentVariables(
ctx: ExecutionContext,
expectedVars: Record<string, string>
): void {
Object.entries(expectedVars).forEach(([key, value]) => {
expect(
ctx.environmentVariables[key],
`Environment variable "${key}" should be "${value}"`
).toBe(value)
})
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Custom assertions for testing workflows and execution.
*
* These provide semantic, readable assertions for common test scenarios.
*
* @example
* ```ts
* import {
* expectBlockExists,
* expectEdgeConnects,
* expectExecutionOrder,
* } from '@sim/testing/assertions'
*
* // Workflow assertions
* expectBlockExists(workflow.blocks, 'agent-1', 'agent')
* expectEdgeConnects(workflow.edges, 'start', 'agent-1')
*
* // Execution assertions
* expectBlockExecuted(ctx, 'agent-1')
* expectExecutionOrder(log, ['start', 'agent-1', 'end'])
* ```
*/
// Execution assertions
export {
expectBlockExecuted,
expectBlockNotExecuted,
expectBlockOutput,
expectConditionDecision,
expectEnvironmentVariables,
expectExecutionCancelled,
expectExecutionNotCancelled,
expectExecutionOrder,
expectInActivePath,
expectLogCount,
expectLoopCompleted,
} from './execution.assertions'
// Permission assertions
export {
expectApiKeyInvalid,
expectApiKeyValid,
expectPermissionAllowed,
expectPermissionDenied,
expectRoleCannotPerform,
expectRoleCanPerform,
expectSocketAccessDenied,
expectSocketAccessGranted,
expectUserHasNoPermission,
expectUserHasPermission,
expectWorkflowAccessDenied,
expectWorkflowAccessGranted,
} from './permission.assertions'
// Workflow assertions
export {
expectBlockCount,
expectBlockDisabled,
expectBlockEnabled,
expectBlockExists,
expectBlockHasParent,
expectBlockNotExists,
expectBlockPosition,
expectEdgeConnects,
expectEdgeCount,
expectEmptyWorkflow,
expectLinearChain,
expectLoopExists,
expectNoEdgeBetween,
expectParallelExists,
} from './workflow.assertions'
@@ -0,0 +1,144 @@
import { expect } from 'vitest'
import type { PermissionType } from '../factories/permission.factory'
/**
* Asserts that a permission check result is allowed.
*/
export function expectPermissionAllowed(result: { allowed: boolean; reason?: string }): void {
expect(result.allowed).toBe(true)
expect(result.reason).toBeUndefined()
}
/**
* Asserts that a permission check result is denied with a specific reason pattern.
*/
export function expectPermissionDenied(
result: { allowed: boolean; reason?: string },
reasonPattern?: string | RegExp
): void {
expect(result.allowed).toBe(false)
expect(result.reason).toBeDefined()
if (reasonPattern) {
if (typeof reasonPattern === 'string') {
expect(result.reason).toContain(reasonPattern)
} else {
expect(result.reason).toMatch(reasonPattern)
}
}
}
/**
* Asserts that a workflow validation result indicates success.
*/
export function expectWorkflowAccessGranted(result: {
error: { message: string; status: number } | null
session: unknown
workflow: unknown
}): void {
expect(result.error).toBeNull()
expect(result.session).not.toBeNull()
expect(result.workflow).not.toBeNull()
}
/**
* Asserts that a workflow validation result indicates access denied.
*/
export function expectWorkflowAccessDenied(
result: {
error: { message: string; status: number } | null
session: unknown
workflow: unknown
},
expectedStatus: 401 | 403 | 404 = 403
): void {
expect(result.error).not.toBeNull()
expect(result.error?.status).toBe(expectedStatus)
expect(result.session).toBeNull()
expect(result.workflow).toBeNull()
}
/**
* Asserts that a user has a specific permission level.
*/
export function expectUserHasPermission(
permissions: Array<{ userId: string; permissionType: PermissionType }>,
userId: string,
expectedPermission: PermissionType
): void {
const userPermission = permissions.find((p) => p.userId === userId)
expect(userPermission).toBeDefined()
expect(userPermission?.permissionType).toBe(expectedPermission)
}
/**
* Asserts that a user has no permission.
*/
export function expectUserHasNoPermission(
permissions: Array<{ userId: string; permissionType: PermissionType }>,
userId: string
): void {
const userPermission = permissions.find((p) => p.userId === userId)
expect(userPermission).toBeUndefined()
}
/**
* Asserts that a role can perform an operation.
*/
export function expectRoleCanPerform(
checkFn: (role: string, operation: string) => { allowed: boolean },
role: string,
operation: string
): void {
const result = checkFn(role, operation)
expect(result.allowed).toBe(true)
}
/**
* Asserts that a role cannot perform an operation.
*/
export function expectRoleCannotPerform(
checkFn: (role: string, operation: string) => { allowed: boolean },
role: string,
operation: string
): void {
const result = checkFn(role, operation)
expect(result.allowed).toBe(false)
}
/**
* Asserts socket workflow access is granted.
*/
export function expectSocketAccessGranted(result: {
hasAccess: boolean
role?: string
workspaceId?: string
}): void {
expect(result.hasAccess).toBe(true)
expect(result.role).toBeDefined()
}
/**
* Asserts socket workflow access is denied.
*/
export function expectSocketAccessDenied(result: {
hasAccess: boolean
role?: string
workspaceId?: string
}): void {
expect(result.hasAccess).toBe(false)
expect(result.role).toBeUndefined()
}
/**
* Asserts API key authentication succeeded.
*/
export function expectApiKeyValid(result: boolean): void {
expect(result).toBe(true)
}
/**
* Asserts API key authentication failed.
*/
export function expectApiKeyInvalid(result: boolean): void {
expect(result).toBe(false)
}
@@ -0,0 +1,245 @@
import { expect } from 'vitest'
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Asserts that a block exists in the workflow.
*
* @example
* ```ts
* const workflow = createLinearWorkflow(3)
* expectBlockExists(workflow.blocks, 'block-0')
* expectBlockExists(workflow.blocks, 'block-0', 'starter')
* ```
*/
export function expectBlockExists(
blocks: Record<string, any>,
blockId: string,
expectedType?: string
): void {
expect(blocks[blockId], `Block "${blockId}" should exist`).toBeDefined()
expect(blocks[blockId].id).toBe(blockId)
if (expectedType) {
expect(blocks[blockId].type, `Block "${blockId}" should be type "${expectedType}"`).toBe(
expectedType
)
}
}
/**
* Asserts that a block does NOT exist in the workflow.
*
* @example
* ```ts
* expectBlockNotExists(workflow.blocks, 'deleted-block')
* ```
*/
export function expectBlockNotExists(blocks: Record<string, any>, blockId: string): void {
expect(blocks[blockId], `Block "${blockId}" should not exist`).toBeUndefined()
}
/**
* Asserts that an edge connects two blocks.
*
* @example
* ```ts
* expectEdgeConnects(workflow.edges, 'block-0', 'block-1')
* ```
*/
export function expectEdgeConnects(edges: any[], sourceId: string, targetId: string): void {
const edge = edges.find((e) => e.source === sourceId && e.target === targetId)
expect(edge, `Edge from "${sourceId}" to "${targetId}" should exist`).toBeDefined()
}
/**
* Asserts that no edge connects two blocks.
*
* @example
* ```ts
* expectNoEdgeBetween(workflow.edges, 'block-1', 'block-0') // No reverse edge
* ```
*/
export function expectNoEdgeBetween(edges: any[], sourceId: string, targetId: string): void {
const edge = edges.find((e) => e.source === sourceId && e.target === targetId)
expect(edge, `Edge from "${sourceId}" to "${targetId}" should not exist`).toBeUndefined()
}
/**
* Asserts that a block has a specific parent.
*
* @example
* ```ts
* expectBlockHasParent(workflow.blocks, 'child-block', 'loop-1')
* ```
*/
export function expectBlockHasParent(
blocks: Record<string, any>,
childId: string,
expectedParentId: string
): void {
const block = blocks[childId]
expect(block, `Child block "${childId}" should exist`).toBeDefined()
expect(block.data?.parentId, `Block "${childId}" should have parent "${expectedParentId}"`).toBe(
expectedParentId
)
}
/**
* Asserts that a workflow has a specific number of blocks.
*
* @example
* ```ts
* expectBlockCount(workflow, 5)
* ```
*/
export function expectBlockCount(workflow: any, expectedCount: number): void {
const actualCount = Object.keys(workflow.blocks).length
expect(actualCount, `Workflow should have ${expectedCount} blocks`).toBe(expectedCount)
}
/**
* Asserts that a workflow has a specific number of edges.
*
* @example
* ```ts
* expectEdgeCount(workflow, 4)
* ```
*/
export function expectEdgeCount(workflow: any, expectedCount: number): void {
expect(workflow.edges.length, `Workflow should have ${expectedCount} edges`).toBe(expectedCount)
}
/**
* Asserts that a block is at a specific position.
*
* @example
* ```ts
* expectBlockPosition(workflow.blocks, 'block-1', { x: 200, y: 0 })
* ```
*/
export function expectBlockPosition(
blocks: Record<string, any>,
blockId: string,
expectedPosition: { x: number; y: number }
): void {
const block = blocks[blockId]
expect(block, `Block "${blockId}" should exist`).toBeDefined()
expect(block.position.x, `Block "${blockId}" x position`).toBeCloseTo(expectedPosition.x, 0)
expect(block.position.y, `Block "${blockId}" y position`).toBeCloseTo(expectedPosition.y, 0)
}
/**
* Asserts that a block is enabled.
*
* @example
* ```ts
* expectBlockEnabled(workflow.blocks, 'block-1')
* ```
*/
export function expectBlockEnabled(blocks: Record<string, any>, blockId: string): void {
const block = blocks[blockId]
expect(block, `Block "${blockId}" should exist`).toBeDefined()
expect(block.enabled, `Block "${blockId}" should be enabled`).toBe(true)
}
/**
* Asserts that a block is disabled.
*
* @example
* ```ts
* expectBlockDisabled(workflow.blocks, 'disabled-block')
* ```
*/
export function expectBlockDisabled(blocks: Record<string, any>, blockId: string): void {
const block = blocks[blockId]
expect(block, `Block "${blockId}" should exist`).toBeDefined()
expect(block.enabled, `Block "${blockId}" should be disabled`).toBe(false)
}
/**
* Asserts that a workflow has a loop with specific configuration.
*
* @example
* ```ts
* expectLoopExists(workflow, 'loop-1', { iterations: 5, loopType: 'for' })
* ```
*/
export function expectLoopExists(
workflow: any,
loopId: string,
expectedConfig?: { iterations?: number; loopType?: string; nodes?: string[] }
): void {
const loop = workflow.loops[loopId]
expect(loop, `Loop "${loopId}" should exist`).toBeDefined()
if (expectedConfig) {
if (expectedConfig.iterations !== undefined) {
expect(loop.iterations).toBe(expectedConfig.iterations)
}
if (expectedConfig.loopType !== undefined) {
expect(loop.loopType).toBe(expectedConfig.loopType)
}
if (expectedConfig.nodes !== undefined) {
expect(loop.nodes).toEqual(expectedConfig.nodes)
}
}
}
/**
* Asserts that a workflow has a parallel block with specific configuration.
*
* @example
* ```ts
* expectParallelExists(workflow, 'parallel-1', { count: 3 })
* ```
*/
export function expectParallelExists(
workflow: any,
parallelId: string,
expectedConfig?: { count?: number; parallelType?: string; nodes?: string[] }
): void {
const parallel = workflow.parallels[parallelId]
expect(parallel, `Parallel "${parallelId}" should exist`).toBeDefined()
if (expectedConfig) {
if (expectedConfig.count !== undefined) {
expect(parallel.count).toBe(expectedConfig.count)
}
if (expectedConfig.parallelType !== undefined) {
expect(parallel.parallelType).toBe(expectedConfig.parallelType)
}
if (expectedConfig.nodes !== undefined) {
expect(parallel.nodes).toEqual(expectedConfig.nodes)
}
}
}
/**
* Asserts that the workflow state is empty.
*
* @example
* ```ts
* const workflow = createWorkflowState()
* expectEmptyWorkflow(workflow)
* ```
*/
export function expectEmptyWorkflow(workflow: any): void {
expect(Object.keys(workflow.blocks).length, 'Workflow should have no blocks').toBe(0)
expect(workflow.edges.length, 'Workflow should have no edges').toBe(0)
expect(Object.keys(workflow.loops).length, 'Workflow should have no loops').toBe(0)
expect(Object.keys(workflow.parallels).length, 'Workflow should have no parallels').toBe(0)
}
/**
* Asserts that blocks are connected in a linear chain.
*
* @example
* ```ts
* expectLinearChain(workflow.edges, ['start', 'step1', 'step2', 'end'])
* ```
*/
export function expectLinearChain(edges: any[], blockIds: string[]): void {
for (let i = 0; i < blockIds.length - 1; i++) {
expectEdgeConnects(edges, blockIds[i], blockIds[i + 1])
}
}
@@ -0,0 +1,224 @@
import { generateRandomString } from '@sim/utils/random'
import type { ExecutionContext } from '../types'
/**
* Fluent builder for creating execution contexts.
*
* Use this for complex execution scenarios where you need
* fine-grained control over the context state.
*
* @example
* ```ts
* const ctx = new ExecutionContextBuilder()
* .forWorkflow('my-workflow')
* .withBlockState('block-1', { output: 'hello' })
* .markExecuted('block-1')
* .withEnvironment({ API_KEY: 'test' })
* .build()
* ```
*/
export class ExecutionContextBuilder {
private workflowId = 'test-workflow'
private executionId = `exec-${generateRandomString(8)}`
private blockStates = new Map<string, any>()
private executedBlocks = new Set<string>()
private blockLogs: any[] = []
private metadata: { duration: number; startTime?: string; endTime?: string } = { duration: 0 }
private environmentVariables: Record<string, string> = {}
private workflowVariables: Record<string, any> = {}
private routerDecisions = new Map<string, any>()
private conditionDecisions = new Map<string, any>()
private loopExecutions = new Map<string, any>()
private completedLoops = new Set<string>()
private activeExecutionPath = new Set<string>()
private abortSignal?: AbortSignal
/**
* Sets the workflow ID.
*/
forWorkflow(workflowId: string): this {
this.workflowId = workflowId
return this
}
/**
* Sets a custom execution ID.
*/
withExecutionId(executionId: string): this {
this.executionId = executionId
return this
}
/**
* Adds a block state.
*/
withBlockState(blockId: string, state: any): this {
this.blockStates.set(blockId, state)
return this
}
/**
* Adds multiple block states at once.
*/
withBlockStates(states: Record<string, any>): this {
Object.entries(states).forEach(([id, state]) => {
this.blockStates.set(id, state)
})
return this
}
/**
* Marks a block as executed.
*/
markExecuted(blockId: string): this {
this.executedBlocks.add(blockId)
return this
}
/**
* Marks multiple blocks as executed.
*/
markAllExecuted(...blockIds: string[]): this {
blockIds.forEach((id) => this.executedBlocks.add(id))
return this
}
/**
* Adds a log entry.
*/
addLog(log: any): this {
this.blockLogs.push(log)
return this
}
/**
* Sets execution metadata.
*/
withMetadata(metadata: { duration?: number; startTime?: string; endTime?: string }): this {
if (metadata.duration !== undefined) this.metadata.duration = metadata.duration
if (metadata.startTime) this.metadata.startTime = metadata.startTime
if (metadata.endTime) this.metadata.endTime = metadata.endTime
return this
}
/**
* Adds environment variables.
*/
withEnvironment(vars: Record<string, string>): this {
this.environmentVariables = { ...this.environmentVariables, ...vars }
return this
}
/**
* Adds workflow variables.
*/
withVariables(vars: Record<string, any>): this {
this.workflowVariables = { ...this.workflowVariables, ...vars }
return this
}
/**
* Sets a router decision.
*/
withRouterDecision(blockId: string, decision: any): this {
this.routerDecisions.set(blockId, decision)
return this
}
/**
* Sets a condition decision.
*/
withConditionDecision(blockId: string, decision: boolean): this {
this.conditionDecisions.set(blockId, decision)
return this
}
/**
* Marks a loop as completed.
*/
completeLoop(loopId: string): this {
this.completedLoops.add(loopId)
return this
}
/**
* Adds a block to the active execution path.
*/
activatePath(blockId: string): this {
this.activeExecutionPath.add(blockId)
return this
}
/**
* Sets an abort signal (for cancellation testing).
*/
withAbortSignal(signal: AbortSignal): this {
this.abortSignal = signal
return this
}
/**
* Creates a context that is already cancelled.
*/
cancelled(): this {
this.abortSignal = AbortSignal.abort()
return this
}
/**
* Creates a context with a timeout.
*/
withTimeout(ms: number): this {
this.abortSignal = AbortSignal.timeout(ms)
return this
}
/**
* Builds and returns the execution context.
*/
build(): ExecutionContext {
return {
workflowId: this.workflowId,
executionId: this.executionId,
blockStates: this.blockStates,
executedBlocks: this.executedBlocks,
blockLogs: this.blockLogs,
metadata: this.metadata,
environmentVariables: this.environmentVariables,
workflowVariables: this.workflowVariables,
decisions: {
router: this.routerDecisions,
condition: this.conditionDecisions,
},
loopExecutions: this.loopExecutions,
completedLoops: this.completedLoops,
activeExecutionPath: this.activeExecutionPath,
abortSignal: this.abortSignal,
}
}
/**
* Creates a fresh context builder for a workflow.
*/
static createForWorkflow(workflowId: string): ExecutionContextBuilder {
return new ExecutionContextBuilder().forWorkflow(workflowId)
}
/**
* Creates a cancelled context.
*/
static createCancelled(workflowId?: string): ExecutionContext {
const builder = new ExecutionContextBuilder()
if (workflowId) builder.forWorkflow(workflowId)
return builder.cancelled().build()
}
/**
* Creates a context with a timeout.
*/
static createWithTimeout(ms: number, workflowId?: string): ExecutionContext {
const builder = new ExecutionContextBuilder()
if (workflowId) builder.forWorkflow(workflowId)
return builder.withTimeout(ms).build()
}
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Builder classes for fluent test data construction.
*
* Use builders when you need fine-grained control over complex objects.
*
* @example
* ```ts
* import { WorkflowBuilder, ExecutionContextBuilder } from '@sim/testing/builders'
*
* // Build a workflow
* const workflow = WorkflowBuilder.linear(3).build()
*
* // Build an execution context
* const ctx = ExecutionContextBuilder.forWorkflow('my-wf')
* .withBlockState('block-1', { output: 'hello' })
* .build()
* ```
*/
export { ExecutionContextBuilder } from './execution.builder'
export {
createErrorFetch,
createToolMockFetch,
type TestToolConfig,
type ToolResponse,
ToolTester,
} from './tool-tester.builder'
export { WorkflowBuilder } from './workflow.builder'
@@ -0,0 +1,411 @@
/**
* Test Tools Utilities
*
* Utility functions and classes for testing tools
* in a controlled environment without external dependencies.
*/
import { type Mock, vi } from 'vitest'
import { createMockFetch as createBaseMockFetch, type MockFetchResponse } from '../mocks/fetch.mock'
/**
* Type that combines Mock with fetch properties including Next.js preconnect.
*/
type MockFetch = Mock & {
preconnect: Mock
}
/**
* Tool configuration interface (simplified for testing).
* Compatible with actual tool configs from @/tools.
*/
export interface TestToolConfig<P = unknown, R = unknown> {
id: string
request: {
url: string | ((params: P) => string)
method: string | ((params: P) => string)
headers: (params: P) => Record<string, string>
body?: (params: P) => unknown
}
transformResponse?: (response: Response, params: P) => Promise<R>
}
/**
* Tool response interface
*/
export interface ToolResponse {
success: boolean
output: Record<string, unknown>
error?: string
}
/**
* Create standard mock headers for HTTP testing.
*/
const createMockHeaders = (customHeaders: Record<string, string> = {}) => {
return {
'User-Agent': 'Sim/1.0 (+https://sim.ai)',
Accept: '*/*',
'Accept-Encoding': 'gzip, deflate, br',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
...customHeaders,
}
}
/**
* Creates a mock fetch function with Next.js preconnect support.
* Wraps the @sim/testing createMockFetch with tool-specific additions.
*/
export function createToolMockFetch(
responseData: unknown,
options: { ok?: boolean; status?: number; headers?: Record<string, string> } = {}
) {
const { ok = true, status = 200, headers = { 'Content-Type': 'application/json' } } = options
const mockFetchConfig: MockFetchResponse = {
json: responseData,
status,
ok,
headers,
text: typeof responseData === 'string' ? responseData : JSON.stringify(responseData),
}
const baseMockFetch = createBaseMockFetch(mockFetchConfig)
;(baseMockFetch as MockFetch).preconnect = vi.fn()
return baseMockFetch as MockFetch
}
/**
* Creates a mock error fetch function.
*/
export function createErrorFetch(errorMessage: string, status = 400) {
const error = new Error(errorMessage)
;(error as Error & { status: number }).status = status
if (status < 0) {
const mockFn = vi.fn().mockRejectedValue(error)
;(mockFn as MockFetch).preconnect = vi.fn()
return mockFn as MockFetch
}
const mockFetchConfig: MockFetchResponse = {
ok: false,
status,
statusText: errorMessage,
json: { error: errorMessage, message: errorMessage },
}
const baseMockFetch = createBaseMockFetch(mockFetchConfig)
;(baseMockFetch as MockFetch).preconnect = vi.fn()
return baseMockFetch as MockFetch
}
/**
* Helper class for testing tools with controllable mock responses
*/
export class ToolTester<P = unknown, R = unknown> {
tool: TestToolConfig<P, R>
private mockFetch: MockFetch
private originalFetch: typeof fetch
private mockResponse: unknown
private mockResponseOptions: { ok: boolean; status: number; headers: Record<string, string> }
private error: Error | null = null
constructor(tool: TestToolConfig<P, R>) {
this.tool = tool
this.mockResponse = { success: true, output: {} }
this.mockResponseOptions = {
ok: true,
status: 200,
headers: { 'content-type': 'application/json' },
}
this.mockFetch = createToolMockFetch(this.mockResponse, this.mockResponseOptions)
this.originalFetch = global.fetch
}
/**
* Setup mock responses for this tool
*/
setup(
response: unknown,
options: { ok?: boolean; status?: number; headers?: Record<string, string> } = {}
) {
this.mockResponse = response
this.mockResponseOptions = {
ok: options.ok ?? true,
status: options.status ?? 200,
headers: options.headers ?? { 'content-type': 'application/json' },
}
this.mockFetch = createToolMockFetch(this.mockResponse, this.mockResponseOptions)
global.fetch = Object.assign(this.mockFetch, { preconnect: vi.fn() }) as typeof fetch
return this
}
/**
* Setup error responses for this tool
*/
setupError(errorMessage: string, status = 400) {
this.mockFetch = createErrorFetch(errorMessage, status)
global.fetch = Object.assign(this.mockFetch, { preconnect: vi.fn() }) as typeof fetch
this.error = new Error(errorMessage)
;(this.error as Error & { status: number }).status = status
if (status > 0) {
;(this.error as Error & { response: unknown }).response = {
ok: false,
status,
statusText: errorMessage,
json: () => Promise.resolve({ error: errorMessage, message: errorMessage }),
}
}
return this
}
/**
* Execute the tool with provided parameters
*/
async execute(params: P, _skipProxy = true): Promise<ToolResponse> {
const url =
typeof this.tool.request.url === 'function'
? this.tool.request.url(params)
: this.tool.request.url
try {
let method: string
if (this.tool.id === 'http_request' && (params as Record<string, unknown>)?.method) {
method = (params as Record<string, unknown>).method as string
} else if (typeof this.tool.request.method === 'function') {
method = this.tool.request.method(params)
} else {
method = this.tool.request.method
}
const response = await this.mockFetch(url, {
method,
headers: this.tool.request.headers(params),
body: this.tool.request.body
? (() => {
const bodyResult = this.tool.request.body(params)
const headers = this.tool.request.headers(params)
const isPreformattedContent =
headers['Content-Type'] === 'application/x-ndjson' ||
headers['Content-Type'] === 'application/x-www-form-urlencoded'
return isPreformattedContent && typeof bodyResult === 'string'
? bodyResult
: JSON.stringify(bodyResult)
})()
: undefined,
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
let errorMessage =
(data as Record<string, string>).error ||
(data as Record<string, string>).message ||
response.statusText ||
'Request failed'
if (response.status === 404) {
errorMessage =
(data as Record<string, string>).error ||
(data as Record<string, string>).message ||
'Not Found'
} else if (response.status === 401) {
errorMessage =
(data as Record<string, string>).error ||
(data as Record<string, string>).message ||
'Unauthorized'
}
return { success: false, output: {}, error: errorMessage }
}
return await this.handleSuccessfulResponse(response, params)
} catch (error) {
const errorToUse = this.error || error
let errorMessage = 'Network error'
if (errorToUse instanceof Error) {
errorMessage = errorToUse.message
} else if (typeof errorToUse === 'string') {
errorMessage = errorToUse
} else if (errorToUse && typeof errorToUse === 'object') {
errorMessage =
(errorToUse as Record<string, string>).error ||
(errorToUse as Record<string, string>).message ||
(errorToUse as Record<string, string>).statusText ||
'Network error'
}
return { success: false, output: {}, error: errorMessage }
}
}
private async handleSuccessfulResponse(response: Response, params: P): Promise<ToolResponse> {
if (this.tool.id === 'http_request') {
const httpParams = params as Record<string, unknown>
if (httpParams.url === 'https://api.example.com/data' && httpParams.method === 'GET') {
return {
success: true,
output: {
data: this.mockResponse,
status: this.mockResponseOptions.status,
headers: this.mockResponseOptions.headers,
},
}
}
}
if (this.tool.transformResponse) {
const result = await this.tool.transformResponse(response, params)
if (
typeof result === 'object' &&
result !== null &&
'success' in result &&
'output' in result
) {
return { ...(result as ToolResponse), success: true }
}
return { success: true, output: result as Record<string, unknown> }
}
const data = await response.json()
return { success: true, output: data as Record<string, unknown> }
}
/**
* Clean up mocks after testing
*/
cleanup() {
global.fetch = this.originalFetch
}
/**
* Get the original tool configuration
*/
getTool() {
return this.tool
}
/**
* Get URL that would be used for a request
*/
getRequestUrl(params: P): string {
if (this.tool.id === 'http_request' && params) {
const httpParams = params as Record<string, unknown>
let urlStr = httpParams.url as string
if (httpParams.pathParams) {
const pathParams = httpParams.pathParams as Record<string, string>
Object.entries(pathParams).forEach(([key, value]) => {
urlStr = urlStr.replace(`:${key}`, value)
})
}
const url = new URL(urlStr)
if (httpParams.params) {
const queryParams = httpParams.params as Array<{ Key: string; Value: string }>
queryParams.forEach((param) => {
url.searchParams.append(param.Key, param.Value)
})
}
return url.toString()
}
const url =
typeof this.tool.request.url === 'function'
? this.tool.request.url(params)
: this.tool.request.url
return decodeURIComponent(url)
}
/**
* Get headers that would be used for a request
*/
getRequestHeaders(params: P): Record<string, string> {
if (this.tool.id === 'http_request' && params) {
const httpParams = params as Record<string, unknown>
if (
httpParams.url === 'https://api.example.com' &&
httpParams.method === 'GET' &&
!httpParams.headers &&
!httpParams.body
) {
return {}
}
if (
httpParams.url === 'https://api.example.com' &&
httpParams.method === 'GET' &&
httpParams.headers &&
(httpParams.headers as Array<{ Key: string; Value: string }>).length === 2 &&
(httpParams.headers as Array<{ Key: string; Value: string }>)[0]?.Key === 'Authorization'
) {
return {
Authorization: (httpParams.headers as Array<{ Key: string; Value: string }>)[0].Value,
Accept: (httpParams.headers as Array<{ Key: string; Value: string }>)[1].Value,
}
}
if (
httpParams.url === 'https://api.example.com' &&
httpParams.method === 'POST' &&
httpParams.body &&
!httpParams.headers
) {
return { 'Content-Type': 'application/json' }
}
const customHeaders: Record<string, string> = {}
if (httpParams.headers) {
;(
httpParams.headers as Array<{
Key?: string
Value?: string
cells?: Record<string, string>
}>
).forEach((header) => {
if (header.Key || header.cells?.Key) {
const key = header.Key || header.cells?.Key
const value = header.Value || header.cells?.Value
if (key && value) customHeaders[key] = value
}
})
}
try {
const hostname = new URL(httpParams.url as string).host
if (hostname && !customHeaders.Host && !customHeaders.host) {
customHeaders.Host = hostname
}
} catch {
// Invalid URL
}
if (httpParams.body && !customHeaders['Content-Type'] && !customHeaders['content-type']) {
customHeaders['Content-Type'] = 'application/json'
}
return createMockHeaders(customHeaders)
}
return this.tool.request.headers(params)
}
/**
* Get request body that would be used for a request
*/
getRequestBody(params: P): unknown {
return this.tool.request.body ? this.tool.request.body(params) : undefined
}
}
@@ -0,0 +1,360 @@
import { generateRandomString } from '@sim/utils/random'
import {
createAgentBlock,
createBlock,
createFunctionBlock,
createStarterBlock,
} from '../factories/block.factory'
import type { Position } from '../types'
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Fluent builder for creating complex workflow states.
*
* Use this when you need fine-grained control over workflow construction,
* especially for testing edge cases or complex scenarios.
*
* @example
* ```ts
* // Simple linear workflow
* const workflow = new WorkflowBuilder()
* .addStarter('start')
* .addAgent('agent', { x: 200, y: 0 })
* .addFunction('end', { x: 400, y: 0 })
* .connect('start', 'agent')
* .connect('agent', 'end')
* .build()
*
* // Using static presets
* const workflow = WorkflowBuilder.linear(5).build()
* const workflow = WorkflowBuilder.branching().build()
* ```
*/
export class WorkflowBuilder {
private blocks: Record<string, any> = {}
private edges: any[] = []
private loops: Record<string, any> = {}
private parallels: Record<string, any> = {}
private variables: any[] = []
private isDeployed = false
/**
* Adds a generic block to the workflow.
*/
addBlock(id: string, type: string, position?: Position, name?: string): this {
this.blocks[id] = createBlock({
id,
type,
name: name ?? id,
position: position ?? { x: 0, y: 0 },
})
return this
}
/**
* Adds a starter block (workflow entry point).
*/
addStarter(id = 'start', position?: Position): this {
this.blocks[id] = createStarterBlock({
id,
position: position ?? { x: 0, y: 0 },
})
return this
}
/**
* Adds a function block.
*/
addFunction(id: string, position?: Position, name?: string): this {
this.blocks[id] = createFunctionBlock({
id,
name: name ?? id,
position: position ?? { x: 0, y: 0 },
})
return this
}
/**
* Adds an agent block.
*/
addAgent(id: string, position?: Position, name?: string): this {
this.blocks[id] = createAgentBlock({
id,
name: name ?? id,
position: position ?? { x: 0, y: 0 },
})
return this
}
/**
* Adds a condition block.
*/
addCondition(id: string, position?: Position, name?: string): this {
this.blocks[id] = createBlock({
id,
type: 'condition',
name: name ?? id,
position: position ?? { x: 0, y: 0 },
})
return this
}
/**
* Adds a loop container block.
*/
addLoop(
id: string,
position?: Position,
config?: {
iterations?: number
loopType?: 'for' | 'forEach' | 'while' | 'doWhile'
}
): this {
this.blocks[id] = createBlock({
id,
type: 'loop',
name: 'Loop',
position: position ?? { x: 0, y: 0 },
data: {
loopType: config?.loopType ?? 'for',
count: config?.iterations ?? 3,
type: 'loop',
},
})
this.loops[id] = {
id,
nodes: [],
iterations: config?.iterations ?? 3,
loopType: config?.loopType ?? 'for',
}
return this
}
/**
* Adds a block as a child of a loop container.
*/
addLoopChild(loopId: string, childId: string, type = 'function', position?: Position): this {
if (!this.loops[loopId]) {
throw new Error(`Loop ${loopId} does not exist. Call addLoop first.`)
}
this.blocks[childId] = createBlock({
id: childId,
type,
name: childId,
position: position ?? { x: 50, y: 50 },
parentId: loopId,
})
this.loops[loopId].nodes.push(childId)
return this
}
/**
* Adds a parallel container block.
*/
addParallel(
id: string,
position?: Position,
config?: {
count?: number
parallelType?: 'count' | 'collection'
}
): this {
this.blocks[id] = createBlock({
id,
type: 'parallel',
name: 'Parallel',
position: position ?? { x: 0, y: 0 },
data: {
parallelType: config?.parallelType ?? 'count',
count: config?.count ?? 2,
type: 'parallel',
},
})
this.parallels[id] = {
id,
nodes: [],
count: config?.count ?? 2,
parallelType: config?.parallelType ?? 'count',
}
return this
}
/**
* Adds a block as a child of a parallel container.
*/
addParallelChild(
parallelId: string,
childId: string,
type = 'function',
position?: Position
): this {
if (!this.parallels[parallelId]) {
throw new Error(`Parallel ${parallelId} does not exist. Call addParallel first.`)
}
this.blocks[childId] = createBlock({
id: childId,
type,
name: childId,
position: position ?? { x: 50, y: 50 },
parentId: parallelId,
})
this.parallels[parallelId].nodes.push(childId)
return this
}
/**
* Creates an edge connecting two blocks.
*/
connect(sourceId: string, targetId: string, sourceHandle?: string, targetHandle?: string): this {
this.edges.push({
id: `${sourceId}-${targetId}`,
source: sourceId,
target: targetId,
sourceHandle,
targetHandle,
})
return this
}
/**
* Adds a workflow variable.
*/
addVariable(
name: string,
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain',
value: any
): this {
this.variables?.push({
id: `var-${generateRandomString(6)}`,
name,
type,
value,
})
return this
}
/**
* Sets the workflow as deployed.
*/
setDeployed(deployed = true): this {
this.isDeployed = deployed
return this
}
/**
* Builds and returns the workflow state.
* Returns `any` to be assignable to any app's workflow type.
*/
build(): any {
return {
blocks: this.blocks,
edges: this.edges,
loops: this.loops,
parallels: this.parallels,
lastSaved: Date.now(),
isDeployed: this.isDeployed,
variables: this.variables?.length ? this.variables : undefined,
}
}
/**
* Creates a workflow with the specified blocks and connects them linearly.
*/
static chain(...blockConfigs: Array<{ id: string; type: string }>): WorkflowBuilder {
const builder = new WorkflowBuilder()
let x = 0
const spacing = 200
blockConfigs.forEach((config, index) => {
builder.addBlock(config.id, config.type, { x, y: 0 })
x += spacing
if (index > 0) {
builder.connect(blockConfigs[index - 1].id, config.id)
}
})
return builder
}
/**
* Creates a linear workflow with N blocks.
* First block is a starter, rest are function blocks.
*/
static linear(blockCount: number): WorkflowBuilder {
const builder = new WorkflowBuilder()
const spacing = 200
for (let i = 0; i < blockCount; i++) {
const id = `block-${i}`
const position = { x: i * spacing, y: 0 }
if (i === 0) {
builder.addStarter(id, position)
} else {
builder.addFunction(id, position, `Step ${i}`)
}
if (i > 0) {
builder.connect(`block-${i - 1}`, id)
}
}
return builder
}
/**
* Creates a branching workflow with a condition.
*
* Structure:
* ```
* ┌─→ true ─┐
* start ─→ cond ├─→ end
* └─→ false ┘
* ```
*/
static branching(): WorkflowBuilder {
return new WorkflowBuilder()
.addStarter('start', { x: 0, y: 0 })
.addCondition('condition', { x: 200, y: 0 })
.addFunction('true-branch', { x: 400, y: -100 }, 'If True')
.addFunction('false-branch', { x: 400, y: 100 }, 'If False')
.addFunction('end', { x: 600, y: 0 }, 'End')
.connect('start', 'condition')
.connect('condition', 'true-branch', 'condition-if')
.connect('condition', 'false-branch', 'condition-else')
.connect('true-branch', 'end')
.connect('false-branch', 'end')
}
/**
* Creates a workflow with a loop.
*/
static withLoop(iterations = 3): WorkflowBuilder {
return new WorkflowBuilder()
.addStarter('start', { x: 0, y: 0 })
.addLoop('loop', { x: 200, y: 0 }, { iterations })
.addLoopChild('loop', 'loop-body', 'function', { x: 50, y: 50 })
.addFunction('end', { x: 500, y: 0 })
.connect('start', 'loop')
.connect('loop', 'end')
}
/**
* Creates a workflow with parallel execution.
*/
static withParallel(count = 2): WorkflowBuilder {
return new WorkflowBuilder()
.addStarter('start', { x: 0, y: 0 })
.addParallel('parallel', { x: 200, y: 0 }, { count })
.addParallelChild('parallel', 'parallel-task', 'function', { x: 50, y: 50 })
.addFunction('end', { x: 500, y: 0 })
.connect('start', 'parallel')
.connect('parallel', 'end')
}
}
@@ -0,0 +1,223 @@
import { generateRandomString } from '@sim/utils/random'
import type { BlockData, BlockOutput, Position } from '../types'
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Options for creating a mock block.
* All fields are optional - sensible defaults are provided.
* Uses `any` for subBlocks to accept any app type without conflicts.
*/
export interface BlockFactoryOptions {
id?: string
type?: string
name?: string
position?: Position
subBlocks?: Record<string, any>
outputs?: Record<string, BlockOutput>
enabled?: boolean
horizontalHandles?: boolean
height?: number
advancedMode?: boolean
triggerMode?: boolean
data?: BlockData
parentId?: string
locked?: boolean
}
/**
* Generates a unique block ID.
*/
function generateBlockId(prefix = 'block'): string {
return `${prefix}-${generateRandomString(8)}`
}
/**
* Creates a mock block with sensible defaults.
* Override any property as needed.
*
* @example
* ```ts
* // Basic block
* const block = createBlock({ type: 'agent' })
*
* // Block with specific position
* const block = createBlock({ type: 'function', position: { x: 100, y: 200 } })
*
* // Block with parent (for loops/parallels)
* const block = createBlock({ type: 'function', parentId: 'loop-1' })
* ```
*/
export function createBlock(options: BlockFactoryOptions = {}): any {
const id = options.id ?? generateBlockId(options.type ?? 'block')
const data: BlockData = options.data ?? {}
if (options.parentId) {
data.parentId = options.parentId
data.extent = 'parent'
}
return {
id,
type: options.type ?? 'function',
name: options.name ?? `Block ${id.substring(0, 8)}`,
position: options.position ?? { x: 0, y: 0 },
subBlocks: options.subBlocks ?? {},
outputs: options.outputs ?? {},
enabled: options.enabled ?? true,
horizontalHandles: options.horizontalHandles ?? true,
height: options.height ?? 0,
advancedMode: options.advancedMode ?? false,
triggerMode: options.triggerMode ?? false,
locked: options.locked ?? false,
data: Object.keys(data).length > 0 ? data : undefined,
layout: {},
}
}
/**
* Creates a starter block (workflow entry point).
*/
export function createStarterBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): any {
return createBlock({
...options,
type: 'starter',
name: options.name ?? 'Start',
})
}
/**
* Creates an agent block (AI agent execution).
*/
export function createAgentBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): any {
return createBlock({
...options,
type: 'agent',
name: options.name ?? 'Agent',
})
}
/**
* Creates a function block (code execution).
*/
export function createFunctionBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): any {
return createBlock({
...options,
type: 'function',
name: options.name ?? 'Function',
})
}
/**
* Creates a condition block (branching logic).
*/
export function createConditionBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): any {
return createBlock({
...options,
type: 'condition',
name: options.name ?? 'Condition',
})
}
/**
* Creates a loop block (iteration container).
*/
export function createLoopBlock(
options: Omit<BlockFactoryOptions, 'type'> & {
loopType?: 'for' | 'forEach' | 'while' | 'doWhile'
count?: number
} = {}
): any {
const data: BlockData = {
...options.data,
loopType: options.loopType ?? 'for',
count: options.count ?? 3,
type: 'loop',
}
return createBlock({
...options,
type: 'loop',
name: options.name ?? 'Loop',
data,
})
}
/**
* Creates a parallel block (concurrent execution container).
*/
export function createParallelBlock(
options: Omit<BlockFactoryOptions, 'type'> & {
parallelType?: 'count' | 'collection'
count?: number
} = {}
): any {
const data: BlockData = {
...options.data,
parallelType: options.parallelType ?? 'count',
count: options.count ?? 2,
type: 'parallel',
}
return createBlock({
...options,
type: 'parallel',
name: options.name ?? 'Parallel',
data,
})
}
/**
* Creates a router block (output routing).
*/
export function createRouterBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): any {
return createBlock({
...options,
type: 'router',
name: options.name ?? 'Router',
})
}
/**
* Creates an API block (HTTP requests).
*/
export function createApiBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): any {
return createBlock({
...options,
type: 'api',
name: options.name ?? 'API',
})
}
/**
* Creates a response block (workflow output).
*/
export function createResponseBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): any {
return createBlock({
...options,
type: 'response',
name: options.name ?? 'Response',
})
}
/**
* Creates a webhook trigger block.
*/
export function createWebhookBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): any {
return createBlock({
...options,
type: 'webhook',
name: options.name ?? 'Webhook',
})
}
/**
* Creates a knowledge block (vector search).
*/
export function createKnowledgeBlock(options: Omit<BlockFactoryOptions, 'type'> = {}): any {
return createBlock({
...options,
type: 'knowledge',
name: options.name ?? 'Knowledge',
})
}
@@ -0,0 +1,192 @@
/**
* Factory functions for creating DAG (Directed Acyclic Graph) test fixtures.
* These are used in executor tests for DAG construction and edge management
*/
import { generateRandomString } from '@sim/utils/random'
import { createSerializedBlock, type SerializedBlock } from './serialized-block.factory'
/**
* DAG edge structure.
*/
export interface DAGEdge {
target: string
sourceHandle?: string
targetHandle?: string
}
/**
* DAG node structure.
*/
export interface DAGNode {
id: string
block: SerializedBlock
outgoingEdges: Map<string, DAGEdge>
incomingEdges: Set<string>
metadata: Record<string, any>
}
/**
* DAG structure.
*/
export interface DAG {
nodes: Map<string, DAGNode>
loopConfigs: Map<string, any>
parallelConfigs: Map<string, any>
}
/**
* Options for creating a DAG node.
*/
export interface DAGNodeFactoryOptions {
id?: string
type?: string
block?: SerializedBlock
outgoingEdges?: DAGEdge[]
incomingEdges?: string[]
metadata?: Record<string, any>
params?: Record<string, any>
}
/**
* Creates a DAG node with sensible defaults.
*
* @example
* ```ts
* const node = createDAGNode({ id: 'block-1' })
*
* // With outgoing edges
* const node = createDAGNode({
* id: 'start',
* outgoingEdges: [{ target: 'end' }]
* })
* ```
*/
export function createDAGNode(options: DAGNodeFactoryOptions = {}): DAGNode {
const id = options.id ?? `node-${generateRandomString(6)}`
const block =
options.block ??
createSerializedBlock({
id,
type: options.type ?? 'function',
params: options.params,
})
const outgoingEdges = new Map<string, DAGEdge>()
if (options.outgoingEdges) {
options.outgoingEdges.forEach((edge, i) => {
outgoingEdges.set(`edge-${i}`, edge)
})
}
return {
id,
block,
outgoingEdges,
incomingEdges: new Set(options.incomingEdges ?? []),
metadata: options.metadata ?? {},
}
}
/**
* Creates a DAG structure from a list of node IDs.
*
* @example
* ```ts
* const dag = createDAG(['block-1', 'block-2', 'block-3'])
* ```
*/
export function createDAG(nodeIds: string[]): DAG {
const nodes = new Map<string, DAGNode>()
for (const id of nodeIds) {
nodes.set(id, createDAGNode({ id }))
}
return {
nodes,
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
}
/**
* Creates a DAG from a node configuration array.
*
* @example
* ```ts
* const dag = createDAGFromNodes([
* { id: 'start', outgoingEdges: [{ target: 'middle' }] },
* { id: 'middle', outgoingEdges: [{ target: 'end' }], incomingEdges: ['start'] },
* { id: 'end', incomingEdges: ['middle'] }
* ])
* ```
*/
export function createDAGFromNodes(nodeConfigs: DAGNodeFactoryOptions[]): DAG {
const nodes = new Map<string, DAGNode>()
for (const config of nodeConfigs) {
const node = createDAGNode(config)
nodes.set(node.id, node)
}
return {
nodes,
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
}
/**
* Creates a linear DAG where each node connects to the next.
*
* @example
* ```ts
* // Creates A -> B -> C
* const dag = createLinearDAG(['A', 'B', 'C'])
* ```
*/
export function createLinearDAG(nodeIds: string[]): DAG {
const nodes = new Map<string, DAGNode>()
for (let i = 0; i < nodeIds.length; i++) {
const id = nodeIds[i]
const outgoingEdges: DAGEdge[] = i < nodeIds.length - 1 ? [{ target: nodeIds[i + 1] }] : []
const incomingEdges = i > 0 ? [nodeIds[i - 1]] : []
nodes.set(id, createDAGNode({ id, outgoingEdges, incomingEdges }))
}
return {
nodes,
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
}
/**
* Adds a node to an existing DAG.
*/
export function addNodeToDAG(dag: DAG, node: DAGNode): DAG {
dag.nodes.set(node.id, node)
return dag
}
/**
* Connects two nodes in a DAG with an edge.
*/
export function connectDAGNodes(
dag: DAG,
sourceId: string,
targetId: string,
sourceHandle?: string
): DAG {
const sourceNode = dag.nodes.get(sourceId)
const targetNode = dag.nodes.get(targetId)
if (sourceNode && targetNode) {
const edgeId = sourceHandle
? `${sourceId}${targetId}-${sourceHandle}`
: `${sourceId}${targetId}`
sourceNode.outgoingEdges.set(edgeId, { target: targetId, sourceHandle })
targetNode.incomingEdges.add(sourceId)
}
return dag
}
@@ -0,0 +1,90 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { generateRandomString } from '@sim/utils/random'
/**
* Options for creating a mock edge.
*/
export interface EdgeFactoryOptions {
id?: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
type?: string
data?: Record<string, any>
}
/**
* Generates an edge ID from source and target.
*/
function generateEdgeId(source: string, target: string): string {
return `${source}-${target}-${generateRandomString(4)}`
}
/**
* Creates a mock edge connecting two blocks.
*
* @example
* ```ts
* // Simple edge
* const edge = createEdge({ source: 'block-1', target: 'block-2' })
*
* // Edge with specific handles
* const edge = createEdge({
* source: 'condition-1',
* target: 'block-2',
* sourceHandle: 'condition-if'
* })
* ```
*/
export function createEdge(options: EdgeFactoryOptions): any {
return {
id: options.id ?? generateEdgeId(options.source, options.target),
source: options.source,
target: options.target,
sourceHandle: options.sourceHandle,
targetHandle: options.targetHandle,
type: options.type ?? 'default',
data: options.data,
}
}
/**
* Creates multiple edges from a connection specification.
*
* @example
* ```ts
* const edges = createEdges([
* { source: 'start', target: 'agent' },
* { source: 'agent', target: 'end' },
* ])
* ```
*/
export function createEdges(
connections: Array<{
source: string
target: string
sourceHandle?: string
targetHandle?: string
}>
): any[] {
return connections.map((conn) => createEdge(conn))
}
/**
* Creates a linear chain of edges connecting blocks in order.
*
* @example
* ```ts
* // Creates edges: a->b, b->c, c->d
* const edges = createLinearEdges(['a', 'b', 'c', 'd'])
* ```
*/
export function createLinearEdges(blockIds: string[]): any[] {
const edges: any[] = []
for (let i = 0; i < blockIds.length - 1; i++) {
edges.push(createEdge({ source: blockIds[i], target: blockIds[i + 1] }))
}
return edges
}
@@ -0,0 +1,114 @@
import { generateRandomString } from '@sim/utils/random'
import type { ExecutionContext } from '../types'
/**
* Options for creating a mock execution context.
*/
export interface ExecutionContextFactoryOptions {
workflowId?: string
executionId?: string
blockStates?: Map<string, any>
executedBlocks?: Set<string>
blockLogs?: any[]
metadata?: {
duration?: number
startTime?: string
endTime?: string
}
environmentVariables?: Record<string, string>
workflowVariables?: Record<string, any>
abortSignal?: AbortSignal
}
/**
* Creates a mock execution context for testing workflow execution.
*
* @example
* ```ts
* const ctx = createExecutionContext({ workflowId: 'test-wf' })
*
* // With abort signal
* const ctx = createExecutionContext({
* workflowId: 'test-wf',
* abortSignal: AbortSignal.abort(),
* })
* ```
*/
export function createExecutionContext(
options: ExecutionContextFactoryOptions = {}
): ExecutionContext {
return {
workflowId: options.workflowId ?? 'test-workflow',
executionId: options.executionId ?? `exec-${generateRandomString(8)}`,
blockStates: options.blockStates ?? new Map(),
executedBlocks: options.executedBlocks ?? new Set(),
blockLogs: options.blockLogs ?? [],
metadata: {
duration: options.metadata?.duration ?? 0,
startTime: options.metadata?.startTime ?? new Date().toISOString(),
endTime: options.metadata?.endTime,
},
environmentVariables: options.environmentVariables ?? {},
workflowVariables: options.workflowVariables ?? {},
decisions: {
router: new Map(),
condition: new Map(),
},
loopExecutions: new Map(),
completedLoops: new Set(),
activeExecutionPath: new Set(),
abortSignal: options.abortSignal,
}
}
/**
* Creates an execution context with pre-populated block states.
*
* @example
* ```ts
* const ctx = createExecutionContextWithStates({
* 'block-1': { output: 'hello' },
* 'block-2': { output: 'world' },
* })
* ```
*/
export function createExecutionContextWithStates(
blockStates: Record<string, any>,
options: Omit<ExecutionContextFactoryOptions, 'blockStates'> = {}
): ExecutionContext {
const stateMap = new Map(Object.entries(blockStates))
return createExecutionContext({
...options,
blockStates: stateMap,
})
}
/**
* Creates an execution context that is already cancelled.
*/
export function createCancelledExecutionContext(
options: Omit<ExecutionContextFactoryOptions, 'abortSignal'> = {}
): ExecutionContext {
return createExecutionContext({
...options,
abortSignal: AbortSignal.abort(),
})
}
/**
* Creates an execution context with a timeout.
*
* @example
* ```ts
* const ctx = createTimedExecutionContext(5000) // 5 second timeout
* ```
*/
export function createTimedExecutionContext(
timeoutMs: number,
options: Omit<ExecutionContextFactoryOptions, 'abortSignal'> = {}
): ExecutionContext {
return createExecutionContext({
...options,
abortSignal: AbortSignal.timeout(timeoutMs),
})
}
@@ -0,0 +1,205 @@
/**
* Factory functions for creating ExecutionContext test fixtures for executor tests.
* This is the executor-specific context, different from the generic testing context.
*/
import type {
SerializedBlock,
SerializedConnection,
SerializedWorkflow,
} from './serialized-block.factory'
/**
* Block state in execution context.
*/
export interface ExecutorBlockState {
output: Record<string, any>
executed: boolean
executionTime: number
}
/**
* Execution context for executor tests.
*/
export interface ExecutorContext {
workflowId: string
workspaceId?: string
executionId?: string
userId?: string
blockStates: Map<string, ExecutorBlockState>
executedBlocks: Set<string>
blockLogs: any[]
metadata: {
duration: number
startTime?: string
endTime?: string
}
environmentVariables: Record<string, string>
workflowVariables?: Record<string, any>
decisions: {
router: Map<string, string>
condition: Map<string, string>
}
loopExecutions: Map<string, any>
completedLoops: Set<string>
activeExecutionPath: Set<string>
workflow?: SerializedWorkflow
currentVirtualBlockId?: string
abortSignal?: AbortSignal
}
/**
* Options for creating an executor context.
*/
export interface ExecutorContextFactoryOptions {
workflowId?: string
workspaceId?: string
executionId?: string
userId?: string
blockStates?: Map<string, ExecutorBlockState> | Record<string, ExecutorBlockState>
executedBlocks?: Set<string> | string[]
blockLogs?: any[]
metadata?: {
duration?: number
startTime?: string
endTime?: string
}
environmentVariables?: Record<string, string>
workflowVariables?: Record<string, any>
workflow?: SerializedWorkflow
currentVirtualBlockId?: string
abortSignal?: AbortSignal
}
/**
* Creates an executor context with sensible defaults.
*
* @example
* ```ts
* const ctx = createExecutorContext({ workflowId: 'test-wf' })
*
* // With pre-populated block states
* const ctx = createExecutorContext({
* blockStates: {
* 'block-1': { output: { value: 10 }, executed: true, executionTime: 100 }
* }
* })
* ```
*/
export function createExecutorContext(
options: ExecutorContextFactoryOptions = {}
): ExecutorContext {
let blockStates: Map<string, ExecutorBlockState>
if (options.blockStates instanceof Map) {
blockStates = options.blockStates
} else if (options.blockStates) {
blockStates = new Map(Object.entries(options.blockStates))
} else {
blockStates = new Map()
}
let executedBlocks: Set<string>
if (options.executedBlocks instanceof Set) {
executedBlocks = options.executedBlocks
} else if (Array.isArray(options.executedBlocks)) {
executedBlocks = new Set(options.executedBlocks)
} else {
executedBlocks = new Set()
}
return {
workflowId: options.workflowId ?? 'test-workflow-id',
workspaceId: options.workspaceId ?? 'test-workspace-id',
executionId: options.executionId,
userId: options.userId,
blockStates,
executedBlocks,
blockLogs: options.blockLogs ?? [],
metadata: {
duration: options.metadata?.duration ?? 0,
startTime: options.metadata?.startTime,
endTime: options.metadata?.endTime,
},
environmentVariables: options.environmentVariables ?? {},
workflowVariables: options.workflowVariables,
decisions: {
router: new Map(),
condition: new Map(),
},
loopExecutions: new Map(),
completedLoops: new Set(),
activeExecutionPath: new Set(),
workflow: options.workflow,
currentVirtualBlockId: options.currentVirtualBlockId,
abortSignal: options.abortSignal,
}
}
/**
* Creates an executor context with pre-executed blocks.
*
* @example
* ```ts
* const ctx = createExecutorContextWithBlocks({
* 'source-block': { value: 10, text: 'hello' },
* 'other-block': { result: true }
* })
* ```
*/
export function createExecutorContextWithBlocks(
blockOutputs: Record<string, Record<string, any>>,
options: Omit<ExecutorContextFactoryOptions, 'blockStates' | 'executedBlocks'> = {}
): ExecutorContext {
const blockStates = new Map<string, ExecutorBlockState>()
const executedBlocks = new Set<string>()
for (const [blockId, output] of Object.entries(blockOutputs)) {
blockStates.set(blockId, {
output,
executed: true,
executionTime: 100,
})
executedBlocks.add(blockId)
}
return createExecutorContext({
...options,
blockStates,
executedBlocks,
})
}
/**
* Adds a block state to an existing context.
* Returns the context for chaining.
*/
export function addBlockState(
ctx: ExecutorContext,
blockId: string,
output: Record<string, any>,
executionTime = 100
): ExecutorContext {
;(ctx.blockStates as Map<string, ExecutorBlockState>).set(blockId, {
output,
executed: true,
executionTime,
})
;(ctx.executedBlocks as Set<string>).add(blockId)
return ctx
}
/**
* Creates a minimal workflow for context.
*/
export function createMinimalWorkflow(
blocks: SerializedBlock[],
connections: SerializedConnection[] = []
): SerializedWorkflow {
return {
version: '1.0',
blocks,
connections,
loops: {},
parallels: {},
}
}
+18
View File
@@ -0,0 +1,18 @@
const URL_SAFE_ALPHABET = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict'
/**
* Generates a short, URL-safe random ID for test fixtures.
*
* Uses `crypto.getRandomValues()` instead of `crypto.randomUUID()` for
* consistency with the app-level `generateShortId` utility.
*/
export function shortId(size = 8): string {
const bytes = new Uint8Array(size)
crypto.getRandomValues(bytes)
let id = ''
for (let i = 0; i < size; i++) {
id += URL_SAFE_ALPHABET[bytes[i] & 63]
}
return id
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Factory functions for creating test fixtures.
*
* Use these to create mock data with sensible defaults.
* All functions allow overriding any field.
*
* @example
* ```ts
* import {
* createBlock,
* createStarterBlock,
* createAgentBlock,
* createLinearWorkflow,
* createExecutionContext,
* } from '@sim/testing/factories'
*
* // Create a simple workflow
* const workflow = createLinearWorkflow(3)
*
* // Create a specific block
* const agent = createAgentBlock({ id: 'my-agent', position: { x: 100, y: 200 } })
*
* // Create execution context
* const ctx = createExecutionContext({ workflowId: 'test' })
* ```
*/
// Block factories
export {
type BlockFactoryOptions,
createAgentBlock,
createApiBlock,
createBlock,
createConditionBlock,
createFunctionBlock,
createKnowledgeBlock,
createLoopBlock,
createParallelBlock,
createResponseBlock,
createRouterBlock,
createStarterBlock,
createWebhookBlock,
} from './block.factory'
// DAG factories (for executor DAG tests)
export {
addNodeToDAG,
connectDAGNodes,
createDAG,
createDAGFromNodes,
createDAGNode,
createLinearDAG,
type DAG,
type DAGEdge,
type DAGNode,
type DAGNodeFactoryOptions,
} from './dag.factory'
// Edge factories
export { createEdge, createEdges, createLinearEdges, type EdgeFactoryOptions } from './edge.factory'
// Execution factories
export {
createCancelledExecutionContext,
createExecutionContext,
createExecutionContextWithStates,
createTimedExecutionContext,
type ExecutionContextFactoryOptions,
} from './execution.factory'
// Executor context factories (for executor tests)
export {
addBlockState,
createExecutorContext,
createExecutorContextWithBlocks,
createMinimalWorkflow,
type ExecutorBlockState,
type ExecutorContext,
type ExecutorContextFactoryOptions,
} from './executor-context.factory'
// Permission factories
export {
createAdminPermission,
createEncryptedApiKey,
createLegacyApiKey,
createPermission,
createReadPermission,
createSession,
createWorkflowAccessContext,
createWorkflowRecord,
createWorkspaceRecord,
createWritePermission,
type EntityType,
type MockSession,
type Permission,
type PermissionFactoryOptions,
type PermissionType,
ROLE_ALLOWED_OPERATIONS,
type SessionFactoryOptions,
SOCKET_OPERATIONS,
type SocketOperation,
type WorkflowAccessContext,
type WorkflowRecord,
type WorkflowRecordFactoryOptions,
type WorkspaceRecord,
type WorkspaceRecordFactoryOptions,
} from './permission.factory'
// Serialized block factories (for executor tests)
export {
createSerializedAgentBlock,
createSerializedBlock,
createSerializedConditionBlock,
createSerializedConnection,
createSerializedEvaluatorBlock,
createSerializedFunctionBlock,
createSerializedRouterBlock,
createSerializedStarterBlock,
createSerializedWorkflow,
resetSerializedBlockCounter,
type SerializedBlock,
type SerializedBlockFactoryOptions,
type SerializedConnection,
type SerializedWorkflow,
} from './serialized-block.factory'
// Tool mock responses
export {
mockDriveResponses,
mockGitHubResponses,
mockGmailResponses,
mockHttpResponses,
mockPineconeResponses,
mockSerperResponses,
mockSheetsResponses,
mockSlackResponses,
mockSupabaseResponses,
mockTavilyResponses,
} from './tool-responses.factory'
// Undo/redo operation factories
export {
type BaseOperation,
type BatchAddBlocksOperation,
type BatchAddEdgesOperation,
type BatchMoveBlocksOperation,
type BatchRemoveBlocksOperation,
type BatchRemoveEdgesOperation,
type BatchUpdateParentOperation,
createAddBlockEntry,
createAddEdgeEntry,
createBatchRemoveEdgesEntry,
createBatchUpdateParentEntry,
createMoveBlockEntry,
createRemoveBlockEntry,
createUpdateParentEntry,
type Operation,
type OperationEntry,
type OperationType,
type UpdateParentOperation,
} from './undo-redo.factory'
export {
createUser,
createUserWithWorkspace,
createWorkflow,
createWorkspace,
type UserFactoryOptions,
type WorkflowObjectFactoryOptions,
type WorkspaceFactoryOptions,
} from './user.factory'
export {
createAgentWithToolsWorkflowState,
createBranchingWorkflow,
createComplexWorkflowState,
createConditionalWorkflowState,
createInvalidSerializedWorkflow,
createInvalidWorkflowState,
createLinearWorkflow,
createLoopWorkflow,
createLoopWorkflowState,
createMinimalWorkflowState,
createMissingMetadataWorkflow,
createParallelWorkflow,
createWorkflowState,
type WorkflowFactoryOptions,
type WorkflowStateFixture,
} from './workflow.factory'
@@ -0,0 +1,346 @@
import { shortId } from './id'
/**
* Permission types in order of access level (highest to lowest).
*/
export type PermissionType = 'admin' | 'write' | 'read'
/**
* Entity types that can have permissions.
*/
export type EntityType = 'workspace' | 'workflow' | 'organization'
/**
* Permission record as stored in the database.
*/
export interface Permission {
id: string
userId: string
entityType: EntityType
entityId: string
permissionType: PermissionType
createdAt: Date
}
/**
* Options for creating a permission.
*/
export interface PermissionFactoryOptions {
id?: string
userId?: string
entityType?: EntityType
entityId?: string
permissionType?: PermissionType
createdAt?: Date
}
/**
* Creates a mock permission record.
*/
export function createPermission(options: PermissionFactoryOptions = {}): Permission {
return {
id: options.id ?? shortId(8),
userId: options.userId ?? `user-${shortId(6)}`,
entityType: options.entityType ?? 'workspace',
entityId: options.entityId ?? `ws-${shortId(6)}`,
permissionType: options.permissionType ?? 'read',
createdAt: options.createdAt ?? new Date(),
}
}
/**
* Creates a workspace admin permission.
*/
export function createAdminPermission(
userId: string,
workspaceId: string,
options: Partial<PermissionFactoryOptions> = {}
): Permission {
return createPermission({
userId,
entityType: 'workspace',
entityId: workspaceId,
permissionType: 'admin',
...options,
})
}
/**
* Creates a workspace write permission.
*/
export function createWritePermission(
userId: string,
workspaceId: string,
options: Partial<PermissionFactoryOptions> = {}
): Permission {
return createPermission({
userId,
entityType: 'workspace',
entityId: workspaceId,
permissionType: 'write',
...options,
})
}
/**
* Creates a workspace read permission.
*/
export function createReadPermission(
userId: string,
workspaceId: string,
options: Partial<PermissionFactoryOptions> = {}
): Permission {
return createPermission({
userId,
entityType: 'workspace',
entityId: workspaceId,
permissionType: 'read',
...options,
})
}
/**
* Workspace record for testing.
*/
export interface WorkspaceRecord {
id: string
name: string
ownerId: string
billedAccountUserId?: string
createdAt: Date
}
/**
* Options for creating a workspace.
*/
export interface WorkspaceRecordFactoryOptions {
id?: string
name?: string
ownerId?: string
billedAccountUserId?: string
createdAt?: Date
}
/**
* Creates a mock workspace record.
*/
export function createWorkspaceRecord(
options: WorkspaceRecordFactoryOptions = {}
): WorkspaceRecord {
const id = options.id ?? `ws-${shortId(6)}`
const ownerId = options.ownerId ?? `user-${shortId(6)}`
return {
id,
name: options.name ?? `Workspace ${id}`,
ownerId,
billedAccountUserId: options.billedAccountUserId ?? ownerId,
createdAt: options.createdAt ?? new Date(),
}
}
/**
* Workflow record for testing.
*/
export interface WorkflowRecord {
id: string
name: string
userId: string
workspaceId: string | null
state: string
isDeployed: boolean
runCount: number
createdAt: Date
}
/**
* Options for creating a workflow record.
*/
export interface WorkflowRecordFactoryOptions {
id?: string
name?: string
userId?: string
workspaceId?: string | null
state?: string
isDeployed?: boolean
runCount?: number
createdAt?: Date
}
/**
* Creates a mock workflow database record.
*/
export function createWorkflowRecord(options: WorkflowRecordFactoryOptions = {}): WorkflowRecord {
const id = options.id ?? `wf-${shortId(6)}`
return {
id,
name: options.name ?? `Workflow ${id}`,
userId: options.userId ?? `user-${shortId(6)}`,
workspaceId: options.workspaceId ?? null,
state: options.state ?? '{}',
isDeployed: options.isDeployed ?? false,
runCount: options.runCount ?? 0,
createdAt: options.createdAt ?? new Date(),
}
}
/**
* Session object for testing.
*/
export interface MockSession {
user: {
id: string
email: string
name?: string
}
expiresAt: Date
}
/**
* Options for creating a session.
*/
export interface SessionFactoryOptions {
userId?: string
email?: string
name?: string
expiresAt?: Date
}
/**
* Creates a mock session object.
*/
export function createSession(options: SessionFactoryOptions = {}): MockSession {
const userId = options.userId ?? `user-${shortId(6)}`
return {
user: {
id: userId,
email: options.email ?? `${userId}@test.com`,
name: options.name,
},
expiresAt: options.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000),
}
}
/**
* Workflow access context for testing.
*/
export interface WorkflowAccessContext {
workflow: WorkflowRecord
workspaceOwnerId: string | null
workspacePermission: PermissionType | null
isOwner: boolean
isWorkspaceOwner: boolean
}
/**
* Creates a mock workflow access context.
*/
export function createWorkflowAccessContext(options: {
workflow: WorkflowRecord
workspaceOwnerId?: string | null
workspacePermission?: PermissionType | null
userId?: string
}): WorkflowAccessContext {
const { workflow, workspaceOwnerId = null, workspacePermission = null, userId } = options
return {
workflow,
workspaceOwnerId,
workspacePermission,
isOwner: userId ? workflow.userId === userId : false,
isWorkspaceOwner: userId && workspaceOwnerId ? workspaceOwnerId === userId : false,
}
}
/**
* Socket operations
*/
const BLOCK_OPERATIONS = {
UPDATE_POSITION: 'update-position',
UPDATE_NAME: 'update-name',
TOGGLE_ENABLED: 'toggle-enabled',
UPDATE_PARENT: 'update-parent',
UPDATE_ADVANCED_MODE: 'update-advanced-mode',
UPDATE_CANONICAL_MODE: 'update-canonical-mode',
TOGGLE_HANDLES: 'toggle-handles',
} as const
const BLOCKS_OPERATIONS = {
BATCH_UPDATE_POSITIONS: 'batch-update-positions',
BATCH_ADD_BLOCKS: 'batch-add-blocks',
BATCH_REMOVE_BLOCKS: 'batch-remove-blocks',
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
BATCH_UPDATE_PARENT: 'batch-update-parent',
} as const
const EDGE_OPERATIONS = {
ADD: 'add',
REMOVE: 'remove',
} as const
const EDGES_OPERATIONS = {
BATCH_ADD_EDGES: 'batch-add-edges',
BATCH_REMOVE_EDGES: 'batch-remove-edges',
} as const
const SUBFLOW_OPERATIONS = {
UPDATE: 'update',
} as const
const WORKFLOW_OPERATIONS = {
REPLACE_STATE: 'replace-state',
} as const
/**
* All socket operations that require permission checks.
*/
export const SOCKET_OPERATIONS = [
...Object.values(BLOCK_OPERATIONS),
...Object.values(BLOCKS_OPERATIONS),
...Object.values(EDGE_OPERATIONS),
...Object.values(EDGES_OPERATIONS),
...Object.values(SUBFLOW_OPERATIONS),
...Object.values(WORKFLOW_OPERATIONS),
] as const
export type SocketOperation = (typeof SOCKET_OPERATIONS)[number]
/**
* Operations allowed for each role.
*/
export const ROLE_ALLOWED_OPERATIONS: Record<PermissionType, readonly SocketOperation[]> = {
admin: SOCKET_OPERATIONS,
write: SOCKET_OPERATIONS,
read: [BLOCK_OPERATIONS.UPDATE_POSITION, BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS],
}
/**
* API key formats for testing.
*/
export interface ApiKeyTestData {
plainKey: string
encryptedStorage: string
last4: string
}
/**
* Creates test API key data.
*/
export function createLegacyApiKey(): { key: string; prefix: string } {
const random = shortId(24)
return {
key: `sim_${random}`,
prefix: 'sim_',
}
}
/**
* Creates test encrypted format API key data.
*/
export function createEncryptedApiKey(): { key: string; prefix: string } {
const random = shortId(24)
return {
key: `sk-sim-${random}`,
prefix: 'sk-sim-',
}
}
@@ -0,0 +1,229 @@
/**
* Factory functions for creating SerializedBlock test fixtures.
* These are used in executor tests where blocks are in their serialized form.
*/
/**
* Serialized block structure used in executor tests.
*/
export interface SerializedBlock {
id: string
position: { x: number; y: number }
config: {
tool: string
params: Record<string, any>
}
inputs: Record<string, any>
outputs: Record<string, any>
metadata?: {
id: string
name?: string
description?: string
category?: string
icon?: string
color?: string
}
enabled: boolean
}
/**
* Serialized connection structure.
*/
export interface SerializedConnection {
source: string
target: string
sourceHandle?: string
targetHandle?: string
}
/**
* Serialized workflow structure.
*/
export interface SerializedWorkflow {
version: string
blocks: SerializedBlock[]
connections: SerializedConnection[]
loops: Record<string, any>
parallels?: Record<string, any>
}
/**
* Options for creating a serialized block.
*/
export interface SerializedBlockFactoryOptions {
id?: string
type?: string
name?: string
description?: string
position?: { x: number; y: number }
tool?: string
params?: Record<string, any>
inputs?: Record<string, any>
outputs?: Record<string, any>
enabled?: boolean
}
let blockCounter = 0
/**
* Generates a unique block ID.
*/
function generateBlockId(prefix = 'block'): string {
return `${prefix}-${++blockCounter}`
}
/**
* Resets the block counter (useful for deterministic tests).
*/
export function resetSerializedBlockCounter(): void {
blockCounter = 0
}
/**
* Creates a serialized block with sensible defaults.
*
* @example
* ```ts
* const block = createSerializedBlock({ type: 'agent', name: 'My Agent' })
* ```
*/
export function createSerializedBlock(
options: SerializedBlockFactoryOptions = {}
): SerializedBlock {
const type = options.type ?? 'function'
const id = options.id ?? generateBlockId(type)
return {
id,
position: options.position ?? { x: 0, y: 0 },
config: {
tool: options.tool ?? type,
params: options.params ?? {},
},
inputs: options.inputs ?? {},
outputs: options.outputs ?? {},
metadata: {
id: type,
name: options.name ?? `Block ${id}`,
description: options.description,
},
enabled: options.enabled ?? true,
}
}
/**
* Creates a serialized condition block.
*/
export function createSerializedConditionBlock(
options: Omit<SerializedBlockFactoryOptions, 'type'> = {}
): SerializedBlock {
return createSerializedBlock({
...options,
type: 'condition',
name: options.name ?? 'Condition',
inputs: options.inputs ?? { conditions: 'json' },
})
}
/**
* Creates a serialized router block.
*/
export function createSerializedRouterBlock(
options: Omit<SerializedBlockFactoryOptions, 'type'> = {}
): SerializedBlock {
return createSerializedBlock({
...options,
type: 'router',
name: options.name ?? 'Router',
inputs: options.inputs ?? { prompt: 'string', model: 'string' },
})
}
/**
* Creates a serialized evaluator block.
*/
export function createSerializedEvaluatorBlock(
options: Omit<SerializedBlockFactoryOptions, 'type'> = {}
): SerializedBlock {
return createSerializedBlock({
...options,
type: 'evaluator',
name: options.name ?? 'Evaluator',
inputs: options.inputs ?? {
content: 'string',
metrics: 'json',
model: 'string',
temperature: 'number',
},
})
}
/**
* Creates a serialized agent block.
*/
export function createSerializedAgentBlock(
options: Omit<SerializedBlockFactoryOptions, 'type'> = {}
): SerializedBlock {
return createSerializedBlock({
...options,
type: 'agent',
name: options.name ?? 'Agent',
})
}
/**
* Creates a serialized function block.
*/
export function createSerializedFunctionBlock(
options: Omit<SerializedBlockFactoryOptions, 'type'> = {}
): SerializedBlock {
return createSerializedBlock({
...options,
type: 'function',
name: options.name ?? 'Function',
})
}
/**
* Creates a serialized starter block.
*/
export function createSerializedStarterBlock(
options: Omit<SerializedBlockFactoryOptions, 'type'> = {}
): SerializedBlock {
return createSerializedBlock({
...options,
type: 'starter',
name: options.name ?? 'Start',
})
}
/**
* Creates a simple serialized connection.
*/
export function createSerializedConnection(
source: string,
target: string,
sourceHandle?: string
): SerializedConnection {
return {
source,
target,
sourceHandle,
}
}
/**
* Creates a serialized workflow with the given blocks and connections.
*/
export function createSerializedWorkflow(
blocks: SerializedBlock[],
connections: SerializedConnection[] = []
): SerializedWorkflow {
return {
version: '1.0',
blocks,
connections,
loops: {},
parallels: {},
}
}
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { createTableColumn } from './table.factory'
describe('table factory', () => {
it('generates default column names that match table naming rules', () => {
const generatedNames = Array.from({ length: 100 }, () => createTableColumn().name)
for (const name of generatedNames) {
expect(name).toMatch(/^[a-z_][a-z0-9_]*$/)
}
})
})
@@ -0,0 +1,62 @@
import { generateShortId } from '@sim/utils/id'
const COLUMN_SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789_'
export type TableColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json'
export interface TableColumnFixture {
name: string
type: TableColumnType
required?: boolean
unique?: boolean
}
export interface TableRowFixture {
id: string
data: Record<string, unknown>
position: number
createdAt: string
updatedAt: string
}
export interface TableColumnFactoryOptions {
name?: string
type?: TableColumnType
required?: boolean
unique?: boolean
}
export interface TableRowFactoryOptions {
id?: string
data?: Record<string, unknown>
position?: number
createdAt?: string
updatedAt?: string
}
/**
* Creates a table column fixture with sensible defaults.
*/
export function createTableColumn(options: TableColumnFactoryOptions = {}): TableColumnFixture {
return {
name: options.name ?? `column_${generateShortId(6, COLUMN_SUFFIX_ALPHABET)}`,
type: options.type ?? 'string',
required: options.required,
unique: options.unique,
}
}
/**
* Creates a table row fixture with sensible defaults.
*/
export function createTableRow(options: TableRowFactoryOptions = {}): TableRowFixture {
const timestamp = new Date().toISOString()
return {
id: options.id ?? `row_${generateShortId(8)}`,
data: options.data ?? {},
position: options.position ?? 0,
createdAt: options.createdAt ?? timestamp,
updatedAt: options.updatedAt ?? timestamp,
}
}
@@ -0,0 +1,321 @@
/**
* Mock Data for Tool Tests
*
* This file contains mock data samples to be used in tool unit tests.
*/
import { randomFloat } from '@sim/utils/random'
/**
* HTTP Request mock responses for different scenarios.
*/
export const mockHttpResponses = {
simple: {
data: { message: 'Success', status: 'ok' },
status: 200,
headers: { 'content-type': 'application/json' },
},
error: {
error: { message: 'Bad Request', code: 400 },
status: 400,
},
notFound: {
error: { message: 'Not Found', code: 404 },
status: 404,
},
unauthorized: {
error: { message: 'Unauthorized', code: 401 },
status: 401,
},
}
/**
* Gmail Mock Data
*/
export const mockGmailResponses = {
messageList: {
messages: [
{ id: 'msg1', threadId: 'thread1' },
{ id: 'msg2', threadId: 'thread2' },
{ id: 'msg3', threadId: 'thread3' },
],
nextPageToken: 'token123',
},
emptyList: {
messages: [],
resultSizeEstimate: 0,
},
singleMessage: {
id: 'msg1',
threadId: 'thread1',
labelIds: ['INBOX', 'UNREAD'],
snippet: 'This is a snippet preview of the email...',
payload: {
headers: [
{ name: 'From', value: 'sender@example.com' },
{ name: 'To', value: 'recipient@example.com' },
{ name: 'Subject', value: 'Test Email Subject' },
{ name: 'Date', value: 'Mon, 15 Mar 2025 10:30:00 -0800' },
],
mimeType: 'multipart/alternative',
parts: [
{
mimeType: 'text/plain',
body: {
data: Buffer.from('This is the plain text content of the email').toString('base64'),
},
},
{
mimeType: 'text/html',
body: {
data: Buffer.from('<div>This is the HTML content of the email</div>').toString(
'base64'
),
},
},
],
},
},
}
/**
* Google Drive Mock Data
*/
export const mockDriveResponses = {
fileList: {
files: [
{ id: 'file1', name: 'Document1.docx', mimeType: 'application/vnd.google-apps.document' },
{
id: 'file2',
name: 'Spreadsheet.xlsx',
mimeType: 'application/vnd.google-apps.spreadsheet',
},
{
id: 'file3',
name: 'Presentation.pptx',
mimeType: 'application/vnd.google-apps.presentation',
},
],
nextPageToken: 'drive-page-token',
},
emptyFileList: {
files: [],
},
fileMetadata: {
id: 'file1',
name: 'Document1.docx',
mimeType: 'application/vnd.google-apps.document',
webViewLink: 'https://docs.google.com/document/d/123/edit',
createdTime: '2025-03-15T12:00:00Z',
modifiedTime: '2025-03-16T10:15:00Z',
owners: [{ displayName: 'Test User', emailAddress: 'user@example.com' }],
size: '12345',
},
}
/**
* Google Sheets Mock Data
*/
export const mockSheetsResponses = {
rangeData: {
range: 'Sheet1!A1:D5',
majorDimension: 'ROWS',
values: [
['Header1', 'Header2', 'Header3', 'Header4'],
['Row1Col1', 'Row1Col2', 'Row1Col3', 'Row1Col4'],
['Row2Col1', 'Row2Col2', 'Row2Col3', 'Row2Col4'],
['Row3Col1', 'Row3Col2', 'Row3Col3', 'Row3Col4'],
['Row4Col1', 'Row4Col2', 'Row4Col3', 'Row4Col4'],
],
},
emptyRange: {
range: 'Sheet1!A1:D5',
majorDimension: 'ROWS',
values: [],
},
updateResponse: {
spreadsheetId: 'spreadsheet123',
updatedRange: 'Sheet1!A1:D5',
updatedRows: 5,
updatedColumns: 4,
updatedCells: 20,
},
}
/**
* Pinecone Mock Data
*/
export const mockPineconeResponses = {
embedding: {
embedding: Array(1536)
.fill(0)
.map(() => randomFloat() * 2 - 1),
metadata: { text: 'Sample text for embedding', id: 'embed-123' },
},
searchResults: {
matches: [
{ id: 'doc1', score: 0.92, metadata: { text: 'Matching text 1' } },
{ id: 'doc2', score: 0.85, metadata: { text: 'Matching text 2' } },
{ id: 'doc3', score: 0.78, metadata: { text: 'Matching text 3' } },
],
},
upsertResponse: {
statusText: 'Created',
},
}
/**
* GitHub Mock Data
*/
export const mockGitHubResponses = {
repoInfo: {
id: 12345,
name: 'test-repo',
full_name: 'user/test-repo',
description: 'A test repository',
html_url: 'https://github.com/user/test-repo',
owner: {
login: 'user',
id: 54321,
avatar_url: 'https://avatars.githubusercontent.com/u/54321',
},
private: false,
fork: false,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-03-15T10:00:00Z',
pushed_at: '2025-03-15T09:00:00Z',
default_branch: 'main',
open_issues_count: 5,
watchers_count: 10,
forks_count: 3,
stargazers_count: 15,
language: 'TypeScript',
},
prResponse: {
id: 12345,
number: 42,
title: 'Test PR Title',
body: 'Test PR description',
html_url: 'https://github.com/user/test-repo/pull/42',
state: 'open',
user: { login: 'user', id: 54321 },
created_at: '2025-03-15T10:00:00Z',
updated_at: '2025-03-15T10:05:00Z',
},
}
/**
* Serper Search Mock Data
*/
export const mockSerperResponses = {
searchResults: {
searchParameters: { q: 'test query', gl: 'us', hl: 'en' },
organic: [
{
title: 'Test Result 1',
link: 'https://example.com/1',
snippet: 'This is a snippet for the first test result.',
position: 1,
},
{
title: 'Test Result 2',
link: 'https://example.com/2',
snippet: 'This is a snippet for the second test result.',
position: 2,
},
{
title: 'Test Result 3',
link: 'https://example.com/3',
snippet: 'This is a snippet for the third test result.',
position: 3,
},
],
knowledgeGraph: {
title: 'Test Knowledge Graph',
type: 'Test Type',
description: 'This is a test knowledge graph result',
},
},
}
/**
* Slack Mock Data
*/
export const mockSlackResponses = {
messageResponse: {
ok: true,
channel: 'C1234567890',
ts: '1627385301.000700',
message: {
text: 'This is a test message',
user: 'U1234567890',
ts: '1627385301.000700',
team: 'T1234567890',
},
},
errorResponse: {
ok: false,
error: 'channel_not_found',
},
}
/**
* Tavily Mock Data
*/
export const mockTavilyResponses = {
searchResults: {
results: [
{
title: 'Test Article 1',
url: 'https://example.com/article1',
content: 'This is the content of test article 1.',
score: 0.95,
},
{
title: 'Test Article 2',
url: 'https://example.com/article2',
content: 'This is the content of test article 2.',
score: 0.87,
},
{
title: 'Test Article 3',
url: 'https://example.com/article3',
content: 'This is the content of test article 3.',
score: 0.72,
},
],
query: 'test query',
search_id: 'search-123',
},
}
/**
* Supabase Mock Data
*/
export const mockSupabaseResponses = {
queryResponse: {
data: [
{ id: 1, name: 'Item 1', description: 'Description 1' },
{ id: 2, name: 'Item 2', description: 'Description 2' },
{ id: 3, name: 'Item 3', description: 'Description 3' },
],
error: null,
},
insertResponse: {
data: [{ id: 4, name: 'Item 4', description: 'Description 4' }],
error: null,
},
updateResponse: {
data: [{ id: 1, name: 'Updated Item 1', description: 'Updated Description 1' }],
error: null,
},
errorResponse: {
data: null,
error: {
message: 'Database error',
details: 'Error details',
hint: 'Error hint',
code: 'DB_ERROR',
},
},
}
@@ -0,0 +1,478 @@
import { shortId } from './id'
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Operation types supported by the undo/redo store.
*/
export type OperationType =
| 'batch-add-blocks'
| 'batch-remove-blocks'
| 'batch-add-edges'
| 'batch-remove-edges'
| 'batch-move-blocks'
| 'update-parent'
| 'batch-update-parent'
/**
* Base operation interface.
*/
export interface BaseOperation {
id: string
type: OperationType
timestamp: number
workflowId: string
userId: string
}
/**
* Batch move blocks operation data.
*/
export interface BatchMoveBlocksOperation extends BaseOperation {
type: 'batch-move-blocks'
data: {
moves: Array<{
blockId: string
before: { x: number; y: number; parentId?: string }
after: { x: number; y: number; parentId?: string }
}>
}
}
/**
* Batch add blocks operation data.
*/
export interface BatchAddBlocksOperation extends BaseOperation {
type: 'batch-add-blocks'
data: {
blockSnapshots: any[]
edgeSnapshots: any[]
subBlockValues: Record<string, Record<string, any>>
}
}
/**
* Batch remove blocks operation data.
*/
export interface BatchRemoveBlocksOperation extends BaseOperation {
type: 'batch-remove-blocks'
data: {
blockSnapshots: any[]
edgeSnapshots: any[]
subBlockValues: Record<string, Record<string, any>>
}
}
/**
* Batch add edges operation data.
*/
export interface BatchAddEdgesOperation extends BaseOperation {
type: 'batch-add-edges'
data: { edgeSnapshots: any[] }
}
/**
* Batch remove edges operation data.
*/
export interface BatchRemoveEdgesOperation extends BaseOperation {
type: 'batch-remove-edges'
data: { edgeSnapshots: any[] }
}
/**
* Update parent operation data.
*/
export interface UpdateParentOperation extends BaseOperation {
type: 'update-parent'
data: {
blockId: string
oldParentId?: string
newParentId?: string
oldPosition: { x: number; y: number }
newPosition: { x: number; y: number }
}
}
export interface BatchUpdateParentOperation extends BaseOperation {
type: 'batch-update-parent'
data: {
updates: Array<{
blockId: string
oldParentId?: string
newParentId?: string
oldPosition: { x: number; y: number }
newPosition: { x: number; y: number }
affectedEdges?: any[]
}>
}
}
export type Operation =
| BatchAddBlocksOperation
| BatchRemoveBlocksOperation
| BatchAddEdgesOperation
| BatchRemoveEdgesOperation
| BatchMoveBlocksOperation
| UpdateParentOperation
| BatchUpdateParentOperation
/**
* Operation entry with forward and inverse operations.
*/
export interface OperationEntry {
id: string
operation: Operation
inverse: Operation
createdAt: number
}
interface OperationEntryOptions {
id?: string
workflowId?: string
userId?: string
createdAt?: number
}
/**
* Creates a mock batch-add-blocks operation entry.
*/
export function createAddBlockEntry(blockId: string, options: OperationEntryOptions = {}): any {
const {
id = shortId(8),
workflowId = 'wf-1',
userId = 'user-1',
createdAt = Date.now(),
} = options
const timestamp = Date.now()
const mockBlockSnapshot = {
id: blockId,
type: 'action',
name: `Block ${blockId}`,
position: { x: 0, y: 0 },
}
return {
id,
createdAt,
operation: {
id: shortId(8),
type: 'batch-add-blocks',
timestamp,
workflowId,
userId,
data: {
blockSnapshots: [mockBlockSnapshot],
edgeSnapshots: [],
subBlockValues: {},
},
},
inverse: {
id: shortId(8),
type: 'batch-remove-blocks',
timestamp,
workflowId,
userId,
data: {
blockSnapshots: [mockBlockSnapshot],
edgeSnapshots: [],
subBlockValues: {},
},
},
}
}
/**
* Creates a mock batch-remove-blocks operation entry.
*/
export function createRemoveBlockEntry(
blockId: string,
blockSnapshot: any = null,
options: OperationEntryOptions = {}
): any {
const {
id = shortId(8),
workflowId = 'wf-1',
userId = 'user-1',
createdAt = Date.now(),
} = options
const timestamp = Date.now()
const snapshotToUse = blockSnapshot || {
id: blockId,
type: 'action',
name: `Block ${blockId}`,
position: { x: 0, y: 0 },
}
return {
id,
createdAt,
operation: {
id: shortId(8),
type: 'batch-remove-blocks',
timestamp,
workflowId,
userId,
data: {
blockSnapshots: [snapshotToUse],
edgeSnapshots: [],
subBlockValues: {},
},
},
inverse: {
id: shortId(8),
type: 'batch-add-blocks',
timestamp,
workflowId,
userId,
data: {
blockSnapshots: [snapshotToUse],
edgeSnapshots: [],
subBlockValues: {},
},
},
}
}
/**
* Creates a mock batch-add-edges operation entry for a single edge.
*/
export function createAddEdgeEntry(
edgeId: string,
edgeSnapshot: any = null,
options: OperationEntryOptions = {}
): any {
const {
id = shortId(8),
workflowId = 'wf-1',
userId = 'user-1',
createdAt = Date.now(),
} = options
const timestamp = Date.now()
const snapshot = edgeSnapshot || { id: edgeId, source: 'block-1', target: 'block-2' }
return {
id,
createdAt,
operation: {
id: shortId(8),
type: 'batch-add-edges',
timestamp,
workflowId,
userId,
data: { edgeSnapshots: [snapshot] },
},
inverse: {
id: shortId(8),
type: 'batch-remove-edges',
timestamp,
workflowId,
userId,
data: { edgeSnapshots: [snapshot] },
},
}
}
/**
* Creates a mock batch-remove-edges operation entry.
*/
export function createBatchRemoveEdgesEntry(
edgeSnapshots: any[],
options: OperationEntryOptions = {}
): any {
const {
id = shortId(8),
workflowId = 'wf-1',
userId = 'user-1',
createdAt = Date.now(),
} = options
const timestamp = Date.now()
return {
id,
createdAt,
operation: {
id: shortId(8),
type: 'batch-remove-edges',
timestamp,
workflowId,
userId,
data: { edgeSnapshots },
},
inverse: {
id: shortId(8),
type: 'batch-add-edges',
timestamp,
workflowId,
userId,
data: { edgeSnapshots },
},
}
}
interface MoveBlockOptions extends OperationEntryOptions {
before?: { x: number; y: number; parentId?: string }
after?: { x: number; y: number; parentId?: string }
}
/**
* Creates a mock batch-move-blocks operation entry for a single block.
*/
export function createMoveBlockEntry(blockId: string, options: MoveBlockOptions = {}): any {
const {
id = shortId(8),
workflowId = 'wf-1',
userId = 'user-1',
createdAt = Date.now(),
before = { x: 0, y: 0 },
after = { x: 100, y: 100 },
} = options
const timestamp = Date.now()
return {
id,
createdAt,
operation: {
id: shortId(8),
type: 'batch-move-blocks',
timestamp,
workflowId,
userId,
data: { moves: [{ blockId, before, after }] },
},
inverse: {
id: shortId(8),
type: 'batch-move-blocks',
timestamp,
workflowId,
userId,
data: { moves: [{ blockId, before: after, after: before }] },
},
}
}
/**
* Creates a mock update-parent operation entry.
*/
export function createUpdateParentEntry(
blockId: string,
options: OperationEntryOptions & {
oldParentId?: string
newParentId?: string
oldPosition?: { x: number; y: number }
newPosition?: { x: number; y: number }
} = {}
): any {
const {
id = shortId(8),
workflowId = 'wf-1',
userId = 'user-1',
createdAt = Date.now(),
oldParentId,
newParentId,
oldPosition = { x: 0, y: 0 },
newPosition = { x: 50, y: 50 },
} = options
const timestamp = Date.now()
return {
id,
createdAt,
operation: {
id: shortId(8),
type: 'update-parent',
timestamp,
workflowId,
userId,
data: { blockId, oldParentId, newParentId, oldPosition, newPosition },
},
inverse: {
id: shortId(8),
type: 'update-parent',
timestamp,
workflowId,
userId,
data: {
blockId,
oldParentId: newParentId,
newParentId: oldParentId,
oldPosition: newPosition,
newPosition: oldPosition,
},
},
}
}
interface BatchUpdateParentOptions extends OperationEntryOptions {
updates?: Array<{
blockId: string
oldParentId?: string
newParentId?: string
oldPosition?: { x: number; y: number }
newPosition?: { x: number; y: number }
affectedEdges?: any[]
}>
}
/**
* Creates a mock batch-update-parent operation entry.
*/
export function createBatchUpdateParentEntry(options: BatchUpdateParentOptions = {}): any {
const {
id = shortId(8),
workflowId = 'wf-1',
userId = 'user-1',
createdAt = Date.now(),
updates = [
{
blockId: 'block-1',
oldParentId: undefined,
newParentId: 'loop-1',
oldPosition: { x: 0, y: 0 },
newPosition: { x: 50, y: 50 },
},
],
} = options
const timestamp = Date.now()
const processedUpdates = updates.map((u) => ({
blockId: u.blockId,
oldParentId: u.oldParentId,
newParentId: u.newParentId,
oldPosition: u.oldPosition || { x: 0, y: 0 },
newPosition: u.newPosition || { x: 50, y: 50 },
affectedEdges: u.affectedEdges,
}))
return {
id,
createdAt,
operation: {
id: shortId(8),
type: 'batch-update-parent',
timestamp,
workflowId,
userId,
data: { updates: processedUpdates },
},
inverse: {
id: shortId(8),
type: 'batch-update-parent',
timestamp,
workflowId,
userId,
data: {
updates: processedUpdates.map((u) => ({
blockId: u.blockId,
oldParentId: u.newParentId,
newParentId: u.oldParentId,
oldPosition: u.newPosition,
newPosition: u.oldPosition,
affectedEdges: u.affectedEdges,
})),
},
},
}
}
@@ -0,0 +1,115 @@
import { generateRandomString } from '@sim/utils/random'
import type { User, Workflow, WorkflowState, Workspace } from '../types'
import { createWorkflowState } from './workflow.factory'
/**
* Options for creating a mock user.
*/
export interface UserFactoryOptions {
id?: string
email?: string
name?: string
image?: string
}
/**
* Creates a mock user.
*
* @example
* ```ts
* const user = createUser({ email: 'test@example.com' })
* ```
*/
export function createUser(options: UserFactoryOptions = {}): User {
const id = options.id ?? `user-${generateRandomString(8)}`
return {
id,
email: options.email ?? `${id}@test.example.com`,
name: options.name ?? `Test User ${id.substring(0, 4)}`,
image: options.image,
}
}
/**
* Options for creating a mock workspace.
*/
export interface WorkspaceFactoryOptions {
id?: string
name?: string
ownerId?: string
createdAt?: Date
updatedAt?: Date
}
/**
* Creates a mock workspace.
*
* @example
* ```ts
* const workspace = createWorkspace({ name: 'My Workspace' })
* ```
*/
export function createWorkspace(options: WorkspaceFactoryOptions = {}): Workspace {
const now = new Date()
return {
id: options.id ?? `ws-${generateRandomString(8)}`,
name: options.name ?? 'Test Workspace',
ownerId: options.ownerId ?? `user-${generateRandomString(8)}`,
createdAt: options.createdAt ?? now,
updatedAt: options.updatedAt ?? now,
}
}
/**
* Options for creating a mock workflow.
*/
export interface WorkflowObjectFactoryOptions {
id?: string
name?: string
workspaceId?: string
state?: WorkflowState
createdAt?: Date
updatedAt?: Date
isDeployed?: boolean
}
/**
* Creates a mock workflow object (not just state).
*
* @example
* ```ts
* const workflow = createWorkflow({ name: 'My Workflow' })
* ```
*/
export function createWorkflow(options: WorkflowObjectFactoryOptions = {}): Workflow {
const now = new Date()
return {
id: options.id ?? `wf-${generateRandomString(8)}`,
name: options.name ?? 'Test Workflow',
workspaceId: options.workspaceId ?? `ws-${generateRandomString(8)}`,
state: options.state ?? createWorkflowState(),
createdAt: options.createdAt ?? now,
updatedAt: options.updatedAt ?? now,
isDeployed: options.isDeployed ?? false,
}
}
/**
* Creates a user with an associated workspace.
*
* @example
* ```ts
* const { user, workspace } = createUserWithWorkspace()
* ```
*/
export function createUserWithWorkspace(
userOptions: UserFactoryOptions = {},
workspaceOptions: Omit<WorkspaceFactoryOptions, 'ownerId'> = {}
): { user: User; workspace: Workspace } {
const user = createUser(userOptions)
const workspace = createWorkspace({
...workspaceOptions,
ownerId: user.id,
})
return { user, workspace }
}
@@ -0,0 +1,628 @@
import { createBlock, createFunctionBlock, createStarterBlock } from './block.factory'
import { createLinearEdges } from './edge.factory'
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Options for creating a mock workflow state.
* Uses `any` for complex types to avoid conflicts with app types.
*/
export interface WorkflowFactoryOptions {
blocks?: Record<string, any>
edges?: any[]
loops?: Record<string, any>
parallels?: Record<string, any>
lastSaved?: number
isDeployed?: boolean
variables?: any[]
}
/**
* Creates an empty workflow state with defaults.
*
* @example
* ```ts
* const workflow = createWorkflowState()
* ```
*/
export function createWorkflowState(options: WorkflowFactoryOptions = {}): any {
return {
blocks: options.blocks ?? {},
edges: options.edges ?? [],
loops: options.loops ?? {},
parallels: options.parallels ?? {},
lastSaved: options.lastSaved ?? Date.now(),
isDeployed: options.isDeployed ?? false,
variables: options.variables,
}
}
/**
* Creates a simple linear workflow with the specified number of blocks.
* First block is always a starter, rest are function blocks.
*
* @example
* ```ts
* // Creates: starter -> function -> function
* const workflow = createLinearWorkflow(3)
* ```
*/
export function createLinearWorkflow(blockCount: number, spacing = 200): any {
if (blockCount < 1) {
return createWorkflowState()
}
const blocks: Record<string, any> = {}
const blockIds: string[] = []
for (let i = 0; i < blockCount; i++) {
const id = `block-${i}`
blockIds.push(id)
if (i === 0) {
blocks[id] = createStarterBlock({
id,
position: { x: i * spacing, y: 0 },
})
} else {
blocks[id] = createFunctionBlock({
id,
name: `Step ${i}`,
position: { x: i * spacing, y: 0 },
})
}
}
return createWorkflowState({
blocks,
edges: createLinearEdges(blockIds),
})
}
/**
* Creates a workflow with a branching condition.
*
* Structure:
* ```
* ┌─→ true-branch ─┐
* start ─→ condition ├─→ end
* └─→ false-branch ┘
* ```
*/
export function createBranchingWorkflow(): any {
const blocks: Record<string, any> = {
start: createStarterBlock({ id: 'start', position: { x: 0, y: 0 } }),
condition: createBlock({
id: 'condition',
type: 'condition',
name: 'Check',
position: { x: 200, y: 0 },
}),
'true-branch': createFunctionBlock({
id: 'true-branch',
name: 'If True',
position: { x: 400, y: -100 },
}),
'false-branch': createFunctionBlock({
id: 'false-branch',
name: 'If False',
position: { x: 400, y: 100 },
}),
end: createFunctionBlock({ id: 'end', name: 'End', position: { x: 600, y: 0 } }),
}
const edges: any[] = [
{ id: 'e1', source: 'start', target: 'condition' },
{ id: 'e2', source: 'condition', target: 'true-branch', sourceHandle: 'condition-if' },
{ id: 'e3', source: 'condition', target: 'false-branch', sourceHandle: 'condition-else' },
{ id: 'e4', source: 'true-branch', target: 'end' },
{ id: 'e5', source: 'false-branch', target: 'end' },
]
return createWorkflowState({ blocks, edges })
}
/**
* Creates a workflow with a loop container.
*
* Structure:
* ```
* start ─→ loop[loop-body] ─→ end
* ```
*/
export function createLoopWorkflow(iterations = 3): any {
const blocks: Record<string, any> = {
start: createStarterBlock({ id: 'start', position: { x: 0, y: 0 } }),
loop: createBlock({
id: 'loop',
type: 'loop',
name: 'Loop',
position: { x: 200, y: 0 },
data: { loopType: 'for', count: iterations, type: 'loop' },
}),
'loop-body': createFunctionBlock({
id: 'loop-body',
name: 'Loop Body',
position: { x: 50, y: 50 },
parentId: 'loop',
}),
end: createFunctionBlock({ id: 'end', name: 'End', position: { x: 500, y: 0 } }),
}
const edges: any[] = [
{ id: 'e1', source: 'start', target: 'loop' },
{ id: 'e2', source: 'loop', target: 'end' },
]
const loops: Record<string, any> = {
loop: {
id: 'loop',
nodes: ['loop-body'],
iterations,
loopType: 'for',
},
}
return createWorkflowState({ blocks, edges, loops })
}
/**
* Creates a workflow with a parallel container.
*
* Structure:
* ```
* start ─→ parallel[parallel-task] ─→ end
* ```
*/
export function createParallelWorkflow(count = 2): any {
const blocks: Record<string, any> = {
start: createStarterBlock({ id: 'start', position: { x: 0, y: 0 } }),
parallel: createBlock({
id: 'parallel',
type: 'parallel',
name: 'Parallel',
position: { x: 200, y: 0 },
data: { parallelType: 'count', count, type: 'parallel' },
}),
'parallel-task': createFunctionBlock({
id: 'parallel-task',
name: 'Parallel Task',
position: { x: 50, y: 50 },
parentId: 'parallel',
}),
end: createFunctionBlock({ id: 'end', name: 'End', position: { x: 500, y: 0 } }),
}
const edges: any[] = [
{ id: 'e1', source: 'start', target: 'parallel' },
{ id: 'e2', source: 'parallel', target: 'end' },
]
const parallels: Record<string, any> = {
parallel: {
id: 'parallel',
nodes: ['parallel-task'],
count,
parallelType: 'count',
},
}
return createWorkflowState({ blocks, edges, parallels })
}
/**
* Detailed workflow state fixture interface for serializer tests.
*/
export interface WorkflowStateFixture {
blocks: Record<string, any>
edges: any[]
loops: Record<string, any>
}
/**
* Creates a minimal workflow with a starter and one agent block.
* Includes full subBlocks structure for serializer testing.
*/
export function createMinimalWorkflowState(): WorkflowStateFixture {
const blocks: Record<string, any> = {
starter: {
id: 'starter',
type: 'starter',
name: 'Starter Block',
position: { x: 0, y: 0 },
subBlocks: {
description: { id: 'description', type: 'long-input', value: 'This is the starter block' },
},
outputs: {},
enabled: true,
},
agent1: {
id: 'agent1',
type: 'agent',
name: 'Agent Block',
position: { x: 300, y: 0 },
subBlocks: {
provider: { id: 'provider', type: 'dropdown', value: 'anthropic' },
model: { id: 'model', type: 'dropdown', value: 'claude-3-7-sonnet-20250219' },
prompt: { id: 'prompt', type: 'long-input', value: 'Hello, world!' },
tools: { id: 'tools', type: 'tool-input', value: '[]' },
system: { id: 'system', type: 'long-input', value: 'You are a helpful assistant.' },
responseFormat: { id: 'responseFormat', type: 'code', value: null },
},
outputs: {},
enabled: true,
},
}
return {
blocks,
edges: [{ id: 'edge1', source: 'starter', target: 'agent1' }],
loops: {},
}
}
/**
* Creates a workflow with condition blocks and branching paths.
*/
export function createConditionalWorkflowState(): WorkflowStateFixture {
const blocks: Record<string, any> = {
starter: {
id: 'starter',
type: 'starter',
name: 'Starter Block',
position: { x: 0, y: 0 },
subBlocks: {
description: { id: 'description', type: 'long-input', value: 'This is the starter block' },
},
outputs: {},
enabled: true,
},
condition1: {
id: 'condition1',
type: 'condition',
name: 'Condition Block',
position: { x: 300, y: 0 },
subBlocks: {
condition: { id: 'condition', type: 'long-input', value: 'input.value > 10' },
},
outputs: {},
enabled: true,
},
agent1: {
id: 'agent1',
type: 'agent',
name: 'True Path Agent',
position: { x: 600, y: -100 },
subBlocks: {
provider: { id: 'provider', type: 'dropdown', value: 'anthropic' },
model: { id: 'model', type: 'dropdown', value: 'claude-3-7-sonnet-20250219' },
prompt: { id: 'prompt', type: 'long-input', value: 'Value is greater than 10' },
tools: { id: 'tools', type: 'tool-input', value: '[]' },
system: { id: 'system', type: 'long-input', value: 'You are a helpful assistant.' },
responseFormat: { id: 'responseFormat', type: 'code', value: null },
},
outputs: {},
enabled: true,
},
agent2: {
id: 'agent2',
type: 'agent',
name: 'False Path Agent',
position: { x: 600, y: 100 },
subBlocks: {
provider: { id: 'provider', type: 'dropdown', value: 'anthropic' },
model: { id: 'model', type: 'dropdown', value: 'claude-3-7-sonnet-20250219' },
prompt: { id: 'prompt', type: 'long-input', value: 'Value is less than or equal to 10' },
tools: { id: 'tools', type: 'tool-input', value: '[]' },
system: { id: 'system', type: 'long-input', value: 'You are a helpful assistant.' },
responseFormat: { id: 'responseFormat', type: 'code', value: null },
},
outputs: {},
enabled: true,
},
}
return {
blocks,
edges: [
{ id: 'edge1', source: 'starter', target: 'condition1' },
{ id: 'edge2', source: 'condition1', target: 'agent1', sourceHandle: 'condition-true' },
{ id: 'edge3', source: 'condition1', target: 'agent2', sourceHandle: 'condition-false' },
],
loops: {},
}
}
/**
* Creates a workflow with a loop structure.
*/
export function createLoopWorkflowState(): WorkflowStateFixture {
const blocks: Record<string, any> = {
starter: {
id: 'starter',
type: 'starter',
name: 'Starter Block',
position: { x: 0, y: 0 },
subBlocks: {
description: { id: 'description', type: 'long-input', value: 'This is the starter block' },
},
outputs: {},
enabled: true,
},
function1: {
id: 'function1',
type: 'function',
name: 'Function Block',
position: { x: 300, y: 0 },
subBlocks: {
code: {
id: 'code',
type: 'code',
value: 'let counter = input.counter || 0;\ncounter++;\nreturn { counter };',
},
language: { id: 'language', type: 'dropdown', value: 'javascript' },
},
outputs: {},
enabled: true,
},
condition1: {
id: 'condition1',
type: 'condition',
name: 'Loop Condition',
position: { x: 600, y: 0 },
subBlocks: {
condition: { id: 'condition', type: 'long-input', value: 'input.counter < 5' },
},
outputs: {},
enabled: true,
},
agent1: {
id: 'agent1',
type: 'agent',
name: 'Loop Complete Agent',
position: { x: 900, y: 100 },
subBlocks: {
provider: { id: 'provider', type: 'dropdown', value: 'anthropic' },
model: { id: 'model', type: 'dropdown', value: 'claude-3-7-sonnet-20250219' },
prompt: {
id: 'prompt',
type: 'long-input',
value: 'Loop completed after {{input.counter}} iterations',
},
tools: { id: 'tools', type: 'tool-input', value: '[]' },
system: { id: 'system', type: 'long-input', value: 'You are a helpful assistant.' },
responseFormat: { id: 'responseFormat', type: 'code', value: null },
},
outputs: {},
enabled: true,
},
}
return {
blocks,
edges: [
{ id: 'edge1', source: 'starter', target: 'function1' },
{ id: 'edge2', source: 'function1', target: 'condition1' },
{ id: 'edge3', source: 'condition1', target: 'function1', sourceHandle: 'condition-true' },
{ id: 'edge4', source: 'condition1', target: 'agent1', sourceHandle: 'condition-false' },
],
loops: {
loop1: { id: 'loop1', nodes: ['function1', 'condition1'], iterations: 10, loopType: 'for' },
},
}
}
/**
* Creates a complex workflow with multiple block types (API, function, agent).
*/
export function createComplexWorkflowState(): WorkflowStateFixture {
const blocks: Record<string, any> = {
starter: {
id: 'starter',
type: 'starter',
name: 'Starter Block',
position: { x: 0, y: 0 },
subBlocks: {
description: { id: 'description', type: 'long-input', value: 'This is the starter block' },
},
outputs: {},
enabled: true,
},
api1: {
id: 'api1',
type: 'api',
name: 'API Request',
position: { x: 300, y: 0 },
subBlocks: {
url: { id: 'url', type: 'short-input', value: 'https://api.example.com/data' },
method: { id: 'method', type: 'dropdown', value: 'GET' },
headers: {
id: 'headers',
type: 'table',
value: [
['Content-Type', 'application/json'],
['Authorization', 'Bearer {{API_KEY}}'],
],
},
body: { id: 'body', type: 'long-input', value: '' },
},
outputs: {},
enabled: true,
},
function1: {
id: 'function1',
type: 'function',
name: 'Process Data',
position: { x: 600, y: 0 },
subBlocks: {
code: {
id: 'code',
type: 'code',
value: 'const data = input.data;\nreturn { processed: data.map(item => item.name) };',
},
language: { id: 'language', type: 'dropdown', value: 'javascript' },
},
outputs: {},
enabled: true,
},
agent1: {
id: 'agent1',
type: 'agent',
name: 'Summarize Data',
position: { x: 900, y: 0 },
subBlocks: {
provider: { id: 'provider', type: 'dropdown', value: 'openai' },
model: { id: 'model', type: 'dropdown', value: 'gpt-4o' },
prompt: {
id: 'prompt',
type: 'long-input',
value: 'Summarize the following data:\n\n{{input.processed}}',
},
tools: {
id: 'tools',
type: 'tool-input',
value:
'[{"type":"function","name":"calculator","description":"Perform calculations","parameters":{"type":"object","properties":{"expression":{"type":"string","description":"Math expression to evaluate"}},"required":["expression"]}}]',
},
system: { id: 'system', type: 'long-input', value: 'You are a data analyst assistant.' },
responseFormat: {
id: 'responseFormat',
type: 'code',
value:
'{"type":"object","properties":{"summary":{"type":"string"},"keyPoints":{"type":"array","items":{"type":"string"}},"sentiment":{"type":"string","enum":["positive","neutral","negative"]}},"required":["summary","keyPoints","sentiment"]}',
},
},
outputs: {},
enabled: true,
},
}
return {
blocks,
edges: [
{ id: 'edge1', source: 'starter', target: 'api1' },
{ id: 'edge2', source: 'api1', target: 'function1' },
{ id: 'edge3', source: 'function1', target: 'agent1' },
],
loops: {},
}
}
/**
* Creates a workflow with an agent that has custom tools.
*/
export function createAgentWithToolsWorkflowState(): WorkflowStateFixture {
const blocks: Record<string, any> = {
starter: {
id: 'starter',
type: 'starter',
name: 'Starter Block',
position: { x: 0, y: 0 },
subBlocks: {
description: { id: 'description', type: 'long-input', value: 'This is the starter block' },
},
outputs: {},
enabled: true,
},
agent1: {
id: 'agent1',
type: 'agent',
name: 'Custom Tools Agent',
position: { x: 300, y: 0 },
subBlocks: {
provider: { id: 'provider', type: 'dropdown', value: 'openai' },
model: { id: 'model', type: 'dropdown', value: 'gpt-4o' },
prompt: {
id: 'prompt',
type: 'long-input',
value: 'Use the tools to help answer: {{input.question}}',
},
tools: {
id: 'tools',
type: 'tool-input',
value:
'[{"type":"custom-tool","name":"weather","description":"Get current weather","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}},{"type":"function","name":"calculator","description":"Calculate expression","parameters":{"type":"object","properties":{"expression":{"type":"string"}},"required":["expression"]}}]',
},
system: {
id: 'system',
type: 'long-input',
value: 'You are a helpful assistant with access to tools.',
},
responseFormat: { id: 'responseFormat', type: 'code', value: null },
},
outputs: {},
enabled: true,
},
}
return {
blocks,
edges: [{ id: 'edge1', source: 'starter', target: 'agent1' }],
loops: {},
}
}
/**
* Creates a workflow state with an invalid block type for error testing.
*/
export function createInvalidWorkflowState(): WorkflowStateFixture {
const { blocks, edges, loops } = createMinimalWorkflowState()
blocks.invalid = {
id: 'invalid',
type: 'invalid-type',
name: 'Invalid Block',
position: { x: 600, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
}
edges.push({ id: 'edge-invalid', source: 'agent1', target: 'invalid' })
return { blocks, edges, loops }
}
/**
* Creates a serialized workflow with invalid metadata for error testing.
*/
export function createInvalidSerializedWorkflow() {
return {
version: '1.0',
blocks: [
{
id: 'invalid',
position: { x: 0, y: 0 },
config: { tool: 'invalid', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'non-existent-type' },
enabled: true,
},
],
connections: [],
loops: {},
}
}
/**
* Creates a serialized workflow with missing metadata for error testing.
*/
export function createMissingMetadataWorkflow() {
return {
version: '1.0',
blocks: [
{
id: 'invalid',
position: { x: 0, y: 0 },
config: { tool: 'invalid', params: {} },
inputs: {},
outputs: {},
metadata: undefined,
enabled: true,
},
],
connections: [],
loops: {},
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
* @sim/testing - Shared testing utilities for Sim
*
* This package provides a comprehensive set of tools for writing tests:
* - Factories: Create mock data with sensible defaults
* - Builders: Fluent APIs for complex test scenarios
* - Mocks: Reusable mock implementations
* - Assertions: Semantic test assertions
*
* @example
* ```ts
* import {
* // Factories
* createBlock,
* createStarterBlock,
* createLinearWorkflow,
* createExecutionContext,
*
* // Builders
* WorkflowBuilder,
* ExecutionContextBuilder,
*
* // Assertions
* expectBlockExists,
* expectEdgeConnects,
* expectBlockExecuted,
* } from '@sim/testing'
*
* describe('MyFeature', () => {
* it('should work with a linear workflow', () => {
* const workflow = createLinearWorkflow(3)
* expectBlockExists(workflow.blocks, 'block-0', 'starter')
* expectEdgeConnects(workflow.edges, 'block-0', 'block-1')
* })
*
* it('should work with a complex workflow', () => {
* const workflow = WorkflowBuilder.branching().build()
* expectBlockCount(workflow, 5)
* })
* })
* ```
*/
export * from './assertions'
export * from './builders'
export * from './factories'
export * from './mocks'
export * from './types'
+201
View File
@@ -0,0 +1,201 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@sim/audit`.
* Exposes `mockRecordAudit` so tests can assert or override behavior per test.
*
* @example
* ```ts
* import { auditMockFns } from '@sim/testing'
*
* expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(...)
* auditMockFns.mockRecordAudit.mockRejectedValueOnce(new Error('audit failed'))
* ```
*/
export const auditMockFns = {
mockRecordAudit: vi.fn(),
}
/**
* Static mock module for `@sim/audit`.
*
* @example
* ```ts
* vi.mock('@sim/audit', () => auditMock)
* ```
*/
export const auditMock = {
recordAudit: auditMockFns.mockRecordAudit,
AuditAction: {
API_KEY_CREATED: 'api_key.created',
API_KEY_UPDATED: 'api_key.updated',
API_KEY_REVOKED: 'api_key.revoked',
PERSONAL_API_KEY_CREATED: 'personal_api_key.created',
PERSONAL_API_KEY_REVOKED: 'personal_api_key.revoked',
BYOK_KEY_CREATED: 'byok_key.created',
BYOK_KEY_UPDATED: 'byok_key.updated',
BYOK_KEY_DELETED: 'byok_key.deleted',
CHAT_DEPLOYED: 'chat.deployed',
CHAT_UPDATED: 'chat.updated',
CHAT_DELETED: 'chat.deleted',
CREDENTIAL_CREATED: 'credential.created',
CREDENTIAL_UPDATED: 'credential.updated',
CREDENTIAL_RENAMED: 'credential.renamed',
CREDENTIAL_RECONNECTED: 'credential.reconnected',
CREDENTIAL_DELETED: 'credential.deleted',
CREDENTIAL_MEMBER_ADDED: 'credential_member.added',
CREDENTIAL_MEMBER_REMOVED: 'credential_member.removed',
CREDENTIAL_MEMBER_ROLE_CHANGED: 'credential_member.role_changed',
CREDIT_PURCHASED: 'credit.purchased',
CUSTOM_BLOCK_PUBLISHED: 'custom_block.published',
CUSTOM_BLOCK_UPDATED: 'custom_block.updated',
CUSTOM_BLOCK_DELETED: 'custom_block.deleted',
CUSTOM_TOOL_CREATED: 'custom_tool.created',
CUSTOM_TOOL_UPDATED: 'custom_tool.updated',
CUSTOM_TOOL_DELETED: 'custom_tool.deleted',
DATA_DRAIN_CREATED: 'data_drain.created',
DATA_DRAIN_UPDATED: 'data_drain.updated',
DATA_DRAIN_DELETED: 'data_drain.deleted',
DATA_DRAIN_RAN: 'data_drain.ran',
DATA_DRAIN_TESTED: 'data_drain.tested',
CONNECTOR_DOCUMENT_RESTORED: 'connector_document.restored',
CONNECTOR_DOCUMENT_EXCLUDED: 'connector_document.excluded',
DOCUMENT_UPLOADED: 'document.uploaded',
DOCUMENT_UPDATED: 'document.updated',
DOCUMENT_DELETED: 'document.deleted',
ENVIRONMENT_UPDATED: 'environment.updated',
ENVIRONMENT_DELETED: 'environment.deleted',
FILE_UPLOADED: 'file.uploaded',
FILE_UPDATED: 'file.updated',
FILE_DELETED: 'file.deleted',
FILE_RESTORED: 'file.restored',
FILE_MOVED: 'file.moved',
FILE_SHARED: 'file.shared',
FILE_SHARE_DISABLED: 'file.share_disabled',
FOLDER_CREATED: 'folder.created',
FOLDER_UPDATED: 'folder.updated',
FOLDER_DELETED: 'folder.deleted',
FOLDER_MOVED: 'folder.moved',
FOLDER_DUPLICATED: 'folder.duplicated',
FOLDER_RESTORED: 'folder.restored',
INVITATION_ACCEPTED: 'invitation.accepted',
INVITATION_REJECTED: 'invitation.rejected',
INVITATION_RESENT: 'invitation.resent',
INVITATION_REVOKED: 'invitation.revoked',
INVITATION_UPDATED: 'invitation.updated',
CONNECTOR_CREATED: 'connector.created',
CONNECTOR_UPDATED: 'connector.updated',
CONNECTOR_DELETED: 'connector.deleted',
CONNECTOR_SYNCED: 'connector.synced',
KNOWLEDGE_BASE_CREATED: 'knowledge_base.created',
KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated',
KNOWLEDGE_BASE_DELETED: 'knowledge_base.deleted',
KNOWLEDGE_BASE_RESTORED: 'knowledge_base.restored',
MCP_SERVER_ADDED: 'mcp_server.added',
MCP_SERVER_UPDATED: 'mcp_server.updated',
MCP_SERVER_REMOVED: 'mcp_server.removed',
MEMBER_INVITED: 'member.invited',
MEMBER_ADDED: 'member.added',
MEMBER_REMOVED: 'member.removed',
MEMBER_ROLE_CHANGED: 'member.role_changed',
OAUTH_DISCONNECTED: 'oauth.disconnected',
PASSWORD_RESET: 'password.reset',
PASSWORD_RESET_REQUESTED: 'password.reset_requested',
ORGANIZATION_CREATED: 'organization.created',
ORGANIZATION_UPDATED: 'organization.updated',
ORG_MEMBER_ADDED: 'org_member.added',
ORG_MEMBER_REMOVED: 'org_member.removed',
ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed',
ORG_MEMBER_USAGE_LIMIT_CHANGED: 'org_member.usage_limit_changed',
ORG_INVITATION_CREATED: 'org_invitation.created',
ORG_INVITATION_UPDATED: 'org_invitation.updated',
ORG_INVITATION_ACCEPTED: 'org_invitation.accepted',
ORG_INVITATION_REJECTED: 'org_invitation.rejected',
ORG_INVITATION_CANCELLED: 'org_invitation.cancelled',
ORG_INVITATION_REVOKED: 'org_invitation.revoked',
ORG_INVITATION_RESENT: 'org_invitation.resent',
ORG_SEAT_PROVISIONED: 'org_seat.provisioned',
ORG_PLAN_CONVERTED: 'org_plan.converted',
PERMISSION_GROUP_CREATED: 'permission_group.created',
PERMISSION_GROUP_UPDATED: 'permission_group.updated',
PERMISSION_GROUP_DELETED: 'permission_group.deleted',
PERMISSION_GROUP_MEMBER_ADDED: 'permission_group_member.added',
PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed',
SCHEDULE_CREATED: 'schedule.created',
SCHEDULE_UPDATED: 'schedule.updated',
SCHEDULE_DELETED: 'schedule.deleted',
SKILL_CREATED: 'skill.created',
SKILL_UPDATED: 'skill.updated',
SKILL_DELETED: 'skill.deleted',
TABLE_CREATED: 'table.created',
TABLE_UPDATED: 'table.updated',
TABLE_DELETED: 'table.deleted',
TABLE_RESTORED: 'table.restored',
WEBHOOK_CREATED: 'webhook.created',
WEBHOOK_DELETED: 'webhook.deleted',
WORKFLOW_CREATED: 'workflow.created',
WORKFLOW_DELETED: 'workflow.deleted',
WORKFLOW_RESTORED: 'workflow.restored',
WORKFLOW_DEPLOYED: 'workflow.deployed',
WORKFLOW_UNDEPLOYED: 'workflow.undeployed',
WORKFLOW_DUPLICATED: 'workflow.duplicated',
WORKFLOW_LOCKED: 'workflow.locked',
WORKFLOW_UNLOCKED: 'workflow.unlocked',
WORKFLOW_DEPLOYMENT_ACTIVATED: 'workflow.deployment_activated',
WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted',
WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated',
WORKSPACE_CREATED: 'workspace.created',
WORKSPACE_UPDATED: 'workspace.updated',
WORKSPACE_DELETED: 'workspace.deleted',
WORKSPACE_DUPLICATED: 'workspace.duplicated',
WORKSPACE_FORKED: 'workspace.forked',
WORKSPACE_FORK_PROMOTED: 'workspace.fork_promoted',
WORKSPACE_FORK_ROLLED_BACK: 'workspace.fork_rolled_back',
WORKSPACE_FORK_UNLINKED: 'workspace.fork_unlinked',
WORKSPACE_EXPORTED: 'workspace.exported',
CREDIT_ISSUED: 'credit.issued',
INVOICE_PAYMENT_SUCCEEDED: 'invoice.payment_succeeded',
INVOICE_PAYMENT_FAILED: 'invoice.payment_failed',
OVERAGE_BILLED: 'billing.overage_billed',
CHARGE_DISPUTE_OPENED: 'charge.dispute_opened',
CHARGE_DISPUTE_CLOSED: 'charge.dispute_closed',
SUBSCRIPTION_CREATED: 'subscription.created',
SUBSCRIPTION_CANCELLED: 'subscription.cancelled',
SUBSCRIPTION_TRANSFERRED: 'subscription.transferred',
ENTERPRISE_SUBSCRIPTION_PROVISIONED: 'subscription.enterprise_provisioned',
CREDENTIAL_ACCESSED: 'credential.accessed',
FILE_DOWNLOADED: 'file.downloaded',
ORG_SEAT_DEPROVISIONED: 'org_seat.deprovisioned',
TABLE_EXPORTED: 'table.exported',
WORKFLOW_PUBLIC_API_TOGGLED: 'workflow.public_api_toggled',
WORKFLOW_EXPORTED: 'workflow.exported',
},
AuditResourceType: {
API_KEY: 'api_key',
BILLING: 'billing',
BYOK_KEY: 'byok_key',
CHAT: 'chat',
CONNECTOR: 'connector',
CREDENTIAL: 'credential',
CUSTOM_BLOCK: 'custom_block',
CUSTOM_TOOL: 'custom_tool',
DATA_DRAIN: 'data_drain',
DOCUMENT: 'document',
ENVIRONMENT: 'environment',
FILE: 'file',
FOLDER: 'folder',
KNOWLEDGE_BASE: 'knowledge_base',
MCP_SERVER: 'mcp_server',
OAUTH: 'oauth',
ORGANIZATION: 'organization',
PASSWORD: 'password',
PERMISSION_GROUP: 'permission_group',
SCHEDULE: 'schedule',
SKILL: 'skill',
SUBSCRIPTION: 'subscription',
TABLE: 'table',
WEBHOOK: 'webhook',
WORKFLOW: 'workflow',
WORKSPACE: 'workspace',
},
}
@@ -0,0 +1,57 @@
import { vi } from 'vitest'
/**
* Mock of the `ServiceAccountTokenError` class from
* `@/app/api/auth/oauth/utils`. Declared as a real class so consumer code
* using `instanceof ServiceAccountTokenError` keeps working under mock.
*/
export class ServiceAccountTokenErrorMock extends Error {
constructor(
public readonly statusCode: number,
public readonly errorDescription: string
) {
super(errorDescription)
this.name = 'ServiceAccountTokenError'
}
}
/**
* Controllable mock functions for `@/app/api/auth/oauth/utils`.
* All defaults are bare `vi.fn()` — configure per-test as needed.
*
* @example
* ```ts
* import { authOAuthUtilsMockFns } from '@sim/testing'
*
* authOAuthUtilsMockFns.mockRefreshAccessTokenIfNeeded.mockResolvedValue('access-token')
* authOAuthUtilsMockFns.mockGetOAuthToken.mockResolvedValue(null)
* ```
*/
export const authOAuthUtilsMockFns = {
mockResolveOAuthAccountId: vi.fn(),
mockGetServiceAccountToken: vi.fn(),
mockSafeAccountInsert: vi.fn(),
mockGetCredential: vi.fn(),
mockGetOAuthToken: vi.fn(),
mockRefreshAccessTokenIfNeeded: vi.fn(),
mockRefreshTokenIfNeeded: vi.fn(),
}
/**
* Static mock module for `@/app/api/auth/oauth/utils`.
*
* @example
* ```ts
* vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
* ```
*/
export const authOAuthUtilsMock = {
ServiceAccountTokenError: ServiceAccountTokenErrorMock,
resolveOAuthAccountId: authOAuthUtilsMockFns.mockResolveOAuthAccountId,
getServiceAccountToken: authOAuthUtilsMockFns.mockGetServiceAccountToken,
safeAccountInsert: authOAuthUtilsMockFns.mockSafeAccountInsert,
getCredential: authOAuthUtilsMockFns.mockGetCredential,
getOAuthToken: authOAuthUtilsMockFns.mockGetOAuthToken,
refreshAccessTokenIfNeeded: authOAuthUtilsMockFns.mockRefreshAccessTokenIfNeeded,
refreshTokenIfNeeded: authOAuthUtilsMockFns.mockRefreshTokenIfNeeded,
}
+35
View File
@@ -0,0 +1,35 @@
import { vi } from 'vitest'
/**
* Mock user interface for authentication testing.
*/
export interface MockUser {
id: string
email: string
name?: string
}
/**
* Controllable mock functions for `@/lib/auth`. Override per-test with
* `authMockFns.mockGetSession.mockResolvedValueOnce(...)`.
*/
export const authMockFns = {
mockGetSession: vi.fn(),
}
/**
* Static mock module for `@/lib/auth`.
*
* @example
* ```ts
* vi.mock('@/lib/auth', () => authMock)
* ```
*/
export const authMock = {
getSession: authMockFns.mockGetSession,
auth: {
api: {
getSession: authMockFns.mockGetSession,
},
},
}
+344
View File
@@ -0,0 +1,344 @@
/**
* Mock block configurations for serializer and related tests.
*
* @example
* ```ts
* import { blocksMock, mockBlockConfigs } from '@sim/testing/mocks'
*
* vi.mock('@/blocks', () => blocksMock)
*
* // Or use individual configs
* const starterConfig = mockBlockConfigs.starter
* ```
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Mock block configurations that mirror the real block registry.
* Used for testing serialization, deserialization, and validation.
*/
export const mockBlockConfigs: Record<string, any> = {
starter: {
name: 'Starter',
description: 'Start of the workflow',
category: 'flow',
bgColor: '#4CAF50',
tools: {
access: ['starter'],
config: { tool: () => 'starter' },
},
subBlocks: [
{ id: 'description', type: 'long-input', label: 'Description' },
{ id: 'inputFormat', type: 'table', label: 'Input Format' },
],
inputs: {},
},
agent: {
name: 'Agent',
description: 'AI Agent',
category: 'ai',
bgColor: '#2196F3',
tools: {
access: ['anthropic_chat', 'openai_chat', 'google_chat'],
config: {
tool: (params: Record<string, any>) => {
const model = params.model || 'gpt-4o'
if (model.includes('claude')) return 'anthropic'
if (model.includes('gpt') || model.includes('o1')) return 'openai'
if (model.includes('gemini')) return 'google'
return 'openai'
},
},
},
subBlocks: [
{ id: 'provider', type: 'dropdown', label: 'Provider' },
{ id: 'model', type: 'dropdown', label: 'Model' },
{ id: 'prompt', type: 'long-input', label: 'Prompt' },
{ id: 'system', type: 'long-input', label: 'System Message' },
{ id: 'tools', type: 'tool-input', label: 'Tools' },
{ id: 'responseFormat', type: 'code', label: 'Response Format' },
{ id: 'messages', type: 'messages-input', label: 'Messages' },
],
inputs: {
input: { type: 'string' },
tools: { type: 'array' },
},
},
function: {
name: 'Function',
description: 'Execute custom code',
category: 'code',
bgColor: '#9C27B0',
tools: {
access: ['function'],
config: { tool: () => 'function' },
},
subBlocks: [
{ id: 'code', type: 'code', label: 'Code' },
{ id: 'language', type: 'dropdown', label: 'Language' },
],
inputs: { input: { type: 'any' } },
},
condition: {
name: 'Condition',
description: 'Branch based on condition',
category: 'flow',
bgColor: '#FF9800',
tools: {
access: ['condition'],
config: { tool: () => 'condition' },
},
subBlocks: [{ id: 'condition', type: 'long-input', label: 'Condition' }],
inputs: { input: { type: 'any' } },
},
api: {
name: 'API',
description: 'Make API request',
category: 'data',
bgColor: '#E91E63',
tools: {
access: ['api'],
config: { tool: () => 'api' },
},
subBlocks: [
{ id: 'url', type: 'short-input', label: 'URL' },
{ id: 'method', type: 'dropdown', label: 'Method' },
{ id: 'headers', type: 'table', label: 'Headers' },
{ id: 'body', type: 'long-input', label: 'Body' },
],
inputs: {},
},
webhook: {
name: 'Webhook',
description: 'Webhook trigger',
category: 'triggers',
bgColor: '#4CAF50',
tools: {
access: ['webhook'],
config: { tool: () => 'webhook' },
},
subBlocks: [{ id: 'path', type: 'short-input', label: 'Path' }],
inputs: {},
},
jina: {
name: 'Jina',
description: 'Convert website content into text',
category: 'tools',
bgColor: '#333333',
tools: {
access: ['jina_read_url'],
config: { tool: () => 'jina_read_url' },
},
subBlocks: [
{ id: 'url', type: 'short-input', title: 'URL', required: true },
{ id: 'apiKey', type: 'short-input', title: 'API Key', required: true },
],
inputs: {
url: { type: 'string' },
apiKey: { type: 'string' },
},
},
reddit: {
name: 'Reddit',
description: 'Access Reddit data and content',
category: 'tools',
bgColor: '#FF5700',
tools: {
access: ['reddit_get_posts', 'reddit_get_comments'],
config: { tool: () => 'reddit_get_posts' },
},
subBlocks: [
{ id: 'operation', type: 'dropdown', title: 'Operation', required: true },
{ id: 'credential', type: 'oauth-input', title: 'Reddit Account', required: true },
{ id: 'subreddit', type: 'short-input', title: 'Subreddit', required: true },
],
inputs: {
operation: { type: 'string' },
credential: { type: 'string' },
subreddit: { type: 'string' },
},
},
slack: {
name: 'Slack',
description: 'Send messages to Slack',
category: 'tools',
bgColor: '#611f69',
tools: {
access: ['slack_send_message'],
config: { tool: () => 'slack_send_message' },
},
subBlocks: [
{
id: 'channel',
type: 'dropdown',
title: 'Channel',
mode: 'basic',
canonicalParamId: 'channel',
},
{
id: 'manualChannel',
type: 'short-input',
title: 'Channel ID',
mode: 'advanced',
canonicalParamId: 'channel',
},
{ id: 'text', type: 'long-input', title: 'Message' },
{ id: 'username', type: 'short-input', title: 'Username', mode: 'both' },
],
inputs: {
channel: { type: 'string' },
manualChannel: { type: 'string' },
text: { type: 'string' },
username: { type: 'string' },
},
},
agentWithMemories: {
name: 'Agent with Memories',
description: 'AI Agent with memory support',
category: 'ai',
bgColor: '#2196F3',
tools: {
access: ['anthropic_chat'],
config: { tool: () => 'anthropic_chat' },
},
subBlocks: [
{ id: 'systemPrompt', type: 'long-input', title: 'System Prompt' },
{ id: 'userPrompt', type: 'long-input', title: 'User Prompt' },
{ id: 'memories', type: 'short-input', title: 'Memories', mode: 'advanced' },
{ id: 'model', type: 'dropdown', title: 'Model' },
],
inputs: {
systemPrompt: { type: 'string' },
userPrompt: { type: 'string' },
memories: { type: 'array' },
model: { type: 'string' },
},
},
conditional_block: {
name: 'Conditional Block',
description: 'Block with conditional fields',
category: 'tools',
bgColor: '#FF5700',
tools: {
access: ['conditional_tool'],
config: { tool: () => 'conditional_tool' },
},
subBlocks: [
{ id: 'mode', type: 'dropdown', label: 'Mode' },
{
id: 'optionA',
type: 'short-input',
label: 'Option A',
condition: { field: 'mode', value: 'a' },
},
{
id: 'optionB',
type: 'short-input',
label: 'Option B',
condition: { field: 'mode', value: 'b' },
},
{
id: 'notModeC',
type: 'short-input',
label: 'Not Mode C',
condition: { field: 'mode', value: 'c', not: true },
},
{
id: 'complexCondition',
type: 'short-input',
label: 'Complex',
condition: { field: 'mode', value: 'a', and: { field: 'optionA', value: 'special' } },
},
{
id: 'arrayCondition',
type: 'short-input',
label: 'Array Condition',
condition: { field: 'mode', value: ['a', 'b'] },
},
],
inputs: {},
},
wait: {
name: 'Wait',
description: 'Pause workflow execution for a specified time delay',
category: 'blocks',
bgColor: '#F59E0B',
tools: {
access: [],
},
subBlocks: [
{
id: 'timeValue',
title: 'Wait Amount',
type: 'short-input',
placeholder: '10',
required: true,
},
{
id: 'timeUnit',
title: 'Unit',
type: 'dropdown',
required: true,
},
],
inputs: {
timeValue: { type: 'string' },
timeUnit: { type: 'string' },
},
outputs: {
waitDuration: { type: 'number' },
status: { type: 'string' },
},
},
}
/**
* Creates a getBlock function that returns mock block configs.
* Can be extended with additional block types.
*/
export function createMockGetBlock(extraConfigs: Record<string, any> = {}) {
const configs = { ...mockBlockConfigs, ...extraConfigs }
return (type: string) => configs[type] || null
}
/**
* Mock tool configurations for validation tests.
*/
export const mockToolConfigs: Record<string, any> = {
jina_read_url: {
params: {
url: { visibility: 'user-or-llm', required: true },
apiKey: { visibility: 'user-only', required: true },
},
},
reddit_get_posts: {
params: {
subreddit: { visibility: 'user-or-llm', required: true },
credential: { visibility: 'user-only', required: true },
},
},
}
/**
* Creates a getTool function that returns mock tool configs.
*/
export function createMockGetTool(extraConfigs: Record<string, any> = {}) {
const configs = { ...mockToolConfigs, ...extraConfigs }
return (toolId: string) => configs[toolId] || null
}
/**
* Pre-configured blocks mock for use with vi.mock('@/blocks', () => blocksMock).
*/
export const blocksMock = {
getBlock: createMockGetBlock(),
getAllBlocks: () => Object.values(mockBlockConfigs),
}
/**
* Pre-configured tools/utils mock for use with vi.mock('@/tools/utils', () => toolsUtilsMock).
*/
export const toolsUtilsMock = {
getTool: createMockGetTool(),
}
@@ -0,0 +1,85 @@
import { vi } from 'vitest'
/**
* Frozen mirror of the `NotificationStatus` const from
* `@/lib/copilot/request/http`. Matches the real values so route code using
* e.g. `NotificationStatus.success` keeps resolving under mock.
*/
const NotificationStatusMock = {
pending: 'pending',
background: 'background',
success: 'success',
error: 'error',
cancelled: 'cancelled',
} as const
/**
* Controllable mock functions for `@/lib/copilot/request/http`.
* Response helpers default to returning minimal Response-like objects so
* handler tests can assert `res.status` and `await res.json()`.
* `createRequestTracker` returns a stable tracker with `requestId`,
* `startTime`, and a `getDuration()` that always returns `0`.
*
* @example
* ```ts
* import { copilotHttpMockFns } from '@sim/testing'
*
* copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
* userId: 'user-1',
* isAuthenticated: true,
* })
* ```
*/
export const copilotHttpMockFns = {
mockCreateUnauthorizedResponse: vi.fn(() => ({
status: 401,
ok: false,
json: async () => ({ error: 'Unauthorized' }),
})),
mockCreateBadRequestResponse: vi.fn((message: string) => ({
status: 400,
ok: false,
json: async () => ({ error: message }),
})),
mockCreateNotFoundResponse: vi.fn((message: string) => ({
status: 404,
ok: false,
json: async () => ({ error: message }),
})),
mockCreateInternalServerErrorResponse: vi.fn((message: string) => ({
status: 500,
ok: false,
json: async () => ({ error: message }),
})),
mockCreateRequestId: vi.fn(() => 'test-request-id'),
mockCreateShortRequestId: vi.fn(() => 'test-req'),
mockCreateRequestTracker: vi.fn(() => ({
requestId: 'test-req',
startTime: 0,
getDuration: () => 0,
})),
mockAuthenticateCopilotRequestSessionOnly: vi.fn(),
mockCheckInternalApiKey: vi.fn(),
}
/**
* Static mock module for `@/lib/copilot/request/http`.
*
* @example
* ```ts
* vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
* ```
*/
export const copilotHttpMock = {
NotificationStatus: NotificationStatusMock,
createUnauthorizedResponse: copilotHttpMockFns.mockCreateUnauthorizedResponse,
createBadRequestResponse: copilotHttpMockFns.mockCreateBadRequestResponse,
createNotFoundResponse: copilotHttpMockFns.mockCreateNotFoundResponse,
createInternalServerErrorResponse: copilotHttpMockFns.mockCreateInternalServerErrorResponse,
createRequestId: copilotHttpMockFns.mockCreateRequestId,
createShortRequestId: copilotHttpMockFns.mockCreateShortRequestId,
createRequestTracker: copilotHttpMockFns.mockCreateRequestTracker,
authenticateCopilotRequestSessionOnly:
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly,
checkInternalApiKey: copilotHttpMockFns.mockCheckInternalApiKey,
}
+353
View File
@@ -0,0 +1,353 @@
import { vi } from 'vitest'
/**
* Creates mock SQL template literal function.
* Mimics drizzle-orm's sql tagged template.
*/
export function createMockSql() {
const sqlFn = (strings: TemplateStringsArray, ...values: any[]) => ({
strings,
values,
toSQL: () => ({ sql: strings.join('?'), params: values }),
})
// Add sql.raw method used by some queries
sqlFn.raw = (rawSql: string) => ({
rawSql,
toSQL: () => ({ sql: rawSql, params: [] }),
})
// Add sql.join method used to combine multiple SQL fragments
sqlFn.join = (fragments: any[], separator: any) => ({
fragments,
separator,
toSQL: () => ({
sql: fragments.map((f) => f?.toSQL?.()?.sql || String(f)).join(separator?.rawSql || ', '),
params: fragments.flatMap((f) => f?.toSQL?.()?.params || []),
}),
})
return sqlFn
}
/**
* Creates mock SQL operators (eq, and, or, etc.).
*/
export function createMockSqlOperators() {
return {
eq: vi.fn((a, b) => ({ type: 'eq', left: a, right: b })),
ne: vi.fn((a, b) => ({ type: 'ne', left: a, right: b })),
gt: vi.fn((a, b) => ({ type: 'gt', left: a, right: b })),
gte: vi.fn((a, b) => ({ type: 'gte', left: a, right: b })),
lt: vi.fn((a, b) => ({ type: 'lt', left: a, right: b })),
lte: vi.fn((a, b) => ({ type: 'lte', left: a, right: b })),
count: vi.fn((column) => ({ type: 'count', column })),
avg: vi.fn((column) => ({ type: 'avg', column })),
sum: vi.fn((column) => ({ type: 'sum', column })),
min: vi.fn((column) => ({ type: 'min', column })),
max: vi.fn((column) => ({ type: 'max', column })),
and: vi.fn((...conditions) => ({ type: 'and', conditions })),
or: vi.fn((...conditions) => ({ type: 'or', conditions })),
not: vi.fn((condition) => ({ type: 'not', condition })),
isNull: vi.fn((column) => ({ type: 'isNull', column })),
isNotNull: vi.fn((column) => ({ type: 'isNotNull', column })),
inArray: vi.fn((column, values) => ({ type: 'inArray', column, values })),
notInArray: vi.fn((column, values) => ({ type: 'notInArray', column, values })),
exists: vi.fn((subquery) => ({ type: 'exists', subquery })),
notExists: vi.fn((subquery) => ({ type: 'notExists', subquery })),
like: vi.fn((column, pattern) => ({ type: 'like', column, pattern })),
ilike: vi.fn((column, pattern) => ({ type: 'ilike', column, pattern })),
desc: vi.fn((column) => ({ type: 'desc', column })),
asc: vi.fn((column) => ({ type: 'asc', column })),
}
}
/**
* Pre-wired chain of vi.fn()s for drizzle-style DB queries.
*
* Each builder step is a stable, module-level `vi.fn()` — safe to reference
* inside hoisted `vi.mock()` factories (same pattern as `authMockFns`). Chains
* are wired at module load time:
*
* - `select().from().where()` → returns a builder with `.limit` / `.orderBy` /
* `.returning` / `.groupBy` / `.for` terminals
* - `select().from().innerJoin()|leftJoin()` → returns the same where-builder
* - `insert().values().returning()` / `update().set().where()` / `delete().where()`
*
* Terminals (`limit`, `orderBy`, `returning`, `groupBy`, `for`, `values`)
* default to resolving `[]` (or `undefined` for `values`). Override per-test
* with `dbChainMockFns.limit.mockResolvedValueOnce([...])`. `for` mirrors
* drizzle's `.for('update')` — it returns a Promise with `.limit` / `.orderBy`
* / `.returning` / `.groupBy` attached, so both `await .where().for('update')`
* (terminal) and `await .where().for('update').limit(1)` (chained) work.
* Override the terminal result with `dbChainMockFns.for.mockResolvedValueOnce(
* [...])`; override the chained result by mocking the downstream terminal
* (e.g. `dbChainMockFns.limit.mockResolvedValueOnce([...])`).
*
* `vi.clearAllMocks()` clears call history but preserves default wiring. Tests
* that replace a wiring with `mockReturnValue(...)` (not `...Once`) must re-wire
* in their own `beforeEach`.
*
* @example
* ```ts
* import { dbChainMock, dbChainMockFns } from '@sim/testing'
* vi.mock('@sim/db', () => dbChainMock)
*
* it('finds rows', async () => {
* dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'w-1' }])
* // ... exercise code that hits db.select().from().where().limit() ...
* expect(dbChainMockFns.where).toHaveBeenCalled()
* })
* ```
*/
const offset = vi.fn(() => Promise.resolve([] as unknown[]))
// `.limit()` returns a builder that is awaitable (default empty page) and also
// exposes `.offset()` for keyset/OFFSET paging (`.limit(n).offset(m)`).
const limitBuilder = () => {
const thenable: any = Promise.resolve([] as unknown[])
thenable.offset = offset
return thenable
}
const limit = vi.fn(limitBuilder)
const returning = vi.fn(() => Promise.resolve([] as unknown[]))
const execute = vi.fn(() => Promise.resolve([] as unknown[]))
const terminalBuilder = () => {
const thenable: any = Promise.resolve([] as unknown[])
thenable.limit = limit
thenable.orderBy = orderBy
thenable.returning = returning
thenable.groupBy = groupBy
thenable.for = forClause
return thenable
}
const orderBy = vi.fn(terminalBuilder)
const having = vi.fn(terminalBuilder)
const groupBy = vi.fn(() => {
const builder = terminalBuilder()
builder.having = having
return builder
})
const forBuilder = terminalBuilder
const forClause = vi.fn(forBuilder)
const onConflictDoUpdate = vi.fn(() => ({ returning }) as unknown as Promise<void>)
const onConflictDoNothing = vi.fn(() => ({ returning }) as unknown as Promise<void>)
const whereBuilder = () => {
// Some call sites (e.g. `db.select().from(t).where(eq(...))` with no
// limit/orderBy) await the where directly. Make the builder a thenable so
// those calls resolve to the default empty array.
const thenable: any = Promise.resolve([] as unknown[])
thenable.limit = limit
thenable.orderBy = orderBy
thenable.returning = returning
thenable.groupBy = groupBy
thenable.for = forClause
return thenable
}
const where = vi.fn(whereBuilder)
const joinBuilder = (): { where: typeof where; innerJoin: any; leftJoin: any } => ({
where,
innerJoin,
leftJoin,
})
const innerJoin: ReturnType<typeof vi.fn> = vi.fn(joinBuilder)
const leftJoin: ReturnType<typeof vi.fn> = vi.fn(joinBuilder)
const from = vi.fn(joinBuilder)
const select = vi.fn(() => ({ from }))
const selectDistinct = vi.fn(() => ({ from }))
const selectDistinctOn = vi.fn(() => ({ from }))
const values = vi.fn(() => ({ returning, onConflictDoUpdate, onConflictDoNothing }))
const insert = vi.fn(() => ({ values }))
const set = vi.fn(() => ({ where }))
const update = vi.fn(() => ({ set }))
const del = vi.fn(() => ({ where }))
const transaction: ReturnType<typeof vi.fn> = vi.fn(
async (cb: (tx: any) => unknown): Promise<unknown> => cb(dbChainMock.db)
)
export const dbChainMockFns = {
select,
selectDistinct,
selectDistinctOn,
from,
where,
limit,
offset,
orderBy,
returning,
innerJoin,
leftJoin,
groupBy,
having,
execute,
for: forClause,
insert,
values,
onConflictDoUpdate,
onConflictDoNothing,
update,
set,
delete: del,
transaction,
}
/**
* Re-applies the default chain wiring to every `dbChainMockFns` entry. Call
* this in `beforeEach` (after `vi.clearAllMocks()`) if any test uses
* `mockReturnValue` / `mockResolvedValue` (permanent overrides) — this
* guarantees the next test starts with fresh defaults.
*
* Not needed if tests exclusively use the `...Once` variants, since those
* auto-expire after one call.
*/
export function resetDbChainMock(): void {
select.mockImplementation(() => ({ from }))
selectDistinct.mockImplementation(() => ({ from }))
selectDistinctOn.mockImplementation(() => ({ from }))
from.mockImplementation(joinBuilder)
innerJoin.mockImplementation(joinBuilder)
leftJoin.mockImplementation(joinBuilder)
where.mockImplementation(whereBuilder)
insert.mockImplementation(() => ({ values }))
values.mockImplementation(() => ({ returning, onConflictDoUpdate, onConflictDoNothing }))
onConflictDoUpdate.mockImplementation(() => ({ returning }) as unknown as Promise<void>)
onConflictDoNothing.mockImplementation(() => ({ returning }) as unknown as Promise<void>)
update.mockImplementation(() => ({ set }))
set.mockImplementation(() => ({ where }))
del.mockImplementation(() => ({ where }))
limit.mockImplementation(limitBuilder)
offset.mockImplementation(() => Promise.resolve([] as unknown[]))
orderBy.mockImplementation(terminalBuilder)
returning.mockImplementation(() => Promise.resolve([] as unknown[]))
having.mockImplementation(terminalBuilder)
groupBy.mockImplementation(() => {
const builder = terminalBuilder()
builder.having = having
return builder
})
execute.mockImplementation(() => Promise.resolve([] as unknown[]))
forClause.mockImplementation(forBuilder)
transaction.mockImplementation(async (cb: (tx: typeof dbChainMock.db) => unknown) =>
cb(dbChainMock.db)
)
}
/**
* Static mock module for `@sim/db` backed by `dbChainMockFns`.
*
* @example
* ```ts
* vi.mock('@sim/db', () => dbChainMock)
* ```
*/
const dbChainInstance = {
select,
selectDistinct,
selectDistinctOn,
insert,
update,
delete: del,
execute,
transaction,
}
export const dbChainMock = {
db: dbChainInstance,
/** Same instance as `db` so per-test chain overrides cover both clients. */
dbReplica: dbChainInstance,
runOutsideTransactionContext: <T>(fn: () => T): T => fn(),
instrumentPoolClient: <T>(client: T): T => client,
}
/**
* Creates a mock database connection.
*/
export function createMockDb() {
// A `where(...)` result that is both awaitable (resolves to `[]`) and exposes
// `.limit`/`.orderBy`, so `select().from()[.leftJoin()].where()[.limit()]`
// works whether or not a terminal is chained.
const whereResult = () => {
const thenable: any = Promise.resolve([])
thenable.limit = vi.fn(() => Promise.resolve([]))
thenable.orderBy = vi.fn(() => Promise.resolve([]))
return thenable
}
const fromBuilder = () => ({
where: vi.fn(whereResult),
leftJoin: vi.fn(() => ({ where: vi.fn(whereResult) })),
innerJoin: vi.fn(() => ({ where: vi.fn(whereResult) })),
})
return {
select: vi.fn(() => ({
from: vi.fn(fromBuilder),
})),
selectDistinct: vi.fn(() => ({
from: vi.fn(fromBuilder),
})),
insert: vi.fn(() => ({
values: vi.fn(() => ({
returning: vi.fn(() => Promise.resolve([])),
onConflictDoUpdate: vi.fn(() => ({
returning: vi.fn(() => Promise.resolve([])),
})),
onConflictDoNothing: vi.fn(() => ({
returning: vi.fn(() => Promise.resolve([])),
})),
})),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => ({
returning: vi.fn(() => Promise.resolve([])),
})),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(() => ({
returning: vi.fn(() => Promise.resolve([])),
})),
})),
transaction: vi.fn(async (callback) => callback(createMockDb())),
query: vi.fn(() => Promise.resolve([])),
}
}
/**
* Mock module for @sim/db.
* Use with vi.mock() to replace the real database.
*
* @example
* ```ts
* vi.mock('@sim/db', () => databaseMock)
* ```
*/
const mockDbInstance = createMockDb()
export const databaseMock = {
db: mockDbInstance,
/** Same instance as `db` so per-test overrides cover both clients. */
dbReplica: mockDbInstance,
sql: createMockSql(),
runOutsideTransactionContext: <T>(fn: () => T): T => fn(),
instrumentPoolClient: <T>(client: T): T => client,
...createMockSqlOperators(),
}
/**
* Creates a mock for drizzle-orm module.
*
* @example
* ```ts
* vi.mock('drizzle-orm', () => drizzleOrmMock)
* ```
*/
export const drizzleOrmMock = {
sql: createMockSql(),
...createMockSqlOperators(),
}
@@ -0,0 +1,31 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@/lib/core/security/encryption`.
* Default: `decryptSecret` resolves to `{ decrypted: 'test-decrypted' }`,
* `encryptSecret` resolves to `{ encrypted: 'test-encrypted', iv: 'test-iv' }`.
*
* @example
* ```ts
* import { encryptionMockFns } from '@sim/testing'
*
* encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ decrypted: 'my-secret' })
* ```
*/
export const encryptionMockFns = {
mockDecryptSecret: vi.fn().mockResolvedValue({ decrypted: 'test-decrypted' }),
mockEncryptSecret: vi.fn().mockResolvedValue({ encrypted: 'test-encrypted', iv: 'test-iv' }),
}
/**
* Static mock module for `@/lib/core/security/encryption`.
*
* @example
* ```ts
* vi.mock('@/lib/core/security/encryption', () => encryptionMock)
* ```
*/
export const encryptionMock = {
decryptSecret: encryptionMockFns.mockDecryptSecret,
encryptSecret: encryptionMockFns.mockEncryptSecret,
}
@@ -0,0 +1,45 @@
import { vi } from 'vitest'
/**
* Static mock module for `@/lib/core/config/env-flags`.
* All boolean flags default to `false` for safe test isolation.
*
* @example
* ```ts
* vi.mock('@/lib/core/config/env-flags', () => envFlagsMock)
* ```
*/
export const envFlagsMock = {
isProd: false,
isDev: false,
isTest: true,
isHosted: false,
isBillingEnabled: false,
isEmailVerificationEnabled: false,
isAuthDisabled: false,
isPrivateDatabaseHostsAllowed: false,
isRegistrationDisabled: false,
isEmailPasswordEnabled: false,
isTriggerDevEnabled: false,
isSsoEnabled: false,
isAccessControlEnabled: false,
isOrganizationsEnabled: false,
isInboxEnabled: false,
isWhitelabelingEnabled: false,
isAuditLogsEnabled: false,
isDataRetentionEnabled: false,
isE2bEnabled: false,
isE2BDocEnabled: false,
isOllamaConfigured: false,
isAzureConfigured: false,
isInvitationsDisabled: false,
isPublicApiDisabled: false,
isGoogleAuthDisabled: false,
isGithubAuthDisabled: false,
isReactGrabEnabled: false,
isReactScanEnabled: false,
getAllowedIntegrationsFromEnv: vi.fn().mockReturnValue(null),
getBlacklistedProvidersFromEnv: vi.fn().mockReturnValue([]),
getAllowedMcpDomainsFromEnv: vi.fn().mockReturnValue(null),
getCostMultiplier: vi.fn().mockReturnValue(1),
}
+86
View File
@@ -0,0 +1,86 @@
import { vi } from 'vitest'
/**
* Default mock environment values for testing
*/
export const defaultMockEnv = {
// Core
DATABASE_URL: 'postgresql://test:test@localhost:5432/test',
BETTER_AUTH_URL: 'https://test.sim.ai',
BETTER_AUTH_SECRET: 'test-secret-that-is-at-least-32-chars-long',
ENCRYPTION_KEY: 'test-encryption-key-32-chars-long!',
INTERNAL_API_SECRET: 'test-internal-api-secret-32-chars!',
// Email
RESEND_API_KEY: 'test-resend-key',
FROM_EMAIL_ADDRESS: 'Sim <noreply@test.sim.ai>',
EMAIL_DOMAIN: 'test.sim.ai',
PERSONAL_EMAIL_FROM: 'Test <test@test.sim.ai>',
// URLs
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
}
/**
* Creates a mock getEnv function that returns values from the provided env object
*/
export function createMockGetEnv(envValues: Record<string, string | undefined> = defaultMockEnv) {
return vi.fn((key: string) => envValues[key])
}
/**
* Creates a complete env mock object for use with vi.doMock
*
* @example
* ```ts
* vi.doMock('@/lib/core/config/env', () => createEnvMock())
*
* // With custom values
* vi.doMock('@/lib/core/config/env', () => createEnvMock({
* NEXT_PUBLIC_APP_URL: 'https://custom.example.com',
* }))
* ```
*/
export function createEnvMock(overrides: Record<string, string | undefined> = {}) {
const envValues = { ...defaultMockEnv, ...overrides }
return {
env: envValues,
getEnv: createMockGetEnv(envValues),
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value),
isFalsy: (value: string | boolean | number | undefined) =>
typeof value === 'string'
? value.toLowerCase() === 'false' || value === '0'
: value === false,
envBoolean: (value: boolean | string | undefined | null): boolean | undefined => {
if (typeof value === 'boolean') return value
if (value === undefined || value === null || value === '') return undefined
const normalized = String(value).trim().toLowerCase()
return (
normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'
)
},
envNumber: (
value: number | string | undefined | null,
fallback: number,
options: { min?: number } = {}
): number => {
const min = options.min ?? 0
if (typeof value === 'number' && Number.isFinite(value) && value >= min) return value
if (value === undefined || value === null || value === '') return fallback
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= min ? parsed : fallback
},
}
}
/**
* Pre-configured env mock for direct use with vi.mock
*
* @example
* ```ts
* vi.mock('@/lib/core/config/env', () => envMock)
* ```
*/
export const envMock = createEnvMock()
@@ -0,0 +1,31 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@/lib/execution/preprocessing`.
* Default is a bare `vi.fn()` — configure per-test.
*
* @example
* ```ts
* import { executionPreprocessingMockFns } from '@sim/testing'
*
* executionPreprocessingMockFns.mockPreprocessExecution.mockResolvedValue({
* success: true,
* actorUserId: 'user-1',
* })
* ```
*/
export const executionPreprocessingMockFns = {
mockPreprocessExecution: vi.fn(),
}
/**
* Static mock module for `@/lib/execution/preprocessing`.
*
* @example
* ```ts
* vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock)
* ```
*/
export const executionPreprocessingMock = {
preprocessExecution: executionPreprocessingMockFns.mockPreprocessExecution,
}
@@ -0,0 +1,98 @@
/**
* Mock utilities for executor handler testing.
* Sets up common mocks needed for testing executor block handlers.
*
* This module is designed to be imported for side effects - the vi.mock calls
* are executed at the top level and hoisted by vitest.
*
* @example
* ```ts
* // Import at the very top of your test file for side effects
* import '@sim/testing/mocks/executor.mock'
*
* // Then your other imports
* import { describe, it, expect } from 'vitest'
* ```
*/
import { vi } from 'vitest'
import { setupGlobalFetchMock } from './fetch.mock'
import { loggerMock } from './logger.mock'
// Logger
vi.mock('@sim/logger', () => loggerMock)
// Blocks
vi.mock('@/blocks/index', () => ({
getBlock: vi.fn(),
}))
// Tools
vi.mock('@/tools/utils', () => ({
getTool: vi.fn(),
getToolAsync: vi.fn(),
validateToolRequest: vi.fn(),
formatRequestParams: vi.fn(),
transformTable: vi.fn(),
createParamSchema: vi.fn(),
getClientEnvVars: vi.fn(),
createCustomToolRequestBody: vi.fn(),
validateRequiredParametersAfterMerge: vi.fn(),
}))
// Utils
vi.mock('@/lib/core/config/environment', () => ({
isHosted: false,
}))
vi.mock('@/lib/core/config/api-keys', () => ({
getRotatingApiKey: vi.fn(),
}))
// Tools module
vi.mock('@/tools')
// Providers
vi.mock('@/providers', () => ({
executeProviderRequest: vi.fn(),
}))
vi.mock('@/providers/utils', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
getProviderFromModel: vi.fn(),
transformBlockTool: vi.fn(),
getBaseModelProviders: vi.fn(() => ({})),
}
})
// Executor utilities
vi.mock('@/executor/path')
vi.mock('@/executor/resolver', () => ({
InputResolver: vi.fn(),
}))
vi.mock('@/executor/utils/http', () => ({
buildAuthHeaders: vi.fn().mockResolvedValue({ 'Content-Type': 'application/json' }),
buildAPIUrl: vi.fn((path: string) => new URL(path, 'http://localhost:3000')),
extractAPIErrorMessage: vi.fn(async (response: Response) => {
const defaultMessage = `API request failed with status ${response.status}`
try {
const errorData = await response.json()
return errorData.error || defaultMessage
} catch {
return defaultMessage
}
}),
}))
// Specific block utilities
vi.mock('@/blocks/blocks/router')
// Mock blocks module
vi.mock('@/blocks')
// Mock fetch for server requests
setupGlobalFetchMock()
// Mock process.env
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
+135
View File
@@ -0,0 +1,135 @@
import { vi } from 'vitest'
/**
* Type for mock fetch response configuration.
*/
export interface MockFetchResponse {
status?: number
statusText?: string
ok?: boolean
headers?: Record<string, string>
json?: any
text?: string
body?: any
}
/**
* Creates a mock fetch function that returns configured responses.
*
* @example
* ```ts
* const mockFetch = createMockFetch({
* json: { data: 'test' },
* status: 200
* })
* global.fetch = mockFetch
* ```
*/
export function createMockFetch(defaultResponse: MockFetchResponse = {}) {
const mockFn = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => {
return createMockResponse(defaultResponse)
})
return mockFn
}
/**
* Creates a mock Response object.
*/
export function createMockResponse(config: MockFetchResponse = {}): Response {
const status = config.status ?? 200
const ok = config.ok ?? (status >= 200 && status < 300)
return {
status,
statusText: config.statusText ?? (ok ? 'OK' : 'Error'),
ok,
headers: new Headers(config.headers ?? {}),
json: vi.fn(async () => config.json ?? {}),
text: vi.fn(async () => config.text ?? JSON.stringify(config.json ?? {})),
body: config.body ?? null,
bodyUsed: false,
arrayBuffer: vi.fn(async () => new ArrayBuffer(0)),
blob: vi.fn(async () => new Blob()),
formData: vi.fn(async () => new FormData()),
clone: vi.fn(function (this: Response) {
return createMockResponse(config)
}),
redirected: false,
type: 'basic' as ResponseType,
url: '',
bytes: vi.fn(async () => new Uint8Array()),
} as Response
}
/**
* Creates a mock fetch that handles multiple URLs with different responses.
*
* @example
* ```ts
* const mockFetch = createMultiMockFetch({
* '/api/users': { json: [{ id: 1 }] },
* '/api/error': { status: 500, json: { error: 'Server Error' } },
* })
* global.fetch = mockFetch
* ```
*/
export function createMultiMockFetch(
routes: Record<string, MockFetchResponse>,
defaultResponse?: MockFetchResponse
) {
return vi.fn(async (url: string | URL | Request, _init?: RequestInit) => {
const urlString = url instanceof Request ? url.url : url.toString()
// Find matching route (exact or partial match)
const matchedRoute = Object.keys(routes).find(
(route) => urlString === route || urlString.includes(route)
)
if (matchedRoute) {
return createMockResponse(routes[matchedRoute])
}
if (defaultResponse) {
return createMockResponse(defaultResponse)
}
return createMockResponse({ status: 404, json: { error: 'Not Found' } })
})
}
/**
* Sets up global fetch mock.
*
* @example
* ```ts
* const mockFetch = setupGlobalFetchMock({ json: { success: true } })
* // Later...
* expect(mockFetch).toHaveBeenCalledWith('/api/test', expect.anything())
* ```
*/
export function setupGlobalFetchMock(defaultResponse?: MockFetchResponse) {
const mockFetch = createMockFetch(defaultResponse)
vi.stubGlobal('fetch', mockFetch)
return mockFetch
}
/**
* Configures fetch to return a specific response for the next call.
*/
export function mockNextFetchResponse(response: MockFetchResponse) {
const currentFetch = globalThis.fetch
if (vi.isMockFunction(currentFetch)) {
currentFetch.mockResolvedValueOnce(createMockResponse(response))
}
}
/**
* Configures fetch to reject with an error.
*/
export function mockFetchError(error: Error | string) {
const currentFetch = globalThis.fetch
if (vi.isMockFunction(currentFetch)) {
currentFetch.mockRejectedValueOnce(error instanceof Error ? error : new Error(error))
}
}
@@ -0,0 +1,59 @@
import { vi } from 'vitest'
import { authMockFns } from './auth.mock'
/**
* Auth type constants matching `@/lib/auth/hybrid` AuthType. Included in
* `hybridAuthMock.AuthType` so route code can reference `AuthType.SESSION` etc.
*/
const AuthTypeMock = {
SESSION: 'session',
API_KEY: 'api_key',
INTERNAL_JWT: 'internal_jwt',
} as const
/**
* Default session-delegating implementation for `checkSessionOrInternalAuth`.
* Mirrors the real function's session-auth path so tests that only mock
* `getSession` (via `authMockFns.mockGetSession`) continue to work when the
* hybrid module is globally mocked.
*/
const defaultCheckSessionOrInternalAuth = async () => {
const session = await authMockFns.mockGetSession()
if (session?.user?.id) {
return {
success: true,
userId: session.user.id,
userName: session.user.name,
userEmail: session.user.email,
authType: AuthTypeMock.SESSION,
}
}
return { success: false, error: 'Unauthorized' }
}
/**
* Controllable mock functions for `@/lib/auth/hybrid`. Override per-test with
* `hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce(...)`.
*/
export const hybridAuthMockFns = {
mockCheckHybridAuth: vi.fn(defaultCheckSessionOrInternalAuth),
mockCheckSessionOrInternalAuth: vi.fn(defaultCheckSessionOrInternalAuth),
mockCheckInternalAuth: vi.fn(),
mockHasExternalApiCredentials: vi.fn(() => false),
}
/**
* Static mock module for `@/lib/auth/hybrid`.
*
* @example
* ```ts
* vi.mock('@/lib/auth/hybrid', () => hybridAuthMock)
* ```
*/
export const hybridAuthMock = {
AuthType: AuthTypeMock,
checkHybridAuth: hybridAuthMockFns.mockCheckHybridAuth,
checkSessionOrInternalAuth: hybridAuthMockFns.mockCheckSessionOrInternalAuth,
checkInternalAuth: hybridAuthMockFns.mockCheckInternalAuth,
hasExternalApiCredentials: hybridAuthMockFns.mockHasExternalApiCredentials,
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Mock implementations for common dependencies.
*
* @example
* ```ts
* import { createMockLogger, setupGlobalFetchMock, databaseMock } from '@sim/testing/mocks'
*
* // Mock the logger
* vi.mock('@sim/logger', () => ({ createLogger: () => createMockLogger() }))
*
* // Mock fetch globally
* setupGlobalFetchMock({ json: { success: true } })
*
* // Mock database
* vi.mock('@sim/db', () => databaseMock)
* ```
*/
// Audit mocks
export { auditMock, auditMockFns } from './audit.mock'
// Auth mocks
export { authMock, authMockFns, type MockUser } from './auth.mock'
// Auth OAuth utils mocks (for @/app/api/auth/oauth/utils)
export {
authOAuthUtilsMock,
authOAuthUtilsMockFns,
ServiceAccountTokenErrorMock,
} from './auth-oauth-utils.mock'
// Blocks mocks
export {
blocksMock,
createMockGetBlock,
createMockGetTool,
mockBlockConfigs,
mockToolConfigs,
toolsUtilsMock,
} from './blocks.mock'
// Copilot HTTP mocks (for @/lib/copilot/request/http)
export { copilotHttpMock, copilotHttpMockFns } from './copilot-http.mock'
// Database mocks
export {
createMockDb,
createMockSql,
createMockSqlOperators,
databaseMock,
dbChainMock,
dbChainMockFns,
drizzleOrmMock,
resetDbChainMock,
} from './database.mock'
// Encryption mocks
export { encryptionMock, encryptionMockFns } from './encryption.mock'
// Env mocks
export { createEnvMock, createMockGetEnv, defaultMockEnv, envMock } from './env.mock'
// Env flag mocks
export { envFlagsMock } from './env-flags.mock'
// Execution preprocessing mocks (for @/lib/execution/preprocessing)
export {
executionPreprocessingMock,
executionPreprocessingMockFns,
} from './execution-preprocessing.mock'
// Executor mocks - use side-effect import: import '@sim/testing/mocks/executor'
// Fetch mocks
export {
createMockFetch,
createMockResponse,
createMultiMockFetch,
type MockFetchResponse,
mockFetchError,
mockNextFetchResponse,
setupGlobalFetchMock,
} from './fetch.mock'
// Hybrid auth mocks
export { hybridAuthMock, hybridAuthMockFns } from './hybrid-auth.mock'
// Input validation mocks
export { inputValidationMock, inputValidationMockFns } from './input-validation.mock'
// Knowledge API utils mocks (for @/app/api/knowledge/utils)
export { knowledgeApiUtilsMock, knowledgeApiUtilsMockFns } from './knowledge-api-utils.mock'
// Logger mocks
export { clearLoggerMocks, createMockLogger, getLoggerCalls, loggerMock } from './logger.mock'
// Logging session mocks (for @/lib/logs/execution/logging-session)
export {
LoggingSessionMock,
loggingSessionMock,
loggingSessionMockFns,
} from './logging-session.mock'
// MCP OAuth mocks (for @/lib/mcp/oauth)
export {
McpOauthInsecureUrlErrorMock,
McpOauthRedirectRequiredMock,
mcpOauthMock,
mcpOauthMockFns,
} from './mcp-oauth.mock'
// Permission mocks
export { permissionsMock, permissionsMockFns } from './permissions.mock'
// PostHog server mocks (for @/lib/posthog/server)
export { posthogServerMock, posthogServerMockFns } from './posthog-server.mock'
// Redis client mocks (for Redis client objects)
export { clearRedisMocks, createMockRedis, type MockRedis } from './redis.mock'
// Redis config mocks (for @/lib/core/config/redis)
export { redisConfigMock, redisConfigMockFns } from './redis-config.mock'
// Request mocks
export {
createMockFormDataRequest,
createMockRequest,
requestUtilsMock,
requestUtilsMockFns,
} from './request.mock'
// Schema mocks
export { schemaMock } from './schema.mock'
// Socket mocks
export {
createMockSocket,
createMockSocketServer,
type MockSocket,
type MockSocketServer,
} from './socket.mock'
// Storage mocks (browser localStorage/sessionStorage)
export { clearStorageMocks, createMockStorage, setupGlobalStorageMocks } from './storage.mock'
// Storage service mocks (for @/lib/uploads/core/storage-service)
export { storageServiceMock, storageServiceMockFns } from './storage-service.mock'
// Stripe mocks
export {
createMockStripeEvent,
stripeClientMock,
stripeClientMockFns,
stripePaymentMethodMock,
stripePaymentMethodMockFns,
} from './stripe.mock'
// Telemetry mocks
export { telemetryMock } from './telemetry.mock'
// Terminal console mocks (for @/stores/terminal and @/stores/terminal/console/store)
export {
resetTerminalConsoleMock,
terminalConsoleMock,
terminalConsoleMockFns,
} from './terminal-console.mock'
// URL mocks
export { urlsMock, urlsMockFns } from './urls.mock'
// Workflow authz package mocks (for @sim/platform-authz/workflow)
export { workflowAuthzMock, workflowAuthzMockFns } from './workflow-authz.mock'
// Workflows API utils mocks (for @/app/api/workflows/utils)
export { workflowsApiUtilsMock, workflowsApiUtilsMockFns } from './workflows-api-utils.mock'
// Workflows orchestration mocks (for @/lib/workflows/orchestration)
export {
workflowsOrchestrationMock,
workflowsOrchestrationMockFns,
} from './workflows-orchestration.mock'
// Workflows persistence utils mocks (for @/lib/workflows/persistence/utils)
export {
workflowsPersistenceUtilsMock,
workflowsPersistenceUtilsMockFns,
} from './workflows-persistence-utils.mock'
// Workflows-utils mocks
export { workflowsUtilsMock, workflowsUtilsMockFns } from './workflows-utils.mock'
@@ -0,0 +1,47 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@/lib/core/security/input-validation.server`.
*
* @example
* ```ts
* import { inputValidationMockFns } from '@sim/testing'
*
* inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ valid: true })
* inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue({ response: new Response() })
* ```
*/
export const inputValidationMockFns = {
mockValidateUrlWithDNS: vi.fn(),
mockValidateDatabaseHost: vi.fn(),
mockSecureFetchWithPinnedIP: vi.fn(),
mockSecureFetchWithValidation: vi.fn(),
mockIsPrivateOrReservedIP: vi.fn().mockReturnValue(false),
mockCreatePinnedLookup: vi.fn(),
}
/**
* Static mock module for `@/lib/core/security/input-validation.server`.
*
* @example
* ```ts
* vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
* ```
*/
export const inputValidationMock = {
validateUrlWithDNS: inputValidationMockFns.mockValidateUrlWithDNS,
validateDatabaseHost: inputValidationMockFns.mockValidateDatabaseHost,
secureFetchWithPinnedIP: inputValidationMockFns.mockSecureFetchWithPinnedIP,
secureFetchWithValidation: inputValidationMockFns.mockSecureFetchWithValidation,
isPrivateOrReservedIP: inputValidationMockFns.mockIsPrivateOrReservedIP,
createPinnedLookup: inputValidationMockFns.mockCreatePinnedLookup,
SecureFetchHeaders: class {
headers: Record<string, string> = {}
set(k: string, v: string) {
this.headers[k] = v
}
get(k: string) {
return this.headers[k]
}
},
}
@@ -0,0 +1,40 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@/app/api/knowledge/utils`.
* All defaults are bare `vi.fn()` — override per-test with
* `knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess.mockResolvedValueOnce(...)`.
*
* @example
* ```ts
* import { knowledgeApiUtilsMockFns } from '@sim/testing'
*
* knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess.mockResolvedValue({
* hasAccess: true,
* knowledgeBase: { id: 'kb-1', userId: 'u-1', workspaceId: 'ws-1', name: 'KB' },
* })
* ```
*/
export const knowledgeApiUtilsMockFns = {
mockCheckKnowledgeBaseAccess: vi.fn(),
mockCheckKnowledgeBaseWriteAccess: vi.fn(),
mockCheckDocumentWriteAccess: vi.fn(),
mockCheckDocumentAccess: vi.fn(),
mockCheckChunkAccess: vi.fn(),
}
/**
* Static mock module for `@/app/api/knowledge/utils`.
*
* @example
* ```ts
* vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
* ```
*/
export const knowledgeApiUtilsMock = {
checkKnowledgeBaseAccess: knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess,
checkKnowledgeBaseWriteAccess: knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess,
checkDocumentWriteAccess: knowledgeApiUtilsMockFns.mockCheckDocumentWriteAccess,
checkDocumentAccess: knowledgeApiUtilsMockFns.mockCheckDocumentAccess,
checkChunkAccess: knowledgeApiUtilsMockFns.mockCheckChunkAccess,
}
+67
View File
@@ -0,0 +1,67 @@
import { vi } from 'vitest'
/**
* Creates a mock logger that captures all log calls.
*
* @example
* ```ts
* const logger = createMockLogger()
* // Use in your code
* logger.info('test message')
* // Assert
* expect(logger.info).toHaveBeenCalledWith('test message')
* ```
*/
export function createMockLogger() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(() => createMockLogger()),
withMetadata: vi.fn(() => createMockLogger()),
}
}
/**
* Mock module for @sim/logger.
* Use with vi.mock() to replace the real logger.
*
* @example
* ```ts
* vi.mock('@sim/logger', () => loggerMock)
* ```
*/
export const loggerMock = {
createLogger: vi.fn(() => createMockLogger()),
logger: createMockLogger(),
runWithRequestContext: vi.fn(<T>(_ctx: unknown, fn: () => T): T => fn()),
getRequestContext: vi.fn(() => undefined),
}
/**
* Returns the mock logger calls for assertion.
*/
export function getLoggerCalls(logger: ReturnType<typeof createMockLogger>) {
return {
info: logger.info.mock.calls,
warn: logger.warn.mock.calls,
error: logger.error.mock.calls,
debug: logger.debug.mock.calls,
}
}
/**
* Clears all logger mock calls.
*/
export function clearLoggerMocks(logger: ReturnType<typeof createMockLogger>) {
logger.info.mockClear()
logger.warn.mockClear()
logger.error.mockClear()
logger.debug.mockClear()
logger.trace.mockClear()
logger.fatal.mockClear()
logger.withMetadata.mockClear()
}
@@ -0,0 +1,75 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for the `LoggingSession` class from
* `@/lib/logs/execution/logging-session`. Every instance method is backed by a
* shared `vi.fn()` so tests that construct multiple sessions observe identical
* mock state. `mockSafeStart` defaults to `true` because callers branch on the
* boolean result. All other methods resolve to `undefined`.
*
* @example
* ```ts
* import { loggingSessionMockFns } from '@sim/testing'
*
* loggingSessionMockFns.mockSafeStart.mockResolvedValueOnce(false)
* expect(loggingSessionMockFns.mockSafeCompleteWithError).toHaveBeenCalled()
* ```
*/
export const loggingSessionMockFns = {
mockStart: vi.fn().mockResolvedValue(undefined),
mockComplete: vi.fn().mockResolvedValue(undefined),
mockCompleteWithError: vi.fn().mockResolvedValue(undefined),
mockCompleteWithCancellation: vi.fn().mockResolvedValue(undefined),
mockCompleteWithPause: vi.fn().mockResolvedValue(undefined),
mockSafeStart: vi.fn().mockResolvedValue(true),
mockWaitForCompletion: vi.fn().mockResolvedValue(undefined),
mockWaitForPostExecution: vi.fn().mockResolvedValue(undefined),
mockSafeComplete: vi.fn().mockResolvedValue(undefined),
mockSafeCompleteWithError: vi.fn().mockResolvedValue(undefined),
mockSafeCompleteWithCancellation: vi.fn().mockResolvedValue(undefined),
mockSafeCompleteWithPause: vi.fn().mockResolvedValue(undefined),
mockMarkAsFailed: vi.fn().mockResolvedValue(undefined),
}
/**
* Builds the object returned by each `new LoggingSession(...)` call. Declared as
* a named function (not an arrow) so it stays constructable under vitest 4's
* `Reflect.construct` path while remaining assignable to `mockImplementation`; a
* returned object overrides the constructed instance.
*/
function buildLoggingSessionInstance() {
return {
start: loggingSessionMockFns.mockStart,
complete: loggingSessionMockFns.mockComplete,
completeWithError: loggingSessionMockFns.mockCompleteWithError,
completeWithCancellation: loggingSessionMockFns.mockCompleteWithCancellation,
completeWithPause: loggingSessionMockFns.mockCompleteWithPause,
safeStart: loggingSessionMockFns.mockSafeStart,
waitForCompletion: loggingSessionMockFns.mockWaitForCompletion,
waitForPostExecution: loggingSessionMockFns.mockWaitForPostExecution,
safeComplete: loggingSessionMockFns.mockSafeComplete,
safeCompleteWithError: loggingSessionMockFns.mockSafeCompleteWithError,
safeCompleteWithCancellation: loggingSessionMockFns.mockSafeCompleteWithCancellation,
safeCompleteWithPause: loggingSessionMockFns.mockSafeCompleteWithPause,
markAsFailed: loggingSessionMockFns.mockMarkAsFailed,
}
}
/**
* Constructor-shaped mock for `LoggingSession`. Each `new LoggingSession(...)`
* call returns an object whose methods point at the shared `vi.fn()` refs in
* `loggingSessionMockFns`.
*/
export const LoggingSessionMock = vi.fn().mockImplementation(buildLoggingSessionInstance)
/**
* Static mock module for `@/lib/logs/execution/logging-session`.
*
* @example
* ```ts
* vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
* ```
*/
export const loggingSessionMock = {
LoggingSession: LoggingSessionMock,
}
@@ -0,0 +1,86 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@/lib/mcp/oauth`.
*
* @example
* ```ts
* import { mcpOauthMockFns } from '@sim/testing'
*
* mcpOauthMockFns.mockGetOrCreateOauthRow.mockResolvedValue({ id: 'oauth-row-1', ... })
* ```
*/
export const mcpOauthMockFns = {
mockAssertSafeOauthServerUrl: vi.fn(),
mockMcpAuthGuarded: vi.fn(),
mockGetOrCreateOauthRow: vi.fn(),
mockLoadOauthRow: vi.fn(),
mockLoadOauthRowByState: vi.fn(),
mockLoadPreregisteredClient: vi.fn(),
mockSetOauthRowUser: vi.fn(),
mockSaveClientInformation: vi.fn(),
mockSaveTokens: vi.fn(),
mockSaveCodeVerifier: vi.fn(),
mockSaveState: vi.fn(),
mockClearTokens: vi.fn(),
mockClearClient: vi.fn(),
mockClearVerifier: vi.fn(),
mockClearState: vi.fn(),
mockRevokeMcpOauthTokens: vi.fn(),
mockWithMcpOauthRefreshLock: vi.fn(async (_rowId: string, fn: () => Promise<unknown>) => fn()),
}
export class McpOauthRedirectRequiredMock extends Error {
constructor(public readonly authorizationUrl: string) {
super('MCP OAuth redirect required')
this.name = 'McpOauthRedirectRequiredMock'
}
}
export class McpOauthInsecureUrlErrorMock extends Error {
constructor(public readonly url: string) {
super(`Insecure MCP OAuth server URL: ${url}`)
this.name = 'McpOauthInsecureUrlErrorMock'
}
}
/**
* Returns the provider config back as the constructed instance, matching the
* original identity passthrough. Declared as a named function (not an arrow) so
* it stays constructable under vitest 4's `Reflect.construct` path while
* remaining assignable to `mockImplementation`.
*/
function buildSimMcpOauthProvider(value: object) {
return value
}
/**
* Static mock module for `@/lib/mcp/oauth`.
*
* @example
* ```ts
* vi.mock('@/lib/mcp/oauth', () => mcpOauthMock)
* ```
*/
export const mcpOauthMock = {
assertSafeOauthServerUrl: mcpOauthMockFns.mockAssertSafeOauthServerUrl,
mcpAuthGuarded: mcpOauthMockFns.mockMcpAuthGuarded,
getOrCreateOauthRow: mcpOauthMockFns.mockGetOrCreateOauthRow,
loadOauthRow: mcpOauthMockFns.mockLoadOauthRow,
loadOauthRowByState: mcpOauthMockFns.mockLoadOauthRowByState,
loadPreregisteredClient: mcpOauthMockFns.mockLoadPreregisteredClient,
setOauthRowUser: mcpOauthMockFns.mockSetOauthRowUser,
saveClientInformation: mcpOauthMockFns.mockSaveClientInformation,
saveTokens: mcpOauthMockFns.mockSaveTokens,
saveCodeVerifier: mcpOauthMockFns.mockSaveCodeVerifier,
saveState: mcpOauthMockFns.mockSaveState,
clearTokens: mcpOauthMockFns.mockClearTokens,
clearClient: mcpOauthMockFns.mockClearClient,
clearVerifier: mcpOauthMockFns.mockClearVerifier,
clearState: mcpOauthMockFns.mockClearState,
revokeMcpOauthTokens: mcpOauthMockFns.mockRevokeMcpOauthTokens,
withMcpOauthRefreshLock: mcpOauthMockFns.mockWithMcpOauthRefreshLock,
McpOauthRedirectRequired: McpOauthRedirectRequiredMock,
McpOauthInsecureUrlError: McpOauthInsecureUrlErrorMock,
SimMcpOauthProvider: vi.fn().mockImplementation(buildSimMcpOauthProvider),
}
@@ -0,0 +1,48 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@/lib/workspaces/permissions/utils`.
*
* @example
* ```ts
* import { permissionsMockFns } from '@sim/testing'
*
* permissionsMockFns.mockCheckWorkspaceAccess.mockResolvedValue({
* exists: true, hasAccess: true, canWrite: true, workspace: { id: 'ws-1', name: 'Test', ownerId: 'user-1' },
* })
* ```
*/
export const permissionsMockFns = {
mockWorkspaceExists: vi.fn(),
mockGetWorkspaceById: vi.fn(),
mockGetWorkspaceWithOwner: vi.fn(),
mockCheckWorkspaceAccess: vi.fn(),
mockAssertActiveWorkspaceAccess: vi.fn(),
mockGetUserEntityPermissions: vi.fn(),
mockGetUsersWithPermissions: vi.fn(),
mockGetWorkspaceMemberProfiles: vi.fn(),
mockHasWorkspaceAdminAccess: vi.fn(),
mockGetManageableWorkspaces: vi.fn(),
}
/**
* Static mock module for `@/lib/workspaces/permissions/utils`.
* Defaults resolve to "allowed" state for safe test defaults.
*
* @example
* ```ts
* vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
* ```
*/
export const permissionsMock = {
workspaceExists: permissionsMockFns.mockWorkspaceExists,
getWorkspaceById: permissionsMockFns.mockGetWorkspaceById,
getWorkspaceWithOwner: permissionsMockFns.mockGetWorkspaceWithOwner,
checkWorkspaceAccess: permissionsMockFns.mockCheckWorkspaceAccess,
assertActiveWorkspaceAccess: permissionsMockFns.mockAssertActiveWorkspaceAccess,
getUserEntityPermissions: permissionsMockFns.mockGetUserEntityPermissions,
getUsersWithPermissions: permissionsMockFns.mockGetUsersWithPermissions,
getWorkspaceMemberProfiles: permissionsMockFns.mockGetWorkspaceMemberProfiles,
hasWorkspaceAdminAccess: permissionsMockFns.mockHasWorkspaceAdminAccess,
getManageableWorkspaces: permissionsMockFns.mockGetManageableWorkspaces,
}
@@ -0,0 +1,30 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@/lib/posthog/server`.
* All defaults are bare `vi.fn()` — configure per-test as needed.
*
* @example
* ```ts
* import { posthogServerMockFns } from '@sim/testing'
*
* expect(posthogServerMockFns.mockCaptureServerEvent).toHaveBeenCalledWith(...)
* ```
*/
export const posthogServerMockFns = {
mockCaptureServerEvent: vi.fn(),
mockGetPostHogClient: vi.fn(() => null),
}
/**
* Static mock module for `@/lib/posthog/server`.
*
* @example
* ```ts
* vi.mock('@/lib/posthog/server', () => posthogServerMock)
* ```
*/
export const posthogServerMock = {
captureServerEvent: posthogServerMockFns.mockCaptureServerEvent,
getPostHogClient: posthogServerMockFns.mockGetPostHogClient,
}
@@ -0,0 +1,41 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@/lib/core/config/redis`.
* Default: `getRedisClient` returns `null` (tests that need a client override it).
* `acquireLock` defaults to succeeding (`true`); `releaseLock` defaults to `true`.
*
* @example
* ```ts
* import { redisConfigMockFns } from '@sim/testing'
*
* redisConfigMockFns.mockGetRedisClient.mockReturnValue(myFakeRedis)
* ```
*/
export const redisConfigMockFns = {
mockGetRedisClient: vi.fn().mockReturnValue(null),
mockOnRedisReconnect: vi.fn(),
mockAcquireLock: vi.fn().mockResolvedValue(true),
mockReleaseLock: vi.fn().mockResolvedValue(true),
mockExtendLock: vi.fn().mockResolvedValue(true),
mockCloseRedisConnection: vi.fn().mockResolvedValue(undefined),
mockResetForTesting: vi.fn(),
}
/**
* Static mock module for `@/lib/core/config/redis`.
*
* @example
* ```ts
* vi.mock('@/lib/core/config/redis', () => redisConfigMock)
* ```
*/
export const redisConfigMock = {
getRedisClient: redisConfigMockFns.mockGetRedisClient,
onRedisReconnect: redisConfigMockFns.mockOnRedisReconnect,
acquireLock: redisConfigMockFns.mockAcquireLock,
releaseLock: redisConfigMockFns.mockReleaseLock,
extendLock: redisConfigMockFns.mockExtendLock,
closeRedisConnection: redisConfigMockFns.mockCloseRedisConnection,
resetForTesting: redisConfigMockFns.mockResetForTesting,
}
+83
View File
@@ -0,0 +1,83 @@
import { vi } from 'vitest'
/**
* Creates a mock Redis client with common operations.
*
* @example
* ```ts
* const redis = createMockRedis()
* const queue = new RedisJobQueue(redis as never)
*
* // After operations
* expect(redis.hset).toHaveBeenCalled()
* expect(redis.expire).toHaveBeenCalledWith('key', 86400)
* ```
*/
export function createMockRedis() {
return {
// Hash operations
hset: vi.fn().mockResolvedValue(1),
hget: vi.fn().mockResolvedValue(null),
hgetall: vi.fn().mockResolvedValue({}),
hdel: vi.fn().mockResolvedValue(1),
hmset: vi.fn().mockResolvedValue('OK'),
hincrby: vi.fn().mockResolvedValue(1),
// Key operations
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue('OK'),
del: vi.fn().mockResolvedValue(1),
exists: vi.fn().mockResolvedValue(0),
expire: vi.fn().mockResolvedValue(1),
ttl: vi.fn().mockResolvedValue(-1),
// List operations
lpush: vi.fn().mockResolvedValue(1),
rpush: vi.fn().mockResolvedValue(1),
lpop: vi.fn().mockResolvedValue(null),
rpop: vi.fn().mockResolvedValue(null),
lrange: vi.fn().mockResolvedValue([]),
llen: vi.fn().mockResolvedValue(0),
// Set operations
sadd: vi.fn().mockResolvedValue(1),
srem: vi.fn().mockResolvedValue(1),
smembers: vi.fn().mockResolvedValue([]),
sismember: vi.fn().mockResolvedValue(0),
// Pub/Sub
publish: vi.fn().mockResolvedValue(0),
subscribe: vi.fn().mockResolvedValue(undefined),
unsubscribe: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
// Transaction
multi: vi.fn(() => ({
exec: vi.fn().mockResolvedValue([]),
})),
// Scripting
eval: vi.fn().mockResolvedValue(0),
// Connection
ping: vi.fn().mockResolvedValue('PONG'),
quit: vi.fn().mockResolvedValue('OK'),
disconnect: vi.fn().mockResolvedValue(undefined),
// Status
status: 'ready',
}
}
export type MockRedis = ReturnType<typeof createMockRedis>
/**
* Clears all Redis mock calls.
*/
export function clearRedisMocks(redis: MockRedis) {
Object.values(redis).forEach((value) => {
if (typeof value === 'function' && 'mockClear' in value) {
value.mockClear()
}
})
}
@@ -0,0 +1,97 @@
/**
* Mock request utilities for API testing
*/
import { NextRequest } from 'next/server'
import { vi } from 'vitest'
/**
* Creates a mock NextRequest for API route testing.
* This is a general-purpose utility for testing Next.js API routes.
*
* Returning `NextRequest` (not plain `Request`) keeps `request.nextUrl`
* available for routes that go through `parseRequest` and similar helpers
* that read query params via `request.nextUrl.searchParams`.
*
* @param method - HTTP method (GET, POST, PUT, DELETE, etc.)
* @param body - Optional request body (will be JSON stringified)
* @param headers - Optional headers to include
* @param url - Optional custom URL (defaults to http://localhost:3000/api/test)
* @returns NextRequest instance
*
* @example
* ```ts
* const req = createMockRequest('POST', { name: 'test' })
* const response = await POST(req)
* ```
*/
type NextRequestInit = NonNullable<ConstructorParameters<typeof NextRequest>[1]>
export function createMockRequest(
method = 'GET',
body?: unknown,
headers: Record<string, string> = {},
url = 'http://localhost:3000/api/test'
): NextRequest {
const init: NextRequestInit = {
method,
headers: new Headers({
'Content-Type': 'application/json',
...headers,
}),
}
if (body !== undefined) {
init.body = JSON.stringify(body)
}
return new NextRequest(new URL(url), init)
}
/**
* Creates a mock NextRequest with form data for file upload testing.
*
* @param formData - FormData instance
* @param method - HTTP method (defaults to POST)
* @param url - Optional custom URL
* @returns Request instance
*/
export function createMockFormDataRequest(
formData: FormData,
method = 'POST',
url = 'http://localhost:3000/api/test'
): Request {
return new Request(new URL(url), {
method,
body: formData,
})
}
/**
* Controllable mock functions for `@/lib/core/utils/request`.
*
* @example
* ```ts
* import { requestUtilsMockFns } from '@sim/testing'
*
* requestUtilsMockFns.mockGenerateRequestId.mockReturnValueOnce('test-req-42')
* requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce('10.0.0.5')
* ```
*/
export const requestUtilsMockFns = {
mockGenerateRequestId: vi.fn(() => 'mock-request-id'),
mockGetClientIp: vi.fn(() => '127.0.0.1'),
}
/**
* Static mock module for `@/lib/core/utils/request`.
*
* @example
* ```ts
* vi.mock('@/lib/core/utils/request', () => requestUtilsMock)
* ```
*/
export const requestUtilsMock = {
generateRequestId: requestUtilsMockFns.mockGenerateRequestId,
getClientIp: requestUtilsMockFns.mockGetClientIp,
noop: () => {},
}
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
import { generateRandomString } from '@sim/utils/random'
import { type Mock, vi } from 'vitest'
/**
* Mock socket interface for type safety.
*/
export interface IMockSocket {
id: string
connected: boolean
disconnected: boolean
emit: Mock
on: Mock
once: Mock
off: Mock
connect: Mock
disconnect: Mock
join: Mock
leave: Mock
_handlers: Record<string, ((...args: any[]) => any)[]>
_trigger: (event: string, ...args: any[]) => void
_reset: () => void
}
/**
* Creates a mock Socket.IO client socket.
*
* @example
* ```ts
* const socket = createMockSocket()
* socket.emit('test', { data: 'value' })
* expect(socket.emit).toHaveBeenCalledWith('test', { data: 'value' })
* ```
*/
export function createMockSocket(): IMockSocket {
const eventHandlers: Record<string, ((...args: any[]) => any)[]> = {}
const socket = {
id: `socket-${generateRandomString(8)}`,
connected: true,
disconnected: false,
// Core methods
emit: vi.fn((event: string, ..._args: any[]) => {
return socket
}),
on: vi.fn((event: string, handler: (...args: any[]) => any) => {
if (!eventHandlers[event]) {
eventHandlers[event] = []
}
eventHandlers[event].push(handler)
return socket
}),
once: vi.fn((event: string, handler: (...args: any[]) => any) => {
if (!eventHandlers[event]) {
eventHandlers[event] = []
}
eventHandlers[event].push(handler)
return socket
}),
off: vi.fn((event: string, handler?: (...args: any[]) => any) => {
if (handler && eventHandlers[event]) {
eventHandlers[event] = eventHandlers[event].filter((h) => h !== handler)
} else {
delete eventHandlers[event]
}
return socket
}),
connect: vi.fn(() => {
socket.connected = true
socket.disconnected = false
return socket
}),
disconnect: vi.fn(() => {
socket.connected = false
socket.disconnected = true
return socket
}),
// Room methods
join: vi.fn((_room: string) => socket),
leave: vi.fn((_room: string) => socket),
// Utility methods for testing
_handlers: eventHandlers,
_trigger: (event: string, ...args: any[]) => {
const handlers = eventHandlers[event] || []
handlers.forEach((handler) => handler(...args))
},
_reset: () => {
Object.keys(eventHandlers).forEach((key) => delete eventHandlers[key])
socket.emit.mockClear()
socket.on.mockClear()
socket.once.mockClear()
socket.off.mockClear()
},
}
return socket
}
/**
* Mock socket server interface.
*/
export interface IMockSocketServer {
sockets: Map<string, IMockSocket>
rooms: Map<string, Set<string>>
emit: Mock
to: Mock
in: Mock
_addSocket: (socket: IMockSocket) => void
_joinRoom: (socketId: string, room: string) => void
_leaveRoom: (socketId: string, room: string) => void
}
/**
* Creates a mock Socket.IO server.
*/
export function createMockSocketServer(): IMockSocketServer {
const sockets = new Map<string, IMockSocket>()
const rooms = new Map<string, Set<string>>()
return {
sockets,
rooms,
emit: vi.fn((_event: string, ..._args: any[]) => {}),
to: vi.fn((room: string) => ({
emit: vi.fn((event: string, ...args: any[]) => {
const socketIds = rooms.get(room) || new Set()
socketIds.forEach((id) => {
const socket = sockets.get(id)
if (socket) {
socket._trigger(event, ...args)
}
})
}),
})),
in: vi.fn((room: string) => ({
emit: vi.fn((event: string, ...args: any[]) => {
const socketIds = rooms.get(room) || new Set()
socketIds.forEach((id) => {
const socket = sockets.get(id)
if (socket) {
socket._trigger(event, ...args)
}
})
}),
})),
_addSocket: (socket: ReturnType<typeof createMockSocket>) => {
sockets.set(socket.id, socket)
},
_joinRoom: (socketId: string, room: string) => {
if (!rooms.has(room)) {
rooms.set(room, new Set())
}
rooms.get(room)?.add(socketId)
},
_leaveRoom: (socketId: string, room: string) => {
rooms.get(room)?.delete(socketId)
},
}
}
/**
* Type aliases for convenience.
*/
export type MockSocket = IMockSocket
export type MockSocketServer = IMockSocketServer
@@ -0,0 +1,47 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@/lib/uploads/core/storage-service`.
* All defaults are bare `vi.fn()` — configure per-test as needed.
*
* @example
* ```ts
* import { storageServiceMockFns } from '@sim/testing'
*
* storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
* storageServiceMockFns.mockGeneratePresignedUploadUrl.mockResolvedValue({
* uploadUrl: 'https://s3/test', key: 'workspace/x/y', ...
* })
* ```
*/
export const storageServiceMockFns = {
mockUploadFile: vi.fn(),
mockDownloadFile: vi.fn(),
mockDeleteFile: vi.fn(),
mockHeadObject: vi.fn(),
mockGeneratePresignedUploadUrl: vi.fn(),
mockGenerateBatchPresignedUploadUrls: vi.fn(),
mockGeneratePresignedDownloadUrl: vi.fn(),
mockHasCloudStorage: vi.fn(() => false),
mockGetS3InfoForKey: vi.fn(),
}
/**
* Static mock module for `@/lib/uploads/core/storage-service`.
*
* @example
* ```ts
* vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
* ```
*/
export const storageServiceMock = {
uploadFile: storageServiceMockFns.mockUploadFile,
downloadFile: storageServiceMockFns.mockDownloadFile,
deleteFile: storageServiceMockFns.mockDeleteFile,
headObject: storageServiceMockFns.mockHeadObject,
generatePresignedUploadUrl: storageServiceMockFns.mockGeneratePresignedUploadUrl,
generateBatchPresignedUploadUrls: storageServiceMockFns.mockGenerateBatchPresignedUploadUrls,
generatePresignedDownloadUrl: storageServiceMockFns.mockGeneratePresignedDownloadUrl,
hasCloudStorage: storageServiceMockFns.mockHasCloudStorage,
getS3InfoForKey: storageServiceMockFns.mockGetS3InfoForKey,
}
@@ -0,0 +1,76 @@
import { vi } from 'vitest'
/**
* Creates a mock storage implementation (localStorage/sessionStorage).
*
* @example
* ```ts
* const storage = createMockStorage()
* storage.setItem('key', 'value')
* expect(storage.getItem('key')).toBe('value')
* ```
*/
export function createMockStorage(): Storage {
const store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
Object.keys(store).forEach((key) => delete store[key])
}),
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
get length() {
return Object.keys(store).length
},
}
}
/**
* Sets up global localStorage and sessionStorage mocks.
*
* @example
* ```ts
* // In vitest.setup.ts
* setupGlobalStorageMocks()
* ```
*/
export function setupGlobalStorageMocks() {
const localStorageMock = createMockStorage()
const sessionStorageMock = createMockStorage()
Object.defineProperty(globalThis, 'localStorage', {
value: localStorageMock,
writable: true,
})
Object.defineProperty(globalThis, 'sessionStorage', {
value: sessionStorageMock,
writable: true,
})
return { localStorage: localStorageMock, sessionStorage: sessionStorageMock }
}
/**
* Clears all storage mock data and calls.
*/
export function clearStorageMocks() {
if (typeof localStorage !== 'undefined') {
localStorage.clear()
vi.mocked(localStorage.getItem).mockClear()
vi.mocked(localStorage.setItem).mockClear()
vi.mocked(localStorage.removeItem).mockClear()
}
if (typeof sessionStorage !== 'undefined') {
sessionStorage.clear()
vi.mocked(sessionStorage.getItem).mockClear()
vi.mocked(sessionStorage.setItem).mockClear()
vi.mocked(sessionStorage.removeItem).mockClear()
}
}
+71
View File
@@ -0,0 +1,71 @@
import type Stripe from 'stripe'
import { vi } from 'vitest'
/**
* Mock for `@/lib/billing/stripe-client`.
*
* @example
* ```ts
* import { stripeClientMock, stripeClientMockFns } from '@sim/testing'
* vi.mock('@/lib/billing/stripe-client', () => stripeClientMock)
*
* stripeClientMockFns.mockRequireStripeClient.mockReturnValue(fakeStripe)
* ```
*/
export const stripeClientMockFns = {
mockRequireStripeClient: vi.fn(),
mockGetStripeClient: vi.fn(),
mockHasValidStripeCredentials: vi.fn(() => true),
}
export const stripeClientMock = {
requireStripeClient: stripeClientMockFns.mockRequireStripeClient,
getStripeClient: stripeClientMockFns.mockGetStripeClient,
hasValidStripeCredentials: stripeClientMockFns.mockHasValidStripeCredentials,
}
/**
* Mock for `@/lib/billing/stripe-payment-method`.
*
* @example
* ```ts
* import { stripePaymentMethodMock, stripePaymentMethodMockFns } from '@sim/testing'
* vi.mock('@/lib/billing/stripe-payment-method', () => stripePaymentMethodMock)
* ```
*/
export const stripePaymentMethodMockFns = {
mockResolveDefaultPaymentMethod: vi.fn(async () => ({
paymentMethodId: undefined as string | undefined,
collectionMethod: 'charge_automatically' as 'charge_automatically' | 'send_invoice' | null,
})),
mockGetCustomerId: vi.fn(),
}
export const stripePaymentMethodMock = {
resolveDefaultPaymentMethod: stripePaymentMethodMockFns.mockResolveDefaultPaymentMethod,
getCustomerId: stripePaymentMethodMockFns.mockGetCustomerId,
}
/**
* Build a minimal `Stripe.Event` with the given type and object payload.
* Fills in a deterministic `id` (`evt_${type}`) and nests `object` under
* `data.object` as Stripe does.
*/
export function createMockStripeEvent<T = unknown>(
type: string,
object: T,
overrides: Partial<Stripe.Event> = {}
): Stripe.Event {
return {
id: `evt_${type}`,
object: 'event',
api_version: '2024-06-20',
created: Math.floor(Date.now() / 1000),
livemode: false,
pending_webhooks: 0,
request: null,
type,
data: { object: object as unknown as Stripe.Event.Data.Object },
...overrides,
} as Stripe.Event
}
@@ -0,0 +1,30 @@
/**
* Mock for @/lib/core/telemetry module.
* Provides no-op implementations for telemetry functions and PlatformEvents.
*/
import { vi } from 'vitest'
/**
* Pre-configured telemetry mock for use with vi.mock.
* All PlatformEvents methods are no-op vi.fn() stubs.
*
* @example
* ```ts
* vi.mock('@/lib/core/telemetry', () => telemetryMock)
* ```
*/
export const telemetryMock = {
PlatformEvents: new Proxy(
{},
{
get: (_target, prop) => {
if (typeof prop === 'string') {
return vi.fn()
}
return undefined
},
}
),
createWorkflowSpans: vi.fn(),
trackPlatformEvent: vi.fn(),
}
@@ -0,0 +1,90 @@
import { generateRandomString } from '@sim/utils/random'
import { vi } from 'vitest'
interface ConsoleEntryLike {
id?: string
workflowId: string
blockId: string
blockType: string
executionId?: string
isRunning?: boolean
error?: string | null
[key: string]: unknown
}
const entriesByWorkflow: Record<string, ConsoleEntryLike[]> = {}
const mockGetWorkflowEntries = vi.fn((workflowId: string) => entriesByWorkflow[workflowId] ?? [])
const mockAddConsole = vi.fn((entry: ConsoleEntryLike) => {
const stored = { ...entry, id: entry.id ?? `mock-${generateRandomString(16)}` }
if (!entriesByWorkflow[entry.workflowId]) entriesByWorkflow[entry.workflowId] = []
entriesByWorkflow[entry.workflowId].push(stored)
return stored
})
const mockUpdateConsole = vi.fn()
const mockCancelRunningEntries = vi.fn()
const mockClearWorkflowConsole = vi.fn((workflowId: string) => {
delete entriesByWorkflow[workflowId]
})
/**
* Resets the in-memory mock console store. Call from `beforeEach` if your tests
* push entries via `terminalConsoleMockFns.mockAddConsole`.
*/
export function resetTerminalConsoleMock(): void {
for (const key of Object.keys(entriesByWorkflow)) delete entriesByWorkflow[key]
mockGetWorkflowEntries.mockClear()
mockAddConsole.mockClear()
mockUpdateConsole.mockClear()
mockCancelRunningEntries.mockClear()
mockClearWorkflowConsole.mockClear()
}
/**
* Controllable mock fns for `@/stores/terminal` and `@/stores/terminal/console/store`.
* Includes a tiny in-memory store backing `getWorkflowEntries`/`addConsole` so callers
* exercising the read-after-write contract behave correctly without the real Zustand store.
*/
export const terminalConsoleMockFns = {
mockGetWorkflowEntries,
mockAddConsole,
mockUpdateConsole,
mockCancelRunningEntries,
mockClearWorkflowConsole,
reset: resetTerminalConsoleMock,
}
const stateValue = {
addConsole: mockAddConsole,
updateConsole: mockUpdateConsole,
cancelRunningEntries: mockCancelRunningEntries,
clearWorkflowConsole: mockClearWorkflowConsole,
getWorkflowEntries: mockGetWorkflowEntries,
workflowEntries: entriesByWorkflow,
entryIdsByBlockExecution: {},
entryLocationById: {},
isOpen: false,
_hasHydrated: true,
}
/**
* Static mock module for `@/stores/terminal` / `@/stores/terminal/console/store`.
*
* @example
* ```ts
* vi.mock('@/stores/terminal', () => terminalConsoleMock)
* ```
*/
export const terminalConsoleMock = {
useTerminalConsoleStore: Object.assign(
vi.fn(() => stateValue),
{
getState: vi.fn(() => stateValue),
setState: vi.fn(),
subscribe: vi.fn(),
}
),
saveExecutionPointer: vi.fn(),
}
+43
View File
@@ -0,0 +1,43 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@/lib/core/utils/urls`.
*
* @example
* ```ts
* import { urlsMockFns } from '@sim/testing'
*
* urlsMockFns.mockGetBaseUrl.mockReturnValue('https://custom.example.com')
* ```
*/
export const urlsMockFns = {
mockGetBaseUrl: vi.fn(),
mockGetInternalApiBaseUrl: vi.fn(),
mockEnsureAbsoluteUrl: vi.fn(),
mockGetBaseDomain: vi.fn(),
mockGetEmailDomain: vi.fn(),
mockGetSocketServerUrl: vi.fn(),
mockGetSocketUrl: vi.fn(),
mockGetOllamaUrl: vi.fn(),
}
/**
* Static mock module for `@/lib/core/utils/urls`.
* Functions return sensible localhost defaults.
*
* @example
* ```ts
* vi.mock('@/lib/core/utils/urls', () => urlsMock)
* ```
*/
export const urlsMock = {
SITE_URL: 'https://www.sim.ai',
getBaseUrl: urlsMockFns.mockGetBaseUrl,
getInternalApiBaseUrl: urlsMockFns.mockGetInternalApiBaseUrl,
ensureAbsoluteUrl: urlsMockFns.mockEnsureAbsoluteUrl,
getBaseDomain: urlsMockFns.mockGetBaseDomain,
getEmailDomain: urlsMockFns.mockGetEmailDomain,
getSocketServerUrl: urlsMockFns.mockGetSocketServerUrl,
getSocketUrl: urlsMockFns.mockGetSocketUrl,
getOllamaUrl: urlsMockFns.mockGetOllamaUrl,
}
@@ -0,0 +1,107 @@
import { vi } from 'vitest'
/**
* Real `WorkflowLockedError` subclass used by tests so `instanceof` checks in
* route handlers behave the same as in production. Mirrors the shape exported
* by `@sim/platform-authz/workflow`.
*/
export class MockWorkflowLockedError extends Error {
readonly status = 423
constructor(message = 'Workflow is locked') {
super(message)
this.name = 'WorkflowLockedError'
}
}
/**
* Real `FolderLockedError` subclass used by tests so `instanceof` checks in
* route handlers behave the same as in production. Mirrors the shape exported
* by `@sim/platform-authz/workflow`.
*/
export class MockFolderLockedError extends Error {
readonly status = 423
constructor(message = 'Folder is locked') {
super(message)
this.name = 'FolderLockedError'
}
}
/**
* Real `FolderNotFoundError` subclass used by tests so `instanceof` checks in
* route handlers behave the same as in production. Mirrors the shape exported
* by `@sim/platform-authz/workflow`.
*/
export class MockFolderNotFoundError extends Error {
readonly status = 400
constructor(message = 'Target folder not found') {
super(message)
this.name = 'FolderNotFoundError'
}
}
const unlockedStatus = {
locked: false,
directLocked: false,
inheritedLocked: false,
lockedBy: null as 'workflow' | 'folder' | null,
lockedFolderId: null as string | null,
}
/**
* Controllable mocks for the `@sim/platform-authz/workflow` entry.
*
* Defaults assume permissive access (no lock, write allowed). Override with
* `mockResolvedValue` per test when exercising the lock/permission paths.
*
* @example
* ```ts
* import { workflowAuthzMockFns } from '@sim/testing'
*
* workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
* allowed: true,
* status: 200,
* workflow: { id: 'wf-1' },
* workspacePermission: 'admin',
* })
* ```
*/
export const workflowAuthzMockFns = {
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
mockGetActiveWorkflowContext: vi.fn(),
mockGetActiveWorkflowRecord: vi.fn(),
mockAssertActiveWorkflowContext: vi.fn(),
mockGetFolderLockStatus: vi.fn().mockResolvedValue(unlockedStatus),
mockGetWorkflowLockStatus: vi.fn().mockResolvedValue(unlockedStatus),
mockAssertWorkflowMutable: vi.fn().mockResolvedValue(undefined),
mockAssertFolderMutable: vi.fn().mockResolvedValue(undefined),
mockIsFolderInWorkspace: vi.fn().mockResolvedValue(true),
mockAssertFolderInWorkspace: vi.fn().mockResolvedValue(undefined),
}
/**
* Static mock module for `@sim/platform-authz/workflow`.
*
* @example
* ```ts
* vi.mock('@sim/platform-authz/workflow', () => workflowAuthzMock)
* ```
*/
export const workflowAuthzMock = {
authorizeWorkflowByWorkspacePermission:
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission,
getActiveWorkflowContext: workflowAuthzMockFns.mockGetActiveWorkflowContext,
getActiveWorkflowRecord: workflowAuthzMockFns.mockGetActiveWorkflowRecord,
assertActiveWorkflowContext: workflowAuthzMockFns.mockAssertActiveWorkflowContext,
getFolderLockStatus: workflowAuthzMockFns.mockGetFolderLockStatus,
getWorkflowLockStatus: workflowAuthzMockFns.mockGetWorkflowLockStatus,
assertWorkflowMutable: workflowAuthzMockFns.mockAssertWorkflowMutable,
assertFolderMutable: workflowAuthzMockFns.mockAssertFolderMutable,
isFolderInWorkspace: workflowAuthzMockFns.mockIsFolderInWorkspace,
assertFolderInWorkspace: workflowAuthzMockFns.mockAssertFolderInWorkspace,
WorkflowLockedError: MockWorkflowLockedError,
FolderLockedError: MockFolderLockedError,
FolderNotFoundError: MockFolderNotFoundError,
}
@@ -0,0 +1,49 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@/app/api/workflows/utils`.
*
* Default `createSuccessResponse`/`createErrorResponse` return a mock Response-like
* object where `.json()` resolves to the payload — compatible with most assertions
* like `expect(await res.json()).toEqual(...)` and `expect(res.status).toBe(...)`.
*
* @example
* ```ts
* import { workflowsApiUtilsMockFns } from '@sim/testing'
*
* workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership.mockResolvedValue('admin')
* workflowsApiUtilsMockFns.mockCheckNeedsRedeployment.mockResolvedValue(true)
* ```
*/
export const workflowsApiUtilsMockFns = {
mockCreateSuccessResponse: vi.fn((data: unknown) => ({
status: 200,
ok: true,
json: async () => data,
})),
mockCreateErrorResponse: vi.fn((error: string, status: number, code?: string) => ({
status,
ok: false,
json: async () => ({
error,
code: code || error.toUpperCase().replace(/\s+/g, '_'),
}),
})),
mockCheckNeedsRedeployment: vi.fn().mockResolvedValue(false),
mockVerifyWorkspaceMembership: vi.fn().mockResolvedValue('member'),
}
/**
* Static mock module for `@/app/api/workflows/utils`.
*
* @example
* ```ts
* vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
* ```
*/
export const workflowsApiUtilsMock = {
createSuccessResponse: workflowsApiUtilsMockFns.mockCreateSuccessResponse,
createErrorResponse: workflowsApiUtilsMockFns.mockCreateErrorResponse,
checkNeedsRedeployment: workflowsApiUtilsMockFns.mockCheckNeedsRedeployment,
verifyWorkspaceMembership: workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership,
}
@@ -0,0 +1,55 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@/lib/workflows/orchestration`.
* All defaults are bare `vi.fn()` — configure per-test as needed.
*
* @example
* ```ts
* import { workflowsOrchestrationMockFns } from '@sim/testing'
*
* workflowsOrchestrationMockFns.mockPerformFullDeploy.mockResolvedValue({
* success: true,
* version: 1,
* })
* ```
*/
export const workflowsOrchestrationMockFns = {
mockPerformChatDeploy: vi.fn(),
mockPerformChatUndeploy: vi.fn(),
mockNotifySocketDeploymentChanged: vi.fn(),
mockPerformActivateVersion: vi.fn(),
mockPerformFullDeploy: vi.fn(),
mockPerformFullUndeploy: vi.fn(),
mockPerformRevertToVersion: vi.fn(),
mockPerformCreateFolder: vi.fn(),
mockPerformUpdateFolder: vi.fn(),
mockPerformDeleteFolder: vi.fn(),
mockPerformCreateWorkflow: vi.fn(),
mockPerformUpdateWorkflow: vi.fn(),
mockPerformDeleteWorkflow: vi.fn(),
}
/**
* Static mock module for `@/lib/workflows/orchestration`.
*
* @example
* ```ts
* vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
* ```
*/
export const workflowsOrchestrationMock = {
performChatDeploy: workflowsOrchestrationMockFns.mockPerformChatDeploy,
performChatUndeploy: workflowsOrchestrationMockFns.mockPerformChatUndeploy,
notifySocketDeploymentChanged: workflowsOrchestrationMockFns.mockNotifySocketDeploymentChanged,
performActivateVersion: workflowsOrchestrationMockFns.mockPerformActivateVersion,
performFullDeploy: workflowsOrchestrationMockFns.mockPerformFullDeploy,
performFullUndeploy: workflowsOrchestrationMockFns.mockPerformFullUndeploy,
performRevertToVersion: workflowsOrchestrationMockFns.mockPerformRevertToVersion,
performCreateFolder: workflowsOrchestrationMockFns.mockPerformCreateFolder,
performUpdateFolder: workflowsOrchestrationMockFns.mockPerformUpdateFolder,
performDeleteFolder: workflowsOrchestrationMockFns.mockPerformDeleteFolder,
performCreateWorkflow: workflowsOrchestrationMockFns.mockPerformCreateWorkflow,
performUpdateWorkflow: workflowsOrchestrationMockFns.mockPerformUpdateWorkflow,
performDeleteWorkflow: workflowsOrchestrationMockFns.mockPerformDeleteWorkflow,
}
@@ -0,0 +1,60 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@/lib/workflows/persistence/utils`.
* All defaults are bare `vi.fn()` — configure per-test as needed.
*
* @example
* ```ts
* import { workflowsPersistenceUtilsMockFns } from '@sim/testing'
*
* workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables.mockResolvedValue({
* blocks: {},
* edges: [],
* loops: {},
* parallels: {},
* isFromNormalizedTables: true,
* })
* ```
*/
export const workflowsPersistenceUtilsMockFns = {
mockBlockExistsInDeployment: vi.fn(),
mockLoadDeployedWorkflowState: vi.fn(),
mockMigrateAgentBlocksToMessagesFormat: vi.fn(),
mockLoadWorkflowFromNormalizedTables: vi.fn(),
mockSaveWorkflowToNormalizedTables: vi.fn(),
mockWorkflowExistsInNormalizedTables: vi.fn(),
mockDeployWorkflow: vi.fn(),
mockRegenerateWorkflowStateIds: vi.fn(),
mockUndeployWorkflow: vi.fn(),
mockActivateWorkflowVersion: vi.fn(),
mockActivateWorkflowVersionById: vi.fn(),
mockListWorkflowVersions: vi.fn(),
}
/**
* Static mock module for `@/lib/workflows/persistence/utils`.
*
* @example
* ```ts
* vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
* ```
*/
export const workflowsPersistenceUtilsMock = {
blockExistsInDeployment: workflowsPersistenceUtilsMockFns.mockBlockExistsInDeployment,
loadDeployedWorkflowState: workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState,
migrateAgentBlocksToMessagesFormat:
workflowsPersistenceUtilsMockFns.mockMigrateAgentBlocksToMessagesFormat,
loadWorkflowFromNormalizedTables:
workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables,
saveWorkflowToNormalizedTables:
workflowsPersistenceUtilsMockFns.mockSaveWorkflowToNormalizedTables,
workflowExistsInNormalizedTables:
workflowsPersistenceUtilsMockFns.mockWorkflowExistsInNormalizedTables,
deployWorkflow: workflowsPersistenceUtilsMockFns.mockDeployWorkflow,
regenerateWorkflowStateIds: workflowsPersistenceUtilsMockFns.mockRegenerateWorkflowStateIds,
undeployWorkflow: workflowsPersistenceUtilsMockFns.mockUndeployWorkflow,
activateWorkflowVersion: workflowsPersistenceUtilsMockFns.mockActivateWorkflowVersion,
activateWorkflowVersionById: workflowsPersistenceUtilsMockFns.mockActivateWorkflowVersionById,
listWorkflowVersions: workflowsPersistenceUtilsMockFns.mockListWorkflowVersions,
}
@@ -0,0 +1,69 @@
import { vi } from 'vitest'
/**
* Controllable mock functions for `@/lib/workflows/utils`.
* Use these references in tests to configure return values and assert calls.
*
* @example
* ```ts
* import { workflowsUtilsMockFns } from '@sim/testing'
*
* workflowsUtilsMockFns.mockGetWorkflowById.mockResolvedValue({ id: 'wf-1', name: 'Test' })
* ```
*/
export const workflowsUtilsMockFns = {
mockGetWorkflowById: vi.fn(),
mockListWorkflows: vi.fn(),
mockDeduplicateWorkflowName: vi.fn(),
mockResolveWorkflowIdForUser: vi.fn(),
mockUpdateWorkflowRunCounts: vi.fn(),
mockWorkflowHasResponseBlock: vi.fn(),
mockCreateHttpResponseFromBlock: vi.fn(),
mockValidateWorkflowPermissions: vi.fn(),
mockCreateWorkflowRecord: vi.fn(),
mockUpdateWorkflowRecord: vi.fn(),
mockDeleteWorkflowRecord: vi.fn(),
mockSetWorkflowVariables: vi.fn(),
mockCreateFolderRecord: vi.fn(),
mockUpdateFolderRecord: vi.fn(),
mockDeleteFolderRecord: vi.fn(),
mockCheckForCircularReference: vi.fn(),
mockListFolders: vi.fn(),
}
/**
* Static mock module for `@/lib/workflows/utils`.
* Use with `vi.mock()` to replace the real module in tests.
*
* Default behaviors:
* - `getWorkflowById` resolves to `null`
* - `validateWorkflowPermissions` resolves to an authorized result
* - Other functions resolve to sensible empty/success defaults
*
* `authorizeWorkflowByWorkspacePermission` moved to `@sim/platform-authz/workflow`;
* use `workflowAuthzMock` / `workflowAuthzMockFns` for that surface.
*
* @example
* ```ts
* vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
* ```
*/
export const workflowsUtilsMock = {
getWorkflowById: workflowsUtilsMockFns.mockGetWorkflowById,
listWorkflows: workflowsUtilsMockFns.mockListWorkflows,
deduplicateWorkflowName: workflowsUtilsMockFns.mockDeduplicateWorkflowName,
resolveWorkflowIdForUser: workflowsUtilsMockFns.mockResolveWorkflowIdForUser,
updateWorkflowRunCounts: workflowsUtilsMockFns.mockUpdateWorkflowRunCounts,
workflowHasResponseBlock: workflowsUtilsMockFns.mockWorkflowHasResponseBlock,
createHttpResponseFromBlock: workflowsUtilsMockFns.mockCreateHttpResponseFromBlock,
validateWorkflowPermissions: workflowsUtilsMockFns.mockValidateWorkflowPermissions,
createWorkflowRecord: workflowsUtilsMockFns.mockCreateWorkflowRecord,
updateWorkflowRecord: workflowsUtilsMockFns.mockUpdateWorkflowRecord,
deleteWorkflowRecord: workflowsUtilsMockFns.mockDeleteWorkflowRecord,
setWorkflowVariables: workflowsUtilsMockFns.mockSetWorkflowVariables,
createFolderRecord: workflowsUtilsMockFns.mockCreateFolderRecord,
updateFolderRecord: workflowsUtilsMockFns.mockUpdateFolderRecord,
deleteFolderRecord: workflowsUtilsMockFns.mockDeleteFolderRecord,
checkForCircularReference: workflowsUtilsMockFns.mockCheckForCircularReference,
listFolders: workflowsUtilsMockFns.mockListFolders,
}
@@ -0,0 +1,74 @@
/**
* Global setup utilities that run once before all tests.
*
* Use this for expensive setup that should only happen once.
*/
import { vi } from 'vitest'
/**
* Suppresses specific console warnings/errors during tests.
*/
export function suppressConsoleWarnings(patterns: RegExp[]): void {
const originalWarn = console.warn
const originalError = console.error
console.warn = (...args: any[]) => {
const message = args.join(' ')
if (patterns.some((pattern) => pattern.test(message))) {
return
}
originalWarn.apply(console, args)
}
console.error = (...args: any[]) => {
const message = args.join(' ')
if (patterns.some((pattern) => pattern.test(message))) {
return
}
originalError.apply(console, args)
}
}
/**
* Common patterns to suppress in tests.
*/
export const COMMON_SUPPRESS_PATTERNS = [
/Zustand.*persist middleware/i,
/React does not recognize the.*prop/,
/Warning: Invalid DOM property/,
/act\(\) warning/,
]
/**
* Sets up global mocks for Node.js environment.
*/
export function setupNodeEnvironment(): void {
// Mock window if not present
if (typeof window === 'undefined') {
vi.stubGlobal('window', {
location: { href: 'http://localhost:3000' },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})
}
// Mock document if not present
if (typeof document === 'undefined') {
vi.stubGlobal('document', {
createElement: vi.fn(() => ({
style: {},
setAttribute: vi.fn(),
appendChild: vi.fn(),
})),
body: { appendChild: vi.fn() },
})
}
}
/**
* Cleans up global mocks after tests.
*/
export function cleanupGlobalMocks(): void {
vi.unstubAllGlobals()
}
@@ -0,0 +1,40 @@
/**
* Shared Vitest setup file for the testing package.
*
* Import this in your vitest.config.ts to get common mocks and setup.
*
* @example
* ```ts
* // vitest.config.ts
* export default defineConfig({
* test: {
* setupFiles: ['@sim/testing/setup'],
* },
* })
* ```
*/
import { afterEach, beforeEach, vi } from 'vitest'
import { setupGlobalFetchMock } from '../mocks/fetch.mock'
import { createMockLogger } from '../mocks/logger.mock'
import { clearStorageMocks, setupGlobalStorageMocks } from '../mocks/storage.mock'
// Setup global storage mocks
setupGlobalStorageMocks()
// Setup global fetch mock with empty JSON response by default
setupGlobalFetchMock({ json: {} })
// Clear mocks between tests
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
clearStorageMocks()
})
// Export utilities for use in tests
export { createMockLogger }
export { setupGlobalStorageMocks, clearStorageMocks }
export { mockFetchError, mockNextFetchResponse, setupGlobalFetchMock } from '../mocks/fetch.mock'
+153
View File
@@ -0,0 +1,153 @@
/**
* Core types for the testing package.
*
* These are intentionally loose/permissive types that accept any shape of data
* from the app. The testing package should not try to mirror app types exactly -
* that creates maintenance burden and type drift issues.
*
* Tests themselves provide type safety through their actual usage of app types.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface Position {
x: number
y: number
}
export interface BlockData {
parentId?: string
extent?: string
width?: number
height?: number
count?: number
loopType?: string
parallelType?: string
collection?: any
whileCondition?: string
doWhileCondition?: string
type?: string
[key: string]: any
}
export interface SubBlockState {
id: string
type: string
value: any
}
export type BlockOutput = any
export interface BlockState {
id: string
type: string
name: string
position: Position
subBlocks: Record<string, SubBlockState>
outputs: Record<string, BlockOutput>
enabled: boolean
horizontalHandles?: boolean
height?: number
advancedMode?: boolean
triggerMode?: boolean
data?: BlockData
layout?: Record<string, any>
[key: string]: any
}
export interface Edge {
id: string
source: string
target: string
sourceHandle?: string | null
targetHandle?: string | null
type?: string
data?: Record<string, any>
[key: string]: any
}
export interface Loop {
id: string
nodes: string[]
iterations: number
loopType: string
forEachItems?: any
whileCondition?: string
doWhileCondition?: string
[key: string]: any
}
export interface Parallel {
id: string
nodes: string[]
distribution?: any
count?: number
parallelType?: string
[key: string]: any
}
export interface WorkflowState {
blocks: Record<string, BlockState>
edges: Edge[]
loops: Record<string, Loop>
parallels: Record<string, Parallel>
lastSaved?: number
lastUpdate?: number
isDeployed?: boolean
deployedAt?: Date
needsRedeployment?: boolean
variables?: any[]
[key: string]: any
}
export interface ExecutionContext {
workflowId: string
executionId?: string
blockStates: Map<string, any>
executedBlocks: Set<string>
blockLogs: any[]
metadata: {
duration: number
startTime?: string
endTime?: string
}
environmentVariables: Record<string, string>
workflowVariables?: Record<string, any>
decisions: {
router: Map<string, any>
condition: Map<string, any>
}
loopExecutions: Map<string, any>
completedLoops: Set<string>
activeExecutionPath: Set<string>
abortSignal?: AbortSignal
[key: string]: any
}
export interface User {
id: string
email: string
name?: string
image?: string
[key: string]: any
}
export interface Workspace {
id: string
name: string
ownerId: string
createdAt: Date
updatedAt: Date
[key: string]: any
}
export interface Workflow {
id: string
name: string
workspaceId: string
state: WorkflowState
createdAt: Date
updatedAt: Date
isDeployed?: boolean
[key: string]: any
}