mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(deploy): improve auto-generated version descriptions (#4075)
* improvement(deploy): improve auto-generated version descriptions * fix(deploy): address PR review - log dropdown errors, populate first-deploy details * lint
This commit is contained in:
+1
-1
@@ -89,7 +89,7 @@ export function VersionDescriptionModal({
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={(openState) => !openState && handleCloseAttempt()}>
|
||||
<ModalContent size='md'>
|
||||
<ModalContent size='lg'>
|
||||
<ModalHeader>
|
||||
<span>Version Description</span>
|
||||
</ModalHeader>
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
normalizeVariables,
|
||||
sanitizeVariable,
|
||||
} from './normalize'
|
||||
import { formatValueForDisplay, resolveValueForDisplay } from './resolve-values'
|
||||
import { formatValueForDisplay, resolveFieldLabel, resolveValueForDisplay } from './resolve-values'
|
||||
|
||||
const MAX_CHANGES_PER_BLOCK = 6
|
||||
const MAX_EDGE_DETAILS = 3
|
||||
|
||||
const logger = createLogger('WorkflowComparison')
|
||||
|
||||
@@ -45,10 +48,22 @@ export interface WorkflowDiffSummary {
|
||||
addedBlocks: Array<{ id: string; type: string; name?: string }>
|
||||
removedBlocks: Array<{ id: string; type: string; name?: string }>
|
||||
modifiedBlocks: Array<{ id: string; type: string; name?: string; changes: FieldChange[] }>
|
||||
edgeChanges: { added: number; removed: number }
|
||||
edgeChanges: {
|
||||
added: number
|
||||
removed: number
|
||||
addedDetails: Array<{ sourceName: string; targetName: string }>
|
||||
removedDetails: Array<{ sourceName: string; targetName: string }>
|
||||
}
|
||||
loopChanges: { added: number; removed: number; modified: number }
|
||||
parallelChanges: { added: number; removed: number; modified: number }
|
||||
variableChanges: { added: number; removed: number; modified: number }
|
||||
variableChanges: {
|
||||
added: number
|
||||
removed: number
|
||||
modified: number
|
||||
addedNames: string[]
|
||||
removedNames: string[]
|
||||
modifiedNames: string[]
|
||||
}
|
||||
hasChanges: boolean
|
||||
}
|
||||
|
||||
@@ -63,10 +78,17 @@ export function generateWorkflowDiffSummary(
|
||||
addedBlocks: [],
|
||||
removedBlocks: [],
|
||||
modifiedBlocks: [],
|
||||
edgeChanges: { added: 0, removed: 0 },
|
||||
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 },
|
||||
variableChanges: {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
modified: 0,
|
||||
addedNames: [],
|
||||
removedNames: [],
|
||||
modifiedNames: [],
|
||||
},
|
||||
hasChanges: false,
|
||||
}
|
||||
|
||||
@@ -79,10 +101,28 @@ export function generateWorkflowDiffSummary(
|
||||
name: block.name,
|
||||
})
|
||||
}
|
||||
result.edgeChanges.added = (currentState.edges || []).length
|
||||
|
||||
const edges = currentState.edges || []
|
||||
result.edgeChanges.added = edges.length
|
||||
for (const edge of edges) {
|
||||
const sourceBlock = currentBlocks[edge.source]
|
||||
const targetBlock = currentBlocks[edge.target]
|
||||
result.edgeChanges.addedDetails.push({
|
||||
sourceName: sourceBlock?.name || sourceBlock?.type || edge.source,
|
||||
targetName: targetBlock?.name || targetBlock?.type || edge.target,
|
||||
})
|
||||
}
|
||||
|
||||
result.loopChanges.added = Object.keys(currentState.loops || {}).length
|
||||
result.parallelChanges.added = Object.keys(currentState.parallels || {}).length
|
||||
result.variableChanges.added = Object.keys(currentState.variables || {}).length
|
||||
|
||||
const variables = currentState.variables || {}
|
||||
const varEntries = Object.entries(variables)
|
||||
result.variableChanges.added = varEntries.length
|
||||
for (const [id, variable] of varEntries) {
|
||||
result.variableChanges.addedNames.push((variable as { name?: string }).name || id)
|
||||
}
|
||||
|
||||
result.hasChanges = true
|
||||
return result
|
||||
}
|
||||
@@ -121,7 +161,6 @@ export function generateWorkflowDiffSummary(
|
||||
const previousBlock = previousBlocks[id]
|
||||
const changes: FieldChange[] = []
|
||||
|
||||
// Use shared helpers for block field extraction (single source of truth)
|
||||
const {
|
||||
blockRest: currentRest,
|
||||
normalizedData: currentDataRest,
|
||||
@@ -156,8 +195,6 @@ export function generateWorkflowDiffSummary(
|
||||
newValue: currentBlock.enabled,
|
||||
})
|
||||
}
|
||||
// Check other block properties (boolean fields)
|
||||
// Use !! to normalize: null/undefined/false are all equivalent (falsy)
|
||||
const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode', 'locked'] as const
|
||||
for (const field of blockFields) {
|
||||
if (!!currentBlock[field] !== !!previousBlock[field]) {
|
||||
@@ -169,15 +206,27 @@ export function generateWorkflowDiffSummary(
|
||||
}
|
||||
}
|
||||
if (normalizedStringify(currentDataRest) !== normalizedStringify(previousDataRest)) {
|
||||
changes.push({ field: 'data', oldValue: previousDataRest, newValue: currentDataRest })
|
||||
const allDataKeys = new Set([
|
||||
...Object.keys(currentDataRest),
|
||||
...Object.keys(previousDataRest),
|
||||
])
|
||||
for (const key of allDataKeys) {
|
||||
if (
|
||||
normalizedStringify(currentDataRest[key]) !== normalizedStringify(previousDataRest[key])
|
||||
) {
|
||||
changes.push({
|
||||
field: `data.${key}`,
|
||||
oldValue: previousDataRest[key] ?? null,
|
||||
newValue: currentDataRest[key] ?? null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize trigger config values for both states before comparison
|
||||
const normalizedCurrentSubs = normalizeTriggerConfigValues(currentSubBlocks)
|
||||
const normalizedPreviousSubs = normalizeTriggerConfigValues(previousSubBlocks)
|
||||
|
||||
// Compare subBlocks using shared helper for filtering (single source of truth)
|
||||
const allSubBlockIds = filterSubBlockIds([
|
||||
...new Set([...Object.keys(normalizedCurrentSubs), ...Object.keys(normalizedPreviousSubs)]),
|
||||
])
|
||||
@@ -195,11 +244,9 @@ export function generateWorkflowDiffSummary(
|
||||
continue
|
||||
}
|
||||
|
||||
// Use shared helper for subBlock value normalization (single source of truth)
|
||||
const currentValue = normalizeSubBlockValue(subId, currentSub.value)
|
||||
const previousValue = normalizeSubBlockValue(subId, previousSub.value)
|
||||
|
||||
// For string values, compare directly to catch even small text changes
|
||||
if (typeof currentValue === 'string' && typeof previousValue === 'string') {
|
||||
if (currentValue !== previousValue) {
|
||||
changes.push({ field: subId, oldValue: previousSub.value, newValue: currentSub.value })
|
||||
@@ -212,7 +259,6 @@ export function generateWorkflowDiffSummary(
|
||||
}
|
||||
}
|
||||
|
||||
// Use shared helper for subBlock REST extraction (single source of truth)
|
||||
const currentSubRest = extractSubBlockRest(currentSub)
|
||||
const previousSubRest = extractSubBlockRest(previousSub)
|
||||
|
||||
@@ -240,11 +286,30 @@ export function generateWorkflowDiffSummary(
|
||||
const currentEdgeSet = new Set(currentEdges.map(normalizedStringify))
|
||||
const previousEdgeSet = new Set(previousEdges.map(normalizedStringify))
|
||||
|
||||
for (const edge of currentEdgeSet) {
|
||||
if (!previousEdgeSet.has(edge)) result.edgeChanges.added++
|
||||
const resolveBlockName = (blockId: string): string => {
|
||||
const block = currentBlocks[blockId] || previousBlocks[blockId]
|
||||
return block?.name || block?.type || blockId
|
||||
}
|
||||
for (const edge of previousEdgeSet) {
|
||||
if (!currentEdgeSet.has(edge)) result.edgeChanges.removed++
|
||||
|
||||
for (const edgeStr of currentEdgeSet) {
|
||||
if (!previousEdgeSet.has(edgeStr)) {
|
||||
result.edgeChanges.added++
|
||||
const edge = JSON.parse(edgeStr) as { source: string; target: string }
|
||||
result.edgeChanges.addedDetails.push({
|
||||
sourceName: resolveBlockName(edge.source),
|
||||
targetName: resolveBlockName(edge.target),
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const edgeStr of previousEdgeSet) {
|
||||
if (!currentEdgeSet.has(edgeStr)) {
|
||||
result.edgeChanges.removed++
|
||||
const edge = JSON.parse(edgeStr) as { source: string; target: string }
|
||||
result.edgeChanges.removedDetails.push({
|
||||
sourceName: resolveBlockName(edge.source),
|
||||
targetName: resolveBlockName(edge.target),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const currentLoops = currentState.loops || {}
|
||||
@@ -296,8 +361,18 @@ export function generateWorkflowDiffSummary(
|
||||
const currentVarIds = Object.keys(currentVars)
|
||||
const previousVarIds = Object.keys(previousVars)
|
||||
|
||||
result.variableChanges.added = currentVarIds.filter((id) => !previousVarIds.includes(id)).length
|
||||
result.variableChanges.removed = previousVarIds.filter((id) => !currentVarIds.includes(id)).length
|
||||
for (const id of currentVarIds) {
|
||||
if (!previousVarIds.includes(id)) {
|
||||
result.variableChanges.added++
|
||||
result.variableChanges.addedNames.push(currentVars[id].name || id)
|
||||
}
|
||||
}
|
||||
for (const id of previousVarIds) {
|
||||
if (!currentVarIds.includes(id)) {
|
||||
result.variableChanges.removed++
|
||||
result.variableChanges.removedNames.push(previousVars[id].name || id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of currentVarIds) {
|
||||
if (!previousVarIds.includes(id)) continue
|
||||
@@ -305,6 +380,7 @@ export function generateWorkflowDiffSummary(
|
||||
const previousVar = normalizeValue(sanitizeVariable(previousVars[id]))
|
||||
if (normalizedStringify(currentVar) !== normalizedStringify(previousVar)) {
|
||||
result.variableChanges.modified++
|
||||
result.variableChanges.modifiedNames.push(currentVars[id].name || id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,56 +425,24 @@ export function formatDiffSummaryForDescription(summary: WorkflowDiffSummary): s
|
||||
|
||||
for (const block of summary.modifiedBlocks) {
|
||||
const name = block.name || block.type
|
||||
for (const change of block.changes.slice(0, 3)) {
|
||||
const meaningfulChanges = block.changes.filter((c) => !c.field.endsWith('.properties'))
|
||||
for (const change of meaningfulChanges.slice(0, MAX_CHANGES_PER_BLOCK)) {
|
||||
const fieldLabel = resolveFieldLabel(block.type, change.field)
|
||||
const oldStr = formatValueForDisplay(change.oldValue)
|
||||
const newStr = formatValueForDisplay(change.newValue)
|
||||
changes.push(`Modified ${name}: ${change.field} changed from "${oldStr}" to "${newStr}"`)
|
||||
changes.push(`Modified ${name}: ${fieldLabel} changed from "${oldStr}" to "${newStr}"`)
|
||||
}
|
||||
if (block.changes.length > 3) {
|
||||
changes.push(` ...and ${block.changes.length - 3} more changes in ${name}`)
|
||||
if (meaningfulChanges.length > MAX_CHANGES_PER_BLOCK) {
|
||||
changes.push(
|
||||
` ...and ${meaningfulChanges.length - MAX_CHANGES_PER_BLOCK} more changes in ${name}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.edgeChanges.added > 0) {
|
||||
changes.push(`Added ${summary.edgeChanges.added} connection(s)`)
|
||||
}
|
||||
if (summary.edgeChanges.removed > 0) {
|
||||
changes.push(`Removed ${summary.edgeChanges.removed} connection(s)`)
|
||||
}
|
||||
|
||||
if (summary.loopChanges.added > 0) {
|
||||
changes.push(`Added ${summary.loopChanges.added} loop(s)`)
|
||||
}
|
||||
if (summary.loopChanges.removed > 0) {
|
||||
changes.push(`Removed ${summary.loopChanges.removed} loop(s)`)
|
||||
}
|
||||
if (summary.loopChanges.modified > 0) {
|
||||
changes.push(`Modified ${summary.loopChanges.modified} loop(s)`)
|
||||
}
|
||||
|
||||
if (summary.parallelChanges.added > 0) {
|
||||
changes.push(`Added ${summary.parallelChanges.added} parallel group(s)`)
|
||||
}
|
||||
if (summary.parallelChanges.removed > 0) {
|
||||
changes.push(`Removed ${summary.parallelChanges.removed} parallel group(s)`)
|
||||
}
|
||||
if (summary.parallelChanges.modified > 0) {
|
||||
changes.push(`Modified ${summary.parallelChanges.modified} parallel group(s)`)
|
||||
}
|
||||
|
||||
const varChanges: string[] = []
|
||||
if (summary.variableChanges.added > 0) {
|
||||
varChanges.push(`${summary.variableChanges.added} added`)
|
||||
}
|
||||
if (summary.variableChanges.removed > 0) {
|
||||
varChanges.push(`${summary.variableChanges.removed} removed`)
|
||||
}
|
||||
if (summary.variableChanges.modified > 0) {
|
||||
varChanges.push(`${summary.variableChanges.modified} modified`)
|
||||
}
|
||||
if (varChanges.length > 0) {
|
||||
changes.push(`Variables: ${varChanges.join(', ')}`)
|
||||
}
|
||||
formatEdgeChanges(summary, changes)
|
||||
formatCountChanges(summary.loopChanges, 'loop', changes)
|
||||
formatCountChanges(summary.parallelChanges, 'parallel group', changes)
|
||||
formatVariableChanges(summary, changes)
|
||||
|
||||
return changes.join('\n')
|
||||
}
|
||||
@@ -437,8 +481,9 @@ export async function formatDiffSummaryForDescriptionAsync(
|
||||
const modifiedBlockPromises = summary.modifiedBlocks.map(async (block) => {
|
||||
const name = block.name || block.type
|
||||
const blockChanges: string[] = []
|
||||
const meaningfulChanges = block.changes.filter((c) => !c.field.endsWith('.properties'))
|
||||
|
||||
const changesToProcess = block.changes.slice(0, 3)
|
||||
const changesToProcess = meaningfulChanges.slice(0, MAX_CHANGES_PER_BLOCK)
|
||||
const resolvedChanges = await Promise.all(
|
||||
changesToProcess.map(async (change) => {
|
||||
const context = {
|
||||
@@ -455,7 +500,7 @@ export async function formatDiffSummaryForDescriptionAsync(
|
||||
])
|
||||
|
||||
return {
|
||||
field: change.field,
|
||||
field: resolveFieldLabel(block.type, change.field),
|
||||
oldLabel: oldResolved.displayLabel,
|
||||
newLabel: newResolved.displayLabel,
|
||||
}
|
||||
@@ -468,8 +513,10 @@ export async function formatDiffSummaryForDescriptionAsync(
|
||||
)
|
||||
}
|
||||
|
||||
if (block.changes.length > 3) {
|
||||
blockChanges.push(` ...and ${block.changes.length - 3} more changes in ${name}`)
|
||||
if (meaningfulChanges.length > MAX_CHANGES_PER_BLOCK) {
|
||||
blockChanges.push(
|
||||
` ...and ${meaningfulChanges.length - MAX_CHANGES_PER_BLOCK} more changes in ${name}`
|
||||
)
|
||||
}
|
||||
|
||||
return blockChanges
|
||||
@@ -480,46 +527,10 @@ export async function formatDiffSummaryForDescriptionAsync(
|
||||
changes.push(...blockChanges)
|
||||
}
|
||||
|
||||
if (summary.edgeChanges.added > 0) {
|
||||
changes.push(`Added ${summary.edgeChanges.added} connection(s)`)
|
||||
}
|
||||
if (summary.edgeChanges.removed > 0) {
|
||||
changes.push(`Removed ${summary.edgeChanges.removed} connection(s)`)
|
||||
}
|
||||
|
||||
if (summary.loopChanges.added > 0) {
|
||||
changes.push(`Added ${summary.loopChanges.added} loop(s)`)
|
||||
}
|
||||
if (summary.loopChanges.removed > 0) {
|
||||
changes.push(`Removed ${summary.loopChanges.removed} loop(s)`)
|
||||
}
|
||||
if (summary.loopChanges.modified > 0) {
|
||||
changes.push(`Modified ${summary.loopChanges.modified} loop(s)`)
|
||||
}
|
||||
|
||||
if (summary.parallelChanges.added > 0) {
|
||||
changes.push(`Added ${summary.parallelChanges.added} parallel group(s)`)
|
||||
}
|
||||
if (summary.parallelChanges.removed > 0) {
|
||||
changes.push(`Removed ${summary.parallelChanges.removed} parallel group(s)`)
|
||||
}
|
||||
if (summary.parallelChanges.modified > 0) {
|
||||
changes.push(`Modified ${summary.parallelChanges.modified} parallel group(s)`)
|
||||
}
|
||||
|
||||
const varChanges: string[] = []
|
||||
if (summary.variableChanges.added > 0) {
|
||||
varChanges.push(`${summary.variableChanges.added} added`)
|
||||
}
|
||||
if (summary.variableChanges.removed > 0) {
|
||||
varChanges.push(`${summary.variableChanges.removed} removed`)
|
||||
}
|
||||
if (summary.variableChanges.modified > 0) {
|
||||
varChanges.push(`${summary.variableChanges.modified} modified`)
|
||||
}
|
||||
if (varChanges.length > 0) {
|
||||
changes.push(`Variables: ${varChanges.join(', ')}`)
|
||||
}
|
||||
formatEdgeChanges(summary, changes)
|
||||
formatCountChanges(summary.loopChanges, 'loop', changes)
|
||||
formatCountChanges(summary.parallelChanges, 'parallel group', changes)
|
||||
formatVariableChanges(summary, changes)
|
||||
|
||||
logger.info('Generated async diff description', {
|
||||
workflowId,
|
||||
@@ -529,3 +540,82 @@ export async function formatDiffSummaryForDescriptionAsync(
|
||||
|
||||
return changes.join('\n')
|
||||
}
|
||||
|
||||
function formatEdgeDetailList(
|
||||
edges: Array<{ sourceName: string; targetName: string }>,
|
||||
total: number,
|
||||
verb: string,
|
||||
changes: string[]
|
||||
): void {
|
||||
if (edges.length === 0) {
|
||||
changes.push(`${verb} ${total} connection(s)`)
|
||||
return
|
||||
}
|
||||
for (const edge of edges.slice(0, MAX_EDGE_DETAILS)) {
|
||||
changes.push(`${verb} connection: ${edge.sourceName} -> ${edge.targetName}`)
|
||||
}
|
||||
if (total > MAX_EDGE_DETAILS) {
|
||||
changes.push(` ...and ${total - MAX_EDGE_DETAILS} more ${verb.toLowerCase()} connection(s)`)
|
||||
}
|
||||
}
|
||||
|
||||
function formatEdgeChanges(summary: WorkflowDiffSummary, changes: string[]): void {
|
||||
if (summary.edgeChanges.added > 0) {
|
||||
formatEdgeDetailList(
|
||||
summary.edgeChanges.addedDetails ?? [],
|
||||
summary.edgeChanges.added,
|
||||
'Added',
|
||||
changes
|
||||
)
|
||||
}
|
||||
if (summary.edgeChanges.removed > 0) {
|
||||
formatEdgeDetailList(
|
||||
summary.edgeChanges.removedDetails ?? [],
|
||||
summary.edgeChanges.removed,
|
||||
'Removed',
|
||||
changes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function formatCountChanges(
|
||||
counts: { added: number; removed: number; modified: number },
|
||||
label: string,
|
||||
changes: string[]
|
||||
): void {
|
||||
if (counts.added > 0) changes.push(`Added ${counts.added} ${label}(s)`)
|
||||
if (counts.removed > 0) changes.push(`Removed ${counts.removed} ${label}(s)`)
|
||||
if (counts.modified > 0) changes.push(`Modified ${counts.modified} ${label}(s)`)
|
||||
}
|
||||
|
||||
function formatVariableChanges(summary: WorkflowDiffSummary, changes: string[]): void {
|
||||
const categories = [
|
||||
{
|
||||
count: summary.variableChanges.added,
|
||||
names: summary.variableChanges.addedNames ?? [],
|
||||
verb: 'added',
|
||||
},
|
||||
{
|
||||
count: summary.variableChanges.removed,
|
||||
names: summary.variableChanges.removedNames ?? [],
|
||||
verb: 'removed',
|
||||
},
|
||||
{
|
||||
count: summary.variableChanges.modified,
|
||||
names: summary.variableChanges.modifiedNames ?? [],
|
||||
verb: 'modified',
|
||||
},
|
||||
] as const
|
||||
|
||||
const varParts: string[] = []
|
||||
for (const { count, names, verb } of categories) {
|
||||
if (count > 0) {
|
||||
varParts.push(
|
||||
names.length > 0 ? `${verb} ${names.map((n) => `"${n}"`).join(', ')}` : `${count} ${verb}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (varParts.length > 0) {
|
||||
changes.push(`Variables: ${varParts.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,864 @@
|
||||
/**
|
||||
* @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', () => ({
|
||||
CREDENTIAL_SET: { PREFIX: 'cred_set_' },
|
||||
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/credential-sets', () => ({
|
||||
fetchCredentialSetById: 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)')
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { getSelectorDefinition } from '@/hooks/selectors/registry'
|
||||
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
|
||||
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { formatParameterLabel } from '@/tools/params'
|
||||
|
||||
const logger = createLogger('ResolveValues')
|
||||
|
||||
@@ -126,6 +127,33 @@ function extractMcpToolName(toolId: string): string {
|
||||
return withoutPrefix
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a subBlock field ID to its human-readable title.
|
||||
* Falls back to the raw ID if the block or subBlock is not found.
|
||||
*/
|
||||
export function resolveFieldLabel(blockType: string, subBlockId: string): string {
|
||||
if (subBlockId.startsWith('data.')) {
|
||||
return formatParameterLabel(subBlockId.slice(5))
|
||||
}
|
||||
const blockConfig = getBlock(blockType)
|
||||
if (!blockConfig) return subBlockId
|
||||
const subBlockConfig = blockConfig.subBlocks.find((sb) => sb.id === subBlockId)
|
||||
return subBlockConfig?.title ?? subBlockId
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a dropdown option ID to its human-readable label.
|
||||
* Returns null if the subBlock is not a dropdown or the value is not found.
|
||||
*/
|
||||
function resolveDropdownLabel(subBlockConfig: SubBlockConfig, value: string): string | null {
|
||||
if (subBlockConfig.type !== 'dropdown') return null
|
||||
if (!subBlockConfig.options) return null
|
||||
const options =
|
||||
typeof subBlockConfig.options === 'function' ? subBlockConfig.options() : subBlockConfig.options
|
||||
const match = options.find((opt) => opt.id === value)
|
||||
return match?.label ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a value for display in diff descriptions.
|
||||
*/
|
||||
@@ -138,7 +166,10 @@ export function formatValueForDisplay(value: unknown): string {
|
||||
if (typeof value === 'boolean') return value ? 'enabled' : 'disabled'
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (Array.isArray(value)) return `[${value.length} items]`
|
||||
if (typeof value === 'object') return `${JSON.stringify(value).slice(0, 50)}...`
|
||||
if (typeof value === 'object') {
|
||||
const json = JSON.stringify(value)
|
||||
return json.length > 50 ? `${json.slice(0, 50)}...` : json
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
@@ -165,7 +196,6 @@ export async function resolveValueForDisplay(
|
||||
value: unknown,
|
||||
context: ResolutionContext
|
||||
): Promise<ResolvedValue> {
|
||||
// Non-string or empty values can't be resolved
|
||||
if (typeof value !== 'string' || !value) {
|
||||
return {
|
||||
original: value,
|
||||
@@ -190,9 +220,8 @@ export async function resolveValueForDisplay(
|
||||
)
|
||||
: { workflowId: context.workflowId, workspaceId: context.workspaceId }
|
||||
|
||||
// Credential fields (oauth-input or credential subBlockId)
|
||||
const isCredentialField =
|
||||
subBlockConfig?.type === 'oauth-input' || context.subBlockId === 'credential'
|
||||
subBlockConfig.type === 'oauth-input' || context.subBlockId === 'credential'
|
||||
|
||||
if (isCredentialField && (value.startsWith(CREDENTIAL_SET.PREFIX) || isUuid(value))) {
|
||||
const label = await resolveCredential(value, context.workflowId)
|
||||
@@ -202,8 +231,7 @@ export async function resolveValueForDisplay(
|
||||
return { original: value, displayLabel: semanticFallback, resolved: true }
|
||||
}
|
||||
|
||||
// Workflow selector
|
||||
if (subBlockConfig?.type === 'workflow-selector' && isUuid(value)) {
|
||||
if (subBlockConfig.type === 'workflow-selector' && isUuid(value)) {
|
||||
const label = await resolveWorkflow(value, selectorCtx.workspaceId)
|
||||
if (label) {
|
||||
return { original: value, displayLabel: label, resolved: true }
|
||||
@@ -211,15 +239,27 @@ export async function resolveValueForDisplay(
|
||||
return { original: value, displayLabel: semanticFallback, resolved: true }
|
||||
}
|
||||
|
||||
// MCP tool selector
|
||||
if (subBlockConfig?.type === 'mcp-tool-selector') {
|
||||
if (subBlockConfig.type === 'mcp-tool-selector') {
|
||||
const toolName = extractMcpToolName(value)
|
||||
return { original: value, displayLabel: toolName, resolved: true }
|
||||
}
|
||||
|
||||
// Selector types that require hydration (file-selector, sheet-selector, etc.)
|
||||
// These support external service IDs like Google Drive file IDs
|
||||
if (subBlockConfig && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlockConfig.type)) {
|
||||
if (subBlockConfig.type === 'dropdown') {
|
||||
try {
|
||||
const label = resolveDropdownLabel(subBlockConfig, value)
|
||||
if (label) {
|
||||
return { original: value, displayLabel: label, resolved: true }
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to resolve dropdown label', {
|
||||
value,
|
||||
subBlockId: context.subBlockId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlockConfig.type)) {
|
||||
const resolution = resolveSelectorForSubBlock(subBlockConfig, selectorCtx)
|
||||
|
||||
if (resolution?.key) {
|
||||
@@ -228,22 +268,17 @@ export async function resolveValueForDisplay(
|
||||
return { original: value, displayLabel: label, resolved: true }
|
||||
}
|
||||
}
|
||||
// If resolution failed for a hydration-required type, use semantic fallback
|
||||
return { original: value, displayLabel: semanticFallback, resolved: true }
|
||||
}
|
||||
|
||||
// For fields without specific subBlock types, use pattern matching
|
||||
// UUID fallback
|
||||
if (isUuid(value)) {
|
||||
return { original: value, displayLabel: semanticFallback, resolved: true }
|
||||
}
|
||||
|
||||
// Slack-style IDs (channels: C..., users: U.../W...) get semantic fallback
|
||||
if (/^C[A-Z0-9]{8,}$/.test(value) || /^[UW][A-Z0-9]{8,}$/.test(value)) {
|
||||
return { original: value, displayLabel: semanticFallback, resolved: true }
|
||||
}
|
||||
|
||||
// Credential set prefix without credential field type
|
||||
if (value.startsWith(CREDENTIAL_SET.PREFIX)) {
|
||||
const label = await resolveCredential(value, context.workflowId)
|
||||
if (label) {
|
||||
|
||||
Reference in New Issue
Block a user