Files
simstudioai--sim/apps/sim/lib/workflows/orchestration/deploy.test.ts
T
wehub-resource-sync d25d482dc2
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

361 lines
11 KiB
TypeScript

/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockLimit,
mockUpdateSet,
mockSaveWorkflowToNormalizedTables,
mockRecordAudit,
mockCaptureServerEvent,
mockTransaction,
mockDeployWorkflow,
mockActivateWorkflowVersion,
mockValidateWorkflowSchedules,
mockValidateTriggerWebhookConfigForDeploy,
mockEmitWorkflowDeployedEvent,
mockTx,
} = vi.hoisted(() => ({
mockLimit: vi.fn(),
mockUpdateSet: vi.fn(),
mockSaveWorkflowToNormalizedTables: vi.fn(),
mockRecordAudit: vi.fn(),
mockCaptureServerEvent: vi.fn(),
mockTransaction: vi.fn(),
mockDeployWorkflow: vi.fn(),
mockActivateWorkflowVersion: vi.fn(),
mockValidateWorkflowSchedules: vi.fn(),
mockValidateTriggerWebhookConfigForDeploy: vi.fn(),
mockEmitWorkflowDeployedEvent: vi.fn(),
mockTx: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn(() => ({
for: vi.fn().mockResolvedValue([{ id: 'workflow-1' }]),
})),
})),
})),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
})),
execute: vi.fn().mockResolvedValue(undefined),
},
}))
vi.mock('@sim/db', () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: mockLimit,
})),
})),
})),
update: vi.fn(() => ({
set: mockUpdateSet,
})),
transaction: mockTransaction,
},
workflow: { id: 'workflow.id' },
workflowDeploymentVersion: {
workflowId: 'workflowDeploymentVersion.workflowId',
version: 'workflowDeploymentVersion.version',
isActive: 'workflowDeploymentVersion.isActive',
state: 'workflowDeploymentVersion.state',
},
}))
vi.mock('@sim/audit', () => ({
AuditAction: {
WORKFLOW_DEPLOYMENT_REVERTED: 'WORKFLOW_DEPLOYMENT_REVERTED',
WORKFLOW_DEPLOYED: 'WORKFLOW_DEPLOYED',
WORKFLOW_UNDEPLOYED: 'WORKFLOW_UNDEPLOYED',
WORKFLOW_DEPLOYMENT_ACTIVATED: 'WORKFLOW_DEPLOYMENT_ACTIVATED',
},
AuditResourceType: { WORKFLOW: 'WORKFLOW' },
recordAudit: mockRecordAudit,
}))
vi.mock('@/lib/workflows/deployment-outbox', () => ({
enqueueWorkflowDeploymentSideEffects: vi.fn().mockResolvedValue('outbox-1'),
enqueueWorkflowUndeploySideEffects: vi.fn().mockResolvedValue('outbox-2'),
processWorkflowDeploymentOutboxEvent: vi.fn().mockResolvedValue('completed'),
}))
vi.mock('@/lib/workspace-events/emitter', () => ({
emitWorkflowDeployedEvent: mockEmitWorkflowDeployedEvent,
}))
vi.mock('@/lib/core/config/env', () => ({
env: { INTERNAL_API_SECRET: 'secret' },
}))
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: () => 'http://localhost:3000',
getSocketServerUrl: () => 'http://localhost:3002',
}))
vi.mock('@/lib/posthog/server', () => ({
captureServerEvent: mockCaptureServerEvent,
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
activateWorkflowVersion: mockActivateWorkflowVersion,
activateWorkflowVersionById: vi.fn(),
deployWorkflow: mockDeployWorkflow,
loadWorkflowDeploymentSnapshot: vi.fn(),
saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables,
undeployWorkflow: vi.fn(),
}))
vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
removeMcpToolsForWorkflow: vi.fn(),
syncMcpToolsForWorkflow: vi.fn(),
}))
vi.mock('@/lib/webhooks/deploy', () => ({
cleanupWebhooksForWorkflow: vi.fn(),
restorePreviousVersionWebhooks: vi.fn(),
saveTriggerWebhooksForDeploy: vi.fn(),
validateTriggerWebhookConfigForDeploy: mockValidateTriggerWebhookConfigForDeploy,
}))
vi.mock('@/lib/workflows/schedules', () => ({
cleanupDeploymentVersion: vi.fn(),
createSchedulesForDeploy: vi.fn(),
validateWorkflowSchedules: mockValidateWorkflowSchedules,
}))
import {
performActivateVersion,
performFullDeploy,
performRevertToVersion,
} from '@/lib/workflows/orchestration/deploy'
describe('performRevertToVersion', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
mockTransaction.mockImplementation(async (callback) => callback(mockTx))
mockTx.select.mockImplementation((selection?: Record<string, unknown>) => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit:
selection && Object.hasOwn(selection, 'state')
? mockLimit
: vi.fn(() => ({
for: vi.fn().mockResolvedValue([{ id: 'workflow-1' }]),
})),
})),
})),
}))
mockTx.update.mockReturnValue({ set: mockUpdateSet })
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true })
})
it('restores variables when the deployment snapshot includes them', async () => {
mockLimit.mockResolvedValue([
{
state: {
blocks: {},
edges: [],
loops: {},
parallels: {},
variables: {
variableA: {
id: 'variableA',
name: 'API_KEY',
type: 'plain',
value: 'deployed-value',
},
},
},
},
])
const result = await performRevertToVersion({
workflowId: 'workflow-1',
version: 3,
userId: 'user-1',
workflow: { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' },
})
expect(result.success).toBe(true)
expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledWith(
'workflow-1',
expect.objectContaining({
variables: {
variableA: {
id: 'variableA',
name: 'API_KEY',
type: 'plain',
value: 'deployed-value',
},
},
}),
mockTx
)
expect(mockUpdateSet).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
variableA: {
id: 'variableA',
name: 'API_KEY',
type: 'plain',
value: 'deployed-value',
},
},
})
)
})
it('preserves existing variables when reverting a legacy snapshot without variables', async () => {
mockLimit.mockResolvedValue([
{
state: {
blocks: {},
edges: [],
loops: {},
parallels: {},
},
},
])
const result = await performRevertToVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
workflow: { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' },
})
expect(result.success).toBe(true)
const savedState = mockSaveWorkflowToNormalizedTables.mock.calls[0][1]
expect(Object.hasOwn(savedState, 'variables')).toBe(false)
const workflowUpdate = mockUpdateSet.mock.calls[0][0]
expect(Object.hasOwn(workflowUpdate, 'variables')).toBe(false)
})
})
describe('performFullDeploy workspace event emission', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
mockLimit.mockResolvedValue([
{ id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
])
mockDeployWorkflow.mockResolvedValue({
success: true,
deployedAt: new Date(),
version: 4,
deploymentVersionId: 'dv-1',
previousVersionId: null,
currentState: { blocks: {} },
})
})
it('emits workflow_deployed after a successful deploy', async () => {
const result = await performFullDeploy({
workflowId: 'workflow-1',
userId: 'user-1',
})
expect(result.success).toBe(true)
expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledTimes(1)
expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledWith({
workflowId: 'workflow-1',
workflowName: 'My Workflow',
workspaceId: 'workspace-1',
version: 4,
})
})
it('does not emit when the deploy fails', async () => {
mockDeployWorkflow.mockResolvedValueOnce({ success: false, error: 'nope' })
const result = await performFullDeploy({
workflowId: 'workflow-1',
userId: 'user-1',
})
expect(result.success).toBe(false)
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
it('emission rejection does not fail the deploy', async () => {
mockEmitWorkflowDeployedEvent.mockRejectedValueOnce(new Error('emit failed'))
const result = await performFullDeploy({
workflowId: 'workflow-1',
userId: 'user-1',
})
expect(result.success).toBe(true)
})
})
describe('performActivateVersion workspace event emission', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
mockValidateWorkflowSchedules.mockReturnValue({ isValid: true })
mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true })
mockLimit.mockResolvedValue([{ id: 'dv-2', state: { blocks: {} }, isActive: false }])
mockActivateWorkflowVersion.mockResolvedValue({
success: true,
deployedAt: new Date(),
previousVersionId: 'dv-1',
})
})
it('emits workflow_deployed when activating a version (rollback/activation)', async () => {
const result = await performActivateVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
workflow: { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
})
expect(result.success).toBe(true)
expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledWith({
workflowId: 'workflow-1',
workflowName: 'My Workflow',
workspaceId: 'workspace-1',
version: 2,
})
})
it('does not emit when the version is already active (no-op activation)', async () => {
mockLimit
.mockResolvedValueOnce([{ id: 'dv-2', state: { blocks: {} }, isActive: true }])
.mockResolvedValueOnce([{ deployedAt: new Date() }])
const result = await performActivateVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
workflow: { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
})
expect(result.success).toBe(true)
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
it('does not emit when activation fails', async () => {
mockActivateWorkflowVersion.mockResolvedValueOnce({ success: false, error: 'nope' })
const result = await performActivateVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
workflow: { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
})
expect(result.success).toBe(false)
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
})