chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,859 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetBlock } = vi.hoisted(() => ({
|
||||
mockGetBlock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/subblocks/visibility', () => ({
|
||||
isNonEmptyValue: (v: unknown) => v !== null && v !== undefined && v !== '',
|
||||
}))
|
||||
|
||||
vi.mock('@/triggers/constants', () => ({
|
||||
SYSTEM_SUBBLOCK_IDS: [],
|
||||
TRIGGER_RUNTIME_SUBBLOCK_IDS: [],
|
||||
}))
|
||||
|
||||
vi.mock('@/blocks/types', () => ({
|
||||
SELECTOR_TYPES_HYDRATION_REQUIRED: [],
|
||||
}))
|
||||
|
||||
vi.mock('@/executor/constants', () => ({
|
||||
isUuid: (v: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v),
|
||||
}))
|
||||
|
||||
vi.mock('@/blocks/registry', () => ({
|
||||
getBlock: mockGetBlock,
|
||||
getAllBlocks: () => ({}),
|
||||
getAllBlockTypes: () => [],
|
||||
registry: {},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/subblocks/context', () => ({
|
||||
buildSelectorContextFromBlock: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({
|
||||
fetchOAuthCredentialDetail: vi.fn(() => []),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/selectors/registry', () => ({
|
||||
getSelectorDefinition: vi.fn(() => ({ fetchList: vi.fn(() => []) })),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/selectors/resolution', () => ({
|
||||
resolveSelectorForSubBlock: vi.fn(),
|
||||
}))
|
||||
|
||||
import { WorkflowBuilder } from '@sim/testing'
|
||||
import type { WorkflowDiffSummary } from '@/lib/workflows/comparison/compare'
|
||||
import {
|
||||
formatDiffSummaryForDescription,
|
||||
formatDiffSummaryForDescriptionAsync,
|
||||
generateWorkflowDiffSummary,
|
||||
} from '@/lib/workflows/comparison/compare'
|
||||
import { formatValueForDisplay, resolveFieldLabel } from '@/lib/workflows/comparison/resolve-values'
|
||||
|
||||
function emptyDiffSummary(overrides: Partial<WorkflowDiffSummary> = {}): WorkflowDiffSummary {
|
||||
return {
|
||||
addedBlocks: [],
|
||||
removedBlocks: [],
|
||||
modifiedBlocks: [],
|
||||
edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] },
|
||||
loopChanges: { added: 0, removed: 0, modified: 0 },
|
||||
parallelChanges: { added: 0, removed: 0, modified: 0 },
|
||||
variableChanges: {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
modified: 0,
|
||||
addedNames: [],
|
||||
removedNames: [],
|
||||
modifiedNames: [],
|
||||
},
|
||||
hasChanges: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('resolveFieldLabel', () => {
|
||||
it('resolves subBlock id to its title', () => {
|
||||
mockGetBlock.mockReturnValue({
|
||||
subBlocks: [
|
||||
{ id: 'systemPrompt', title: 'System Prompt' },
|
||||
{ id: 'model', title: 'Model' },
|
||||
],
|
||||
})
|
||||
expect(resolveFieldLabel('agent', 'systemPrompt')).toBe('System Prompt')
|
||||
expect(resolveFieldLabel('agent', 'model')).toBe('Model')
|
||||
})
|
||||
|
||||
it('falls back to raw id when block not found', () => {
|
||||
mockGetBlock.mockReturnValue(null)
|
||||
expect(resolveFieldLabel('unknown_type', 'someField')).toBe('someField')
|
||||
})
|
||||
|
||||
it('falls back to raw id when subBlock not found', () => {
|
||||
mockGetBlock.mockReturnValue({ subBlocks: [{ id: 'other', title: 'Other' }] })
|
||||
expect(resolveFieldLabel('agent', 'missingField')).toBe('missingField')
|
||||
})
|
||||
|
||||
it('converts data.* fields to Title Case', () => {
|
||||
expect(resolveFieldLabel('agent', 'data.loopType')).toBe('Loop Type')
|
||||
expect(resolveFieldLabel('agent', 'data.canonicalModes')).toBe('Canonical Modes')
|
||||
expect(resolveFieldLabel('agent', 'data.isStarter')).toBe('Is Starter')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatValueForDisplay', () => {
|
||||
it('handles null/undefined', () => {
|
||||
expect(formatValueForDisplay(null)).toBe('(none)')
|
||||
expect(formatValueForDisplay(undefined)).toBe('(none)')
|
||||
})
|
||||
|
||||
it('handles booleans', () => {
|
||||
expect(formatValueForDisplay(true)).toBe('enabled')
|
||||
expect(formatValueForDisplay(false)).toBe('disabled')
|
||||
})
|
||||
|
||||
it('truncates long strings', () => {
|
||||
const longStr = 'a'.repeat(60)
|
||||
expect(formatValueForDisplay(longStr)).toBe(`${'a'.repeat(50)}...`)
|
||||
})
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(formatValueForDisplay('')).toBe('(empty)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDiffSummaryForDescription', () => {
|
||||
it('returns no-changes message for empty diff', () => {
|
||||
const result = formatDiffSummaryForDescription(emptyDiffSummary())
|
||||
expect(result).toBe('No structural changes detected (configuration may have changed)')
|
||||
})
|
||||
|
||||
it('uses human-readable field labels for modified blocks', () => {
|
||||
mockGetBlock.mockReturnValue({
|
||||
subBlocks: [
|
||||
{ id: 'systemPrompt', title: 'System Prompt' },
|
||||
{ id: 'model', title: 'Model' },
|
||||
],
|
||||
})
|
||||
|
||||
const summary = emptyDiffSummary({
|
||||
hasChanges: true,
|
||||
modifiedBlocks: [
|
||||
{
|
||||
id: 'block-1',
|
||||
type: 'agent',
|
||||
name: 'My Agent',
|
||||
changes: [
|
||||
{ field: 'systemPrompt', oldValue: 'You are helpful', newValue: 'You are an expert' },
|
||||
{ field: 'model', oldValue: 'gpt-4o', newValue: 'claude-sonnet-4-5' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
expect(result).toContain(
|
||||
'Modified My Agent: System Prompt changed from "You are helpful" to "You are an expert"'
|
||||
)
|
||||
expect(result).toContain(
|
||||
'Modified My Agent: Model changed from "gpt-4o" to "claude-sonnet-4-5"'
|
||||
)
|
||||
expect(result).not.toContain('systemPrompt')
|
||||
expect(result).not.toContain('model changed')
|
||||
})
|
||||
|
||||
it('filters out .properties changes', () => {
|
||||
mockGetBlock.mockReturnValue({ subBlocks: [] })
|
||||
|
||||
const summary = emptyDiffSummary({
|
||||
hasChanges: true,
|
||||
modifiedBlocks: [
|
||||
{
|
||||
id: 'block-1',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
changes: [
|
||||
{ field: 'systemPrompt', oldValue: 'old', newValue: 'new' },
|
||||
{
|
||||
field: 'systemPrompt.properties',
|
||||
oldValue: { some: 'meta' },
|
||||
newValue: { some: 'other' },
|
||||
},
|
||||
{ field: 'model.properties', oldValue: {}, newValue: { x: 1 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
expect(result).toContain('systemPrompt changed')
|
||||
expect(result).not.toContain('.properties')
|
||||
expect(result).not.toContain('model.properties')
|
||||
})
|
||||
|
||||
it('respects MAX_CHANGES_PER_BLOCK limit of 6', () => {
|
||||
mockGetBlock.mockReturnValue({ subBlocks: [] })
|
||||
|
||||
const changes = Array.from({ length: 8 }, (_, i) => ({
|
||||
field: `field${i}`,
|
||||
oldValue: `old${i}`,
|
||||
newValue: `new${i}`,
|
||||
}))
|
||||
|
||||
const summary = emptyDiffSummary({
|
||||
hasChanges: true,
|
||||
modifiedBlocks: [{ id: 'b1', type: 'agent', name: 'Agent', changes }],
|
||||
})
|
||||
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
const lines = result.split('\n')
|
||||
const modifiedLines = lines.filter((l) => l.startsWith('Modified'))
|
||||
expect(modifiedLines).toHaveLength(6)
|
||||
expect(result).toContain('...and 2 more changes in Agent')
|
||||
})
|
||||
|
||||
it('shows edge changes with block names', () => {
|
||||
const summary = emptyDiffSummary({
|
||||
hasChanges: true,
|
||||
edgeChanges: {
|
||||
added: 2,
|
||||
removed: 1,
|
||||
addedDetails: [
|
||||
{ sourceName: 'My Agent', targetName: 'Slack' },
|
||||
{ sourceName: 'Router', targetName: 'Gmail' },
|
||||
],
|
||||
removedDetails: [{ sourceName: 'Function', targetName: 'Webhook' }],
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
expect(result).toContain('Added connection: My Agent -> Slack')
|
||||
expect(result).toContain('Added connection: Router -> Gmail')
|
||||
expect(result).toContain('Removed connection: Function -> Webhook')
|
||||
})
|
||||
|
||||
it('truncates edge details beyond MAX_EDGE_DETAILS', () => {
|
||||
const summary = emptyDiffSummary({
|
||||
hasChanges: true,
|
||||
edgeChanges: {
|
||||
added: 5,
|
||||
removed: 0,
|
||||
addedDetails: [
|
||||
{ sourceName: 'A', targetName: 'B' },
|
||||
{ sourceName: 'C', targetName: 'D' },
|
||||
{ sourceName: 'E', targetName: 'F' },
|
||||
{ sourceName: 'G', targetName: 'H' },
|
||||
{ sourceName: 'I', targetName: 'J' },
|
||||
],
|
||||
removedDetails: [],
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
const connectionLines = result.split('\n').filter((l) => l.startsWith('Added connection'))
|
||||
expect(connectionLines).toHaveLength(3)
|
||||
expect(result).toContain('...and 2 more added connection(s)')
|
||||
})
|
||||
|
||||
it('shows variable changes with names', () => {
|
||||
const summary = emptyDiffSummary({
|
||||
hasChanges: true,
|
||||
variableChanges: {
|
||||
added: 2,
|
||||
removed: 1,
|
||||
modified: 1,
|
||||
addedNames: ['counter', 'apiKey'],
|
||||
removedNames: ['oldVar'],
|
||||
modifiedNames: ['threshold'],
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
expect(result).toContain(
|
||||
'Variables: added "counter", "apiKey", removed "oldVar", modified "threshold"'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles data.* fields with Title Case labels', () => {
|
||||
mockGetBlock.mockReturnValue({ subBlocks: [] })
|
||||
|
||||
const summary = emptyDiffSummary({
|
||||
hasChanges: true,
|
||||
modifiedBlocks: [
|
||||
{
|
||||
id: 'b1',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
changes: [
|
||||
{ field: 'data.loopType', oldValue: 'for', newValue: 'forEach' },
|
||||
{ field: 'data.isStarter', oldValue: true, newValue: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
expect(result).toContain('Modified Agent: Loop Type changed from "for" to "forEach"')
|
||||
expect(result).toContain('Modified Agent: Is Starter changed from "enabled" to "disabled"')
|
||||
})
|
||||
|
||||
it('formats a realistic multi-block workflow change', () => {
|
||||
mockGetBlock.mockImplementation((type: string) => {
|
||||
if (type === 'agent') {
|
||||
return {
|
||||
subBlocks: [
|
||||
{ id: 'systemPrompt', title: 'System Prompt' },
|
||||
{ id: 'model', title: 'Model' },
|
||||
{ id: 'temperature', title: 'Temperature' },
|
||||
],
|
||||
}
|
||||
}
|
||||
if (type === 'slack') {
|
||||
return {
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ id: 'slack_send_message', label: 'Send Message' },
|
||||
{ id: 'slack_list_channels', label: 'List Channels' },
|
||||
],
|
||||
},
|
||||
{ id: 'channel', title: 'Channel' },
|
||||
{ id: 'credential', title: 'Slack Account' },
|
||||
],
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const summary = emptyDiffSummary({
|
||||
hasChanges: true,
|
||||
addedBlocks: [{ id: 'b3', type: 'gmail', name: 'Gmail Notifications' }],
|
||||
removedBlocks: [{ id: 'b4', type: 'function', name: 'Legacy Transform' }],
|
||||
modifiedBlocks: [
|
||||
{
|
||||
id: 'b1',
|
||||
type: 'agent',
|
||||
name: 'AI Assistant',
|
||||
changes: [
|
||||
{ field: 'model', oldValue: 'gpt-4o', newValue: 'claude-sonnet-4-5' },
|
||||
{ field: 'temperature', oldValue: '0.7', newValue: '0.3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'b2',
|
||||
type: 'slack',
|
||||
name: 'Slack Alert',
|
||||
changes: [{ field: 'channel', oldValue: '#general', newValue: '#alerts' }],
|
||||
},
|
||||
],
|
||||
edgeChanges: {
|
||||
added: 1,
|
||||
removed: 0,
|
||||
addedDetails: [{ sourceName: 'AI Assistant', targetName: 'Gmail Notifications' }],
|
||||
removedDetails: [],
|
||||
},
|
||||
variableChanges: {
|
||||
added: 1,
|
||||
removed: 0,
|
||||
modified: 0,
|
||||
addedNames: ['errorCount'],
|
||||
removedNames: [],
|
||||
modifiedNames: [],
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(result).toContain('Added block: Gmail Notifications (gmail)')
|
||||
expect(result).toContain('Removed block: Legacy Transform (function)')
|
||||
expect(result).toContain(
|
||||
'Modified AI Assistant: Model changed from "gpt-4o" to "claude-sonnet-4-5"'
|
||||
)
|
||||
expect(result).toContain('Modified AI Assistant: Temperature changed from "0.7" to "0.3"')
|
||||
expect(result).toContain('Modified Slack Alert: Channel changed from "#general" to "#alerts"')
|
||||
expect(result).toContain('Added connection: AI Assistant -> Gmail Notifications')
|
||||
expect(result).toContain('Variables: added "errorCount"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDiffSummaryForDescriptionAsync', () => {
|
||||
it('resolves dropdown values to labels', async () => {
|
||||
mockGetBlock.mockReturnValue({
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ id: 'calendly_get_current_user', label: 'Get Current User' },
|
||||
{ id: 'calendly_list_event_types', label: 'List Event Types' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const summary = emptyDiffSummary({
|
||||
hasChanges: true,
|
||||
modifiedBlocks: [
|
||||
{
|
||||
id: 'b1',
|
||||
type: 'calendly',
|
||||
name: 'Calendly',
|
||||
changes: [
|
||||
{
|
||||
field: 'operation',
|
||||
oldValue: 'calendly_get_current_user',
|
||||
newValue: 'calendly_list_event_types',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const mockState = { blocks: {} } as any
|
||||
const result = await formatDiffSummaryForDescriptionAsync(summary, mockState, 'wf-1')
|
||||
expect(result).toContain(
|
||||
'Modified Calendly: Operation changed from "Get Current User" to "List Event Types"'
|
||||
)
|
||||
expect(result).not.toContain('calendly_get_current_user')
|
||||
})
|
||||
|
||||
it('uses field titles in async path', async () => {
|
||||
mockGetBlock.mockReturnValue({
|
||||
subBlocks: [{ id: 'systemPrompt', title: 'System Prompt' }],
|
||||
})
|
||||
|
||||
const summary = emptyDiffSummary({
|
||||
hasChanges: true,
|
||||
modifiedBlocks: [
|
||||
{
|
||||
id: 'b1',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
changes: [{ field: 'systemPrompt', oldValue: 'Be helpful', newValue: 'Be concise' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const mockState = { blocks: {} } as any
|
||||
const result = await formatDiffSummaryForDescriptionAsync(summary, mockState, 'wf-1')
|
||||
expect(result).toContain('System Prompt')
|
||||
expect(result).not.toContain('systemPrompt')
|
||||
})
|
||||
|
||||
it('filters .properties changes in async path', async () => {
|
||||
mockGetBlock.mockReturnValue({ subBlocks: [] })
|
||||
|
||||
const summary = emptyDiffSummary({
|
||||
hasChanges: true,
|
||||
modifiedBlocks: [
|
||||
{
|
||||
id: 'b1',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
changes: [
|
||||
{ field: 'prompt', oldValue: 'old', newValue: 'new' },
|
||||
{ field: 'prompt.properties', oldValue: {}, newValue: { x: 1 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const mockState = { blocks: {} } as any
|
||||
const result = await formatDiffSummaryForDescriptionAsync(summary, mockState, 'wf-1')
|
||||
expect(result).not.toContain('.properties')
|
||||
})
|
||||
})
|
||||
|
||||
describe('end-to-end: generateWorkflowDiffSummary + formatDiffSummaryForDescription', () => {
|
||||
beforeEach(() => {
|
||||
mockGetBlock.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('detects added and removed blocks between two workflow versions', () => {
|
||||
const previous = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addAgent('agent-1', undefined, 'Summarizer')
|
||||
.connect('start', 'agent-1')
|
||||
.build()
|
||||
|
||||
const current = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addAgent('agent-1', undefined, 'Summarizer')
|
||||
.addFunction('func-1', undefined, 'Formatter')
|
||||
.connect('start', 'agent-1')
|
||||
.connect('agent-1', 'func-1')
|
||||
.build()
|
||||
|
||||
const summary = generateWorkflowDiffSummary(current, previous)
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(result).toContain('Added block: Formatter (function)')
|
||||
expect(result).toContain('Added connection: Summarizer -> Formatter')
|
||||
expect(result).not.toContain('Removed')
|
||||
})
|
||||
|
||||
it('detects block removal and edge removal', () => {
|
||||
const previous = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addAgent('agent-1', undefined, 'Classifier')
|
||||
.addFunction('func-1', undefined, 'Logger')
|
||||
.connect('start', 'agent-1')
|
||||
.connect('agent-1', 'func-1')
|
||||
.build()
|
||||
|
||||
const current = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addAgent('agent-1', undefined, 'Classifier')
|
||||
.connect('start', 'agent-1')
|
||||
.build()
|
||||
|
||||
const summary = generateWorkflowDiffSummary(current, previous)
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(result).toContain('Removed block: Logger (function)')
|
||||
expect(result).toContain('Removed connection: Classifier -> Logger')
|
||||
expect(result).not.toContain('Added block')
|
||||
})
|
||||
|
||||
it('detects subBlock value changes on modified blocks', () => {
|
||||
const previous = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addAgent('agent-1', undefined, 'Writer')
|
||||
.connect('start', 'agent-1')
|
||||
.build()
|
||||
previous.blocks['agent-1'].subBlocks = {
|
||||
systemPrompt: { id: 'systemPrompt', value: 'You are a helpful assistant' },
|
||||
model: { id: 'model', value: 'gpt-4o' },
|
||||
}
|
||||
|
||||
const current = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addAgent('agent-1', undefined, 'Writer')
|
||||
.connect('start', 'agent-1')
|
||||
.build()
|
||||
current.blocks['agent-1'].subBlocks = {
|
||||
systemPrompt: { id: 'systemPrompt', value: 'You are a concise writer' },
|
||||
model: { id: 'model', value: 'claude-sonnet-4-5' },
|
||||
}
|
||||
|
||||
mockGetBlock.mockReturnValue({
|
||||
subBlocks: [
|
||||
{ id: 'systemPrompt', title: 'System Prompt' },
|
||||
{ id: 'model', title: 'Model' },
|
||||
],
|
||||
})
|
||||
|
||||
const summary = generateWorkflowDiffSummary(current, previous)
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(result).toContain(
|
||||
'Modified Writer: System Prompt changed from "You are a helpful assistant" to "You are a concise writer"'
|
||||
)
|
||||
expect(result).toContain('Modified Writer: Model changed from "gpt-4o" to "claude-sonnet-4-5"')
|
||||
})
|
||||
|
||||
it('detects loop addition with correct count', () => {
|
||||
const previous = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addFunction('func-1', undefined, 'Process')
|
||||
.connect('start', 'func-1')
|
||||
.build()
|
||||
|
||||
const current = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addFunction('func-1', undefined, 'Process')
|
||||
.addLoop('loop-1', undefined, { iterations: 5, loopType: 'for' })
|
||||
.addLoopChild('loop-1', 'loop-body', 'function')
|
||||
.connect('start', 'func-1')
|
||||
.connect('func-1', 'loop-1')
|
||||
.build()
|
||||
|
||||
const summary = generateWorkflowDiffSummary(current, previous)
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(result).toContain('Added block: Loop (loop)')
|
||||
expect(result).toContain('Added block: loop-body (function)')
|
||||
expect(result).toContain('Added 1 loop(s)')
|
||||
expect(result).toContain('Added connection: Process -> Loop')
|
||||
})
|
||||
|
||||
it('detects loop removal', () => {
|
||||
const previous = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addLoop('loop-1', undefined, { iterations: 3, loopType: 'for' })
|
||||
.addLoopChild('loop-1', 'loop-body', 'agent')
|
||||
.connect('start', 'loop-1')
|
||||
.build()
|
||||
|
||||
const current = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addAgent('agent-1', undefined, 'Direct Agent')
|
||||
.connect('start', 'agent-1')
|
||||
.build()
|
||||
|
||||
const summary = generateWorkflowDiffSummary(current, previous)
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(result).toContain('Removed block: Loop (loop)')
|
||||
expect(result).toContain('Removed 1 loop(s)')
|
||||
expect(result).toContain('Added block: Direct Agent (agent)')
|
||||
})
|
||||
|
||||
it('detects loop modification when iterations change', () => {
|
||||
const previous = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addLoop('loop-1', undefined, { iterations: 3, loopType: 'for' })
|
||||
.addLoopChild('loop-1', 'loop-body', 'function')
|
||||
.connect('start', 'loop-1')
|
||||
.build()
|
||||
|
||||
const current = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addLoop('loop-1', undefined, { iterations: 10, loopType: 'for' })
|
||||
.addLoopChild('loop-1', 'loop-body', 'function')
|
||||
.connect('start', 'loop-1')
|
||||
.build()
|
||||
|
||||
const summary = generateWorkflowDiffSummary(current, previous)
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(result).toContain('Modified 1 loop(s)')
|
||||
})
|
||||
|
||||
it('detects parallel addition', () => {
|
||||
const previous = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addFunction('func-1', undefined, 'Sequencer')
|
||||
.connect('start', 'func-1')
|
||||
.build()
|
||||
|
||||
const current = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addParallel('par-1', undefined, { count: 3, parallelType: 'count' })
|
||||
.addParallelChild('par-1', 'par-task-1', 'agent')
|
||||
.addParallelChild('par-1', 'par-task-2', 'function')
|
||||
.connect('start', 'par-1')
|
||||
.build()
|
||||
|
||||
const summary = generateWorkflowDiffSummary(current, previous)
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(result).toContain('Added block: Parallel (parallel)')
|
||||
expect(result).toContain('Added 1 parallel group(s)')
|
||||
expect(result).toContain('Removed block: Sequencer (function)')
|
||||
})
|
||||
|
||||
it('detects parallel removal', () => {
|
||||
const previous = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addParallel('par-1', undefined, { count: 2 })
|
||||
.addParallelChild('par-1', 'par-task', 'function')
|
||||
.connect('start', 'par-1')
|
||||
.build()
|
||||
|
||||
const current = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addFunction('func-1', undefined, 'Simple Step')
|
||||
.connect('start', 'func-1')
|
||||
.build()
|
||||
|
||||
const summary = generateWorkflowDiffSummary(current, previous)
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(result).toContain('Removed block: Parallel (parallel)')
|
||||
expect(result).toContain('Removed 1 parallel group(s)')
|
||||
expect(result).toContain('Added block: Simple Step (function)')
|
||||
})
|
||||
|
||||
it('detects parallel modification when count changes', () => {
|
||||
const previous = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addParallel('par-1', undefined, { count: 2, parallelType: 'count' })
|
||||
.addParallelChild('par-1', 'par-task', 'function')
|
||||
.connect('start', 'par-1')
|
||||
.build()
|
||||
|
||||
const current = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addParallel('par-1', undefined, { count: 5, parallelType: 'count' })
|
||||
.addParallelChild('par-1', 'par-task', 'function')
|
||||
.connect('start', 'par-1')
|
||||
.build()
|
||||
|
||||
const summary = generateWorkflowDiffSummary(current, previous)
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(result).toContain('Modified 1 parallel group(s)')
|
||||
})
|
||||
|
||||
it('detects variable additions and removals with names', () => {
|
||||
const previous = new WorkflowBuilder().addStarter('start').build()
|
||||
previous.variables = {
|
||||
v1: { id: 'v1', name: 'retryCount', type: 'number', value: 3 },
|
||||
v2: { id: 'v2', name: 'apiEndpoint', type: 'string', value: 'https://api.example.com' },
|
||||
}
|
||||
|
||||
const current = new WorkflowBuilder().addStarter('start').build()
|
||||
current.variables = {
|
||||
v1: { id: 'v1', name: 'retryCount', type: 'number', value: 5 },
|
||||
v3: { id: 'v3', name: 'timeout', type: 'number', value: 30 },
|
||||
}
|
||||
|
||||
const summary = generateWorkflowDiffSummary(current, previous)
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(result).toContain('Variables:')
|
||||
expect(result).toContain('added "timeout"')
|
||||
expect(result).toContain('removed "apiEndpoint"')
|
||||
expect(result).toContain('modified "retryCount"')
|
||||
})
|
||||
|
||||
it('produces no-change message for identical workflows', () => {
|
||||
const workflow = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addAgent('agent-1', undefined, 'Agent')
|
||||
.connect('start', 'agent-1')
|
||||
.build()
|
||||
|
||||
const summary = generateWorkflowDiffSummary(workflow, workflow)
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(result).toBe('No structural changes detected (configuration may have changed)')
|
||||
})
|
||||
|
||||
it('handles complex scenario: loop replaced with parallel + new connections + variables', () => {
|
||||
const previous = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addLoop('loop-1', undefined, { iterations: 5 })
|
||||
.addLoopChild('loop-1', 'loop-task', 'agent')
|
||||
.addFunction('sink', undefined, 'Output')
|
||||
.connect('start', 'loop-1')
|
||||
.connect('loop-1', 'sink')
|
||||
.build()
|
||||
previous.variables = {
|
||||
v1: { id: 'v1', name: 'batchSize', type: 'number', value: 10 },
|
||||
}
|
||||
|
||||
const current = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addParallel('par-1', undefined, { count: 3 })
|
||||
.addParallelChild('par-1', 'par-task', 'agent')
|
||||
.addFunction('sink', undefined, 'Output')
|
||||
.addAgent('agg', undefined, 'Aggregator')
|
||||
.connect('start', 'par-1')
|
||||
.connect('par-1', 'agg')
|
||||
.connect('agg', 'sink')
|
||||
.build()
|
||||
current.variables = {
|
||||
v1: { id: 'v1', name: 'batchSize', type: 'number', value: 25 },
|
||||
v2: { id: 'v2', name: 'concurrency', type: 'number', value: 3 },
|
||||
}
|
||||
|
||||
const summary = generateWorkflowDiffSummary(current, previous)
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(result).toContain('Added block: Parallel (parallel)')
|
||||
expect(result).toContain('Added block: Aggregator (agent)')
|
||||
expect(result).toContain('Removed block: Loop (loop)')
|
||||
expect(result).toContain('Added 1 parallel group(s)')
|
||||
expect(result).toContain('Removed 1 loop(s)')
|
||||
expect(result).toContain('added "concurrency"')
|
||||
expect(result).toContain('modified "batchSize"')
|
||||
|
||||
const lines = result.split('\n')
|
||||
expect(lines.length).toBeGreaterThanOrEqual(7)
|
||||
})
|
||||
|
||||
it('detects edge rewiring without block changes', () => {
|
||||
const previous = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addAgent('a', undefined, 'Agent A')
|
||||
.addAgent('b', undefined, 'Agent B')
|
||||
.addFunction('sink', undefined, 'Output')
|
||||
.connect('start', 'a')
|
||||
.connect('a', 'sink')
|
||||
.connect('start', 'b')
|
||||
.connect('b', 'sink')
|
||||
.build()
|
||||
|
||||
const current = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addAgent('a', undefined, 'Agent A')
|
||||
.addAgent('b', undefined, 'Agent B')
|
||||
.addFunction('sink', undefined, 'Output')
|
||||
.connect('start', 'a')
|
||||
.connect('a', 'b')
|
||||
.connect('b', 'sink')
|
||||
.build()
|
||||
|
||||
const summary = generateWorkflowDiffSummary(current, previous)
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(summary.addedBlocks).toHaveLength(0)
|
||||
expect(summary.removedBlocks).toHaveLength(0)
|
||||
expect(result).toContain('Added connection: Agent A -> Agent B')
|
||||
expect(result).toContain('Removed connection:')
|
||||
expect(result).not.toContain('Added block')
|
||||
expect(result).not.toContain('Removed block')
|
||||
})
|
||||
|
||||
it('detects data field changes with human-readable labels', () => {
|
||||
const previous = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addBlock('custom-1', 'function', undefined, 'Processor')
|
||||
.connect('start', 'custom-1')
|
||||
.build()
|
||||
previous.blocks['custom-1'].data = { isStarter: true, retryPolicy: 'linear' }
|
||||
|
||||
const current = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addBlock('custom-1', 'function', undefined, 'Processor')
|
||||
.connect('start', 'custom-1')
|
||||
.build()
|
||||
current.blocks['custom-1'].data = { isStarter: false, retryPolicy: 'exponential' }
|
||||
|
||||
const summary = generateWorkflowDiffSummary(current, previous)
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(result).toContain('Is Starter')
|
||||
expect(result).toContain('Retry Policy')
|
||||
expect(result).toContain('enabled')
|
||||
expect(result).toContain('disabled')
|
||||
expect(result).toContain('linear')
|
||||
expect(result).toContain('exponential')
|
||||
})
|
||||
|
||||
it('detects loop type change via loop config modification', () => {
|
||||
const previous = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addLoop('loop-1', undefined, { iterations: 3, loopType: 'for' })
|
||||
.addLoopChild('loop-1', 'loop-body', 'function')
|
||||
.connect('start', 'loop-1')
|
||||
.build()
|
||||
|
||||
const current = new WorkflowBuilder()
|
||||
.addStarter('start')
|
||||
.addLoop('loop-1', undefined, { iterations: 3, loopType: 'forEach' })
|
||||
.addLoopChild('loop-1', 'loop-body', 'function')
|
||||
.connect('start', 'loop-1')
|
||||
.build()
|
||||
|
||||
const summary = generateWorkflowDiffSummary(current, previous)
|
||||
const result = formatDiffSummaryForDescription(summary)
|
||||
|
||||
expect(result).toContain('Modified 1 loop(s)')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user