6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
1014 lines
29 KiB
TypeScript
1014 lines
29 KiB
TypeScript
import type { BlockFactoryOptions } from '@sim/testing'
|
|
import {
|
|
createAgentBlock,
|
|
createBlock,
|
|
createFunctionBlock,
|
|
createLoopBlock,
|
|
createStarterBlock,
|
|
} from '@sim/testing'
|
|
import type { Edge } from 'reactflow'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { normalizeName } from '@/executor/constants'
|
|
import { getUniqueBlockName, regenerateBlockIds } from './utils'
|
|
|
|
describe('normalizeName', () => {
|
|
it.concurrent('should convert to lowercase', () => {
|
|
expect(normalizeName('MyVariable')).toBe('myvariable')
|
|
expect(normalizeName('UPPERCASE')).toBe('uppercase')
|
|
expect(normalizeName('MixedCase')).toBe('mixedcase')
|
|
})
|
|
|
|
it.concurrent('should remove spaces', () => {
|
|
expect(normalizeName('my variable')).toBe('myvariable')
|
|
expect(normalizeName('my variable')).toBe('myvariable')
|
|
expect(normalizeName(' spaced ')).toBe('spaced')
|
|
})
|
|
|
|
it.concurrent('should handle both lowercase and space removal', () => {
|
|
expect(normalizeName('JIRA TEAM UUID')).toBe('jirateamuuid')
|
|
expect(normalizeName('My Block Name')).toBe('myblockname')
|
|
expect(normalizeName('API 1')).toBe('api1')
|
|
})
|
|
|
|
it.concurrent('should handle edge cases', () => {
|
|
expect(normalizeName('')).toBe('')
|
|
expect(normalizeName(' ')).toBe('')
|
|
expect(normalizeName('a')).toBe('a')
|
|
expect(normalizeName('already_normalized')).toBe('already_normalized')
|
|
})
|
|
|
|
it.concurrent('should preserve non-space special characters except dots', () => {
|
|
expect(normalizeName('my-variable')).toBe('my-variable')
|
|
expect(normalizeName('my_variable')).toBe('my_variable')
|
|
})
|
|
|
|
it.concurrent('should strip dots since they conflict with the reference path delimiter', () => {
|
|
expect(normalizeName('my.variable')).toBe('myvariable')
|
|
expect(normalizeName('Trigger.dev 1')).toBe('triggerdev1')
|
|
expect(normalizeName('Hunter.io 2')).toBe('hunterio2')
|
|
})
|
|
|
|
it.concurrent('should handle tabs and newlines as whitespace', () => {
|
|
expect(normalizeName('my\tvariable')).toBe('myvariable')
|
|
expect(normalizeName('my\nvariable')).toBe('myvariable')
|
|
expect(normalizeName('my\r\nvariable')).toBe('myvariable')
|
|
})
|
|
|
|
it.concurrent('should handle unicode characters', () => {
|
|
expect(normalizeName('Café')).toBe('café')
|
|
expect(normalizeName('日本語')).toBe('日本語')
|
|
})
|
|
|
|
it.concurrent('should normalize block names correctly', () => {
|
|
expect(normalizeName('Agent 1')).toBe('agent1')
|
|
expect(normalizeName('API Block')).toBe('apiblock')
|
|
expect(normalizeName('My Custom Block')).toBe('mycustomblock')
|
|
})
|
|
|
|
it.concurrent('should normalize variable names correctly', () => {
|
|
expect(normalizeName('jira1')).toBe('jira1')
|
|
expect(normalizeName('JIRA TEAM UUID')).toBe('jirateamuuid')
|
|
expect(normalizeName('My Variable')).toBe('myvariable')
|
|
})
|
|
|
|
it.concurrent('should produce consistent results for references', () => {
|
|
const originalName = 'JIRA TEAM UUID'
|
|
const normalized1 = normalizeName(originalName)
|
|
const normalized2 = normalizeName(originalName)
|
|
|
|
expect(normalized1).toBe(normalized2)
|
|
expect(normalized1).toBe('jirateamuuid')
|
|
})
|
|
|
|
it.concurrent('should allow matching block references to variable references', () => {
|
|
const name = 'API Block'
|
|
const blockRef = `<${normalizeName(name)}.output>`
|
|
const varRef = `<variable.${normalizeName(name)}>`
|
|
|
|
expect(blockRef).toBe('<apiblock.output>')
|
|
expect(varRef).toBe('<variable.apiblock>')
|
|
})
|
|
|
|
it.concurrent('should handle real-world naming patterns consistently', () => {
|
|
const realWorldNames = [
|
|
{ input: 'User ID', expected: 'userid' },
|
|
{ input: 'API Key', expected: 'apikey' },
|
|
{ input: 'OAuth Token', expected: 'oauthtoken' },
|
|
{ input: 'Database URL', expected: 'databaseurl' },
|
|
{ input: 'STRIPE SECRET KEY', expected: 'stripesecretkey' },
|
|
{ input: 'openai api key', expected: 'openaiapikey' },
|
|
{ input: 'Customer Name', expected: 'customername' },
|
|
{ input: 'Order Total', expected: 'ordertotal' },
|
|
]
|
|
|
|
for (const { input, expected } of realWorldNames) {
|
|
expect(normalizeName(input)).toBe(expected)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('getUniqueBlockName', () => {
|
|
it('should return "Start" for starter blocks', () => {
|
|
expect(getUniqueBlockName('Start', {})).toBe('Start')
|
|
expect(getUniqueBlockName('Starter', {})).toBe('Start')
|
|
expect(getUniqueBlockName('start', {})).toBe('Start')
|
|
})
|
|
|
|
it('should return name with number 1 when no existing blocks', () => {
|
|
expect(getUniqueBlockName('Agent', {})).toBe('Agent 1')
|
|
expect(getUniqueBlockName('Function', {})).toBe('Function 1')
|
|
expect(getUniqueBlockName('Loop', {})).toBe('Loop 1')
|
|
})
|
|
|
|
it('should increment number when existing blocks have same base name', () => {
|
|
const existingBlocks = {
|
|
'block-1': createAgentBlock({ id: 'block-1', name: 'Agent 1' }),
|
|
}
|
|
|
|
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 2')
|
|
})
|
|
|
|
it('should find highest number and increment', () => {
|
|
const existingBlocks = {
|
|
'block-1': createAgentBlock({ id: 'block-1', name: 'Agent 1' }),
|
|
'block-2': createAgentBlock({ id: 'block-2', name: 'Agent 3' }),
|
|
'block-3': createAgentBlock({ id: 'block-3', name: 'Agent 2' }),
|
|
}
|
|
|
|
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 4')
|
|
})
|
|
|
|
it('should handle base name with existing number suffix', () => {
|
|
const existingBlocks = {
|
|
'block-1': createFunctionBlock({ id: 'block-1', name: 'Function 1' }),
|
|
'block-2': createFunctionBlock({ id: 'block-2', name: 'Function 2' }),
|
|
}
|
|
|
|
expect(getUniqueBlockName('Function 1', existingBlocks)).toBe('Function 3')
|
|
expect(getUniqueBlockName('Function 5', existingBlocks)).toBe('Function 3')
|
|
})
|
|
|
|
it('should be case insensitive when matching base names', () => {
|
|
const existingBlocks = {
|
|
'block-1': createBlock({ id: 'block-1', name: 'API 1' }),
|
|
'block-2': createBlock({ id: 'block-2', name: 'api 2' }),
|
|
}
|
|
|
|
expect(getUniqueBlockName('API', existingBlocks)).toBe('API 3')
|
|
expect(getUniqueBlockName('api', existingBlocks)).toBe('api 3')
|
|
})
|
|
|
|
it('should handle different block types independently', () => {
|
|
const existingBlocks = {
|
|
'block-1': createAgentBlock({ id: 'block-1', name: 'Agent 1' }),
|
|
'block-2': createFunctionBlock({ id: 'block-2', name: 'Function 1' }),
|
|
'block-3': createLoopBlock({ id: 'block-3', name: 'Loop 1' }),
|
|
}
|
|
|
|
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 2')
|
|
expect(getUniqueBlockName('Function', existingBlocks)).toBe('Function 2')
|
|
expect(getUniqueBlockName('Loop', existingBlocks)).toBe('Loop 2')
|
|
expect(getUniqueBlockName('Router', existingBlocks)).toBe('Router 1')
|
|
})
|
|
|
|
it('should handle blocks without numbers as having number 0', () => {
|
|
const existingBlocks = {
|
|
'block-1': createBlock({ id: 'block-1', name: 'Custom' }),
|
|
}
|
|
|
|
expect(getUniqueBlockName('Custom', existingBlocks)).toBe('Custom 1')
|
|
})
|
|
|
|
it('should handle multi-word base names', () => {
|
|
const existingBlocks = {
|
|
'block-1': createBlock({ id: 'block-1', name: 'API Block 1' }),
|
|
'block-2': createBlock({ id: 'block-2', name: 'API Block 2' }),
|
|
}
|
|
|
|
expect(getUniqueBlockName('API Block', existingBlocks)).toBe('API Block 3')
|
|
})
|
|
|
|
it('should handle starter blocks even with existing starters', () => {
|
|
const existingBlocks = {
|
|
'block-1': createStarterBlock({ id: 'block-1', name: 'Start' }),
|
|
}
|
|
|
|
expect(getUniqueBlockName('Start', existingBlocks)).toBe('Start')
|
|
expect(getUniqueBlockName('Starter', existingBlocks)).toBe('Start')
|
|
})
|
|
|
|
it('should handle empty string base name', () => {
|
|
const existingBlocks = {
|
|
'block-1': createBlock({ id: 'block-1', name: ' 1' }),
|
|
}
|
|
|
|
expect(getUniqueBlockName('', existingBlocks)).toBe(' 1')
|
|
})
|
|
|
|
it('should handle complex real-world scenarios', () => {
|
|
const existingBlocks = {
|
|
starter: createStarterBlock({ id: 'starter', name: 'Start' }),
|
|
agent1: createAgentBlock({ id: 'agent1', name: 'Agent 1' }),
|
|
agent2: createAgentBlock({ id: 'agent2', name: 'Agent 2' }),
|
|
func1: createFunctionBlock({ id: 'func1', name: 'Function 1' }),
|
|
loop1: createLoopBlock({ id: 'loop1', name: 'Loop 1' }),
|
|
}
|
|
|
|
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 3')
|
|
expect(getUniqueBlockName('Function', existingBlocks)).toBe('Function 2')
|
|
expect(getUniqueBlockName('Start', existingBlocks)).toBe('Start')
|
|
expect(getUniqueBlockName('Condition', existingBlocks)).toBe('Condition 1')
|
|
})
|
|
|
|
it('should preserve original base name casing in result', () => {
|
|
const existingBlocks = {
|
|
'block-1': createBlock({ id: 'block-1', name: 'MyBlock 1' }),
|
|
}
|
|
|
|
expect(getUniqueBlockName('MyBlock', existingBlocks)).toBe('MyBlock 2')
|
|
expect(getUniqueBlockName('MYBLOCK', existingBlocks)).toBe('MYBLOCK 2')
|
|
expect(getUniqueBlockName('myblock', existingBlocks)).toBe('myblock 2')
|
|
})
|
|
})
|
|
|
|
describe('regenerateBlockIds', () => {
|
|
const positionOffset = { x: 50, y: 50 }
|
|
|
|
it('should preserve parentId and use same offset when duplicating a block inside an existing subflow', () => {
|
|
const loopId = 'loop-1'
|
|
const childId = 'child-1'
|
|
|
|
const existingBlocks = {
|
|
[loopId]: createLoopBlock({ id: loopId, name: 'Loop 1' }),
|
|
}
|
|
|
|
const blocksToCopy = {
|
|
[childId]: createAgentBlock({
|
|
id: childId,
|
|
name: 'Agent 1',
|
|
position: { x: 100, y: 50 },
|
|
data: { parentId: loopId, extent: 'parent' },
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset, // { x: 50, y: 50 } - small offset, used as-is
|
|
existingBlocks,
|
|
getUniqueBlockName
|
|
)
|
|
|
|
const newBlocks = Object.values(result.blocks)
|
|
expect(newBlocks).toHaveLength(1)
|
|
|
|
const duplicatedBlock = newBlocks[0]
|
|
expect(duplicatedBlock.data?.parentId).toBe(loopId)
|
|
expect(duplicatedBlock.data?.extent).toBe('parent')
|
|
expect(duplicatedBlock.position).toEqual({ x: 150, y: 100 })
|
|
})
|
|
|
|
it('should clear parentId when parent does not exist in paste set or existing blocks', () => {
|
|
const nonExistentParentId = 'non-existent-loop'
|
|
const childId = 'child-1'
|
|
|
|
const blocksToCopy = {
|
|
[childId]: createAgentBlock({
|
|
id: childId,
|
|
name: 'Agent 1',
|
|
position: { x: 100, y: 50 },
|
|
data: { parentId: nonExistentParentId, extent: 'parent' },
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
{},
|
|
getUniqueBlockName
|
|
)
|
|
|
|
const newBlocks = Object.values(result.blocks)
|
|
expect(newBlocks).toHaveLength(1)
|
|
|
|
const duplicatedBlock = newBlocks[0]
|
|
expect(duplicatedBlock.data?.parentId).toBeUndefined()
|
|
expect(duplicatedBlock.data?.extent).toBeUndefined()
|
|
})
|
|
|
|
it('should remap parentId when copying both parent and child together', () => {
|
|
const loopId = 'loop-1'
|
|
const childId = 'child-1'
|
|
|
|
const blocksToCopy = {
|
|
[loopId]: createLoopBlock({
|
|
id: loopId,
|
|
name: 'Loop 1',
|
|
position: { x: 200, y: 200 },
|
|
}),
|
|
[childId]: createAgentBlock({
|
|
id: childId,
|
|
name: 'Agent 1',
|
|
position: { x: 100, y: 50 },
|
|
data: { parentId: loopId, extent: 'parent' },
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
{},
|
|
getUniqueBlockName
|
|
)
|
|
|
|
const newBlocks = Object.values(result.blocks)
|
|
expect(newBlocks).toHaveLength(2)
|
|
|
|
const newLoop = newBlocks.find((b) => b.type === 'loop')
|
|
const newChild = newBlocks.find((b) => b.type === 'agent')
|
|
|
|
expect(newLoop).toBeDefined()
|
|
expect(newChild).toBeDefined()
|
|
expect(newChild!.data?.parentId).toBe(newLoop!.id)
|
|
expect(newChild!.data?.extent).toBe('parent')
|
|
|
|
expect(newLoop!.position).toEqual({ x: 250, y: 250 })
|
|
expect(newChild!.position).toEqual({ x: 100, y: 50 })
|
|
})
|
|
|
|
it('should apply offset to top-level blocks', () => {
|
|
const blockId = 'block-1'
|
|
|
|
const blocksToCopy = {
|
|
[blockId]: createAgentBlock({
|
|
id: blockId,
|
|
name: 'Agent 1',
|
|
position: { x: 100, y: 100 },
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
{},
|
|
getUniqueBlockName
|
|
)
|
|
|
|
const newBlocks = Object.values(result.blocks)
|
|
expect(newBlocks).toHaveLength(1)
|
|
expect(newBlocks[0].position).toEqual({ x: 150, y: 150 })
|
|
})
|
|
|
|
it('should generate unique names for duplicated blocks', () => {
|
|
const blockId = 'block-1'
|
|
|
|
const existingBlocks = {
|
|
existing: createAgentBlock({ id: 'existing', name: 'Agent 1' }),
|
|
}
|
|
|
|
const blocksToCopy = {
|
|
[blockId]: createAgentBlock({
|
|
id: blockId,
|
|
name: 'Agent 1',
|
|
position: { x: 100, y: 100 },
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
existingBlocks,
|
|
getUniqueBlockName
|
|
)
|
|
|
|
const newBlocks = Object.values(result.blocks)
|
|
expect(newBlocks).toHaveLength(1)
|
|
expect(newBlocks[0].name).toBe('Agent 2')
|
|
})
|
|
|
|
it('should ignore large viewport offset for blocks inside existing subflows', () => {
|
|
const loopId = 'loop-1'
|
|
const childId = 'child-1'
|
|
|
|
const existingBlocks = {
|
|
[loopId]: createLoopBlock({ id: loopId, name: 'Loop 1' }),
|
|
}
|
|
|
|
const blocksToCopy = {
|
|
[childId]: createAgentBlock({
|
|
id: childId,
|
|
name: 'Agent 1',
|
|
position: { x: 100, y: 50 },
|
|
data: { parentId: loopId, extent: 'parent' },
|
|
}),
|
|
}
|
|
|
|
const largeViewportOffset = { x: 2000, y: 1500 }
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
largeViewportOffset,
|
|
existingBlocks,
|
|
getUniqueBlockName
|
|
)
|
|
|
|
const duplicatedBlock = Object.values(result.blocks)[0]
|
|
expect(duplicatedBlock.position).toEqual({ x: 280, y: 70 })
|
|
expect(duplicatedBlock.data?.parentId).toBe(loopId)
|
|
})
|
|
|
|
/**
|
|
* Regression: a fallback writer stamped a condition block's `conditions`
|
|
* subblock `short-input`. The id remap must key on block type + subblock key
|
|
* (not the drifted stored type) so the condition row ids and the outgoing
|
|
* edge's sourceHandle move together — previously the handle remapped while
|
|
* the row ids stayed stale, orphaning the edge.
|
|
*/
|
|
it('keeps condition row ids and edge handles consistent when the stored subblock type drifted', () => {
|
|
const conditionId = 'condition-1'
|
|
const targetId = 'target-1'
|
|
|
|
const blocksToCopy = {
|
|
[conditionId]: createBlock({
|
|
id: conditionId,
|
|
type: 'condition',
|
|
name: 'botFilter',
|
|
subBlocks: {
|
|
conditions: {
|
|
id: 'conditions',
|
|
type: 'short-input',
|
|
value: JSON.stringify([
|
|
{ id: `${conditionId}-if`, title: 'if', value: '<a.b>' },
|
|
{ id: `${conditionId}-else`, title: 'else', value: '' },
|
|
]),
|
|
},
|
|
},
|
|
}),
|
|
[targetId]: createAgentBlock({ id: targetId, name: 'Agent 1' }),
|
|
}
|
|
|
|
const edges = [
|
|
{
|
|
id: 'edge-1',
|
|
source: conditionId,
|
|
sourceHandle: `condition-${conditionId}-else`,
|
|
target: targetId,
|
|
targetHandle: 'target',
|
|
},
|
|
] as Edge[]
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
edges,
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
{},
|
|
getUniqueBlockName
|
|
)
|
|
|
|
const newCondition = Object.values(result.blocks).find((b) => b.type === 'condition')!
|
|
const newEdge = result.edges[0]
|
|
const rowIds = JSON.parse(newCondition.subBlocks.conditions.value as string).map(
|
|
(row: { id: string }) => row.id
|
|
)
|
|
|
|
expect(rowIds).toEqual([`${newCondition.id}-if`, `${newCondition.id}-else`])
|
|
expect(newEdge.sourceHandle).toBe(`condition-${newCondition.id}-else`)
|
|
})
|
|
|
|
it('should unlock pasted block when source is locked', () => {
|
|
const blockId = 'block-1'
|
|
|
|
const blocksToCopy = {
|
|
[blockId]: createAgentBlock({
|
|
id: blockId,
|
|
name: 'Locked Agent',
|
|
position: { x: 100, y: 50 },
|
|
locked: true,
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
{},
|
|
getUniqueBlockName
|
|
)
|
|
|
|
const newBlocks = Object.values(result.blocks)
|
|
expect(newBlocks).toHaveLength(1)
|
|
|
|
// Pasted blocks are always unlocked so users can edit them
|
|
const pastedBlock = newBlocks[0]
|
|
expect(pastedBlock.locked).toBe(false)
|
|
})
|
|
|
|
it('should keep pasted block unlocked when source is unlocked', () => {
|
|
const blockId = 'block-1'
|
|
|
|
const blocksToCopy = {
|
|
[blockId]: createAgentBlock({
|
|
id: blockId,
|
|
name: 'Unlocked Agent',
|
|
position: { x: 100, y: 50 },
|
|
locked: false,
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
{},
|
|
getUniqueBlockName
|
|
)
|
|
|
|
const newBlocks = Object.values(result.blocks)
|
|
expect(newBlocks).toHaveLength(1)
|
|
|
|
const pastedBlock = newBlocks[0]
|
|
expect(pastedBlock.locked).toBe(false)
|
|
})
|
|
|
|
it('should unlock all pasted blocks regardless of source locked state', () => {
|
|
const lockedId = 'locked-1'
|
|
const unlockedId = 'unlocked-1'
|
|
|
|
const blocksToCopy = {
|
|
[lockedId]: createAgentBlock({
|
|
id: lockedId,
|
|
name: 'Originally Locked Agent',
|
|
position: { x: 100, y: 50 },
|
|
locked: true,
|
|
}),
|
|
[unlockedId]: createFunctionBlock({
|
|
id: unlockedId,
|
|
name: 'Originally Unlocked Function',
|
|
position: { x: 200, y: 50 },
|
|
locked: false,
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
{},
|
|
getUniqueBlockName
|
|
)
|
|
|
|
const newBlocks = Object.values(result.blocks)
|
|
expect(newBlocks).toHaveLength(2)
|
|
|
|
for (const block of newBlocks) {
|
|
expect(block.locked).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('should preserve original name when no conflicting block exists', () => {
|
|
const blockId = 'block-1'
|
|
|
|
const blocksToCopy = {
|
|
[blockId]: createAgentBlock({
|
|
id: blockId,
|
|
name: 'Agent 1',
|
|
position: { x: 100, y: 100 },
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
{},
|
|
getUniqueBlockName
|
|
)
|
|
|
|
const newBlocks = Object.values(result.blocks)
|
|
expect(newBlocks).toHaveLength(1)
|
|
expect(newBlocks[0].name).toBe('Agent 1')
|
|
})
|
|
|
|
it('should preserve original name with number suffix when no conflict', () => {
|
|
const blocksToCopy = {
|
|
'block-1': createAgentBlock({
|
|
id: 'block-1',
|
|
name: 'Agent 3',
|
|
position: { x: 100, y: 100 },
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
{},
|
|
getUniqueBlockName
|
|
)
|
|
|
|
expect(Object.values(result.blocks)[0].name).toBe('Agent 3')
|
|
})
|
|
|
|
it('should increment name when an exact match exists in destination', () => {
|
|
const existingBlocks = {
|
|
existing: createAgentBlock({ id: 'existing', name: 'Agent 1' }),
|
|
}
|
|
|
|
const blocksToCopy = {
|
|
'block-1': createAgentBlock({
|
|
id: 'block-1',
|
|
name: 'Agent 1',
|
|
position: { x: 100, y: 100 },
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
existingBlocks,
|
|
getUniqueBlockName
|
|
)
|
|
|
|
expect(Object.values(result.blocks)[0].name).toBe('Agent 2')
|
|
})
|
|
|
|
it('should preserve name when only a different-numbered sibling exists', () => {
|
|
const existingBlocks = {
|
|
existing: createAgentBlock({ id: 'existing', name: 'Agent 2' }),
|
|
}
|
|
|
|
const blocksToCopy = {
|
|
'block-1': createAgentBlock({
|
|
id: 'block-1',
|
|
name: 'Agent 5',
|
|
position: { x: 100, y: 100 },
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
existingBlocks,
|
|
getUniqueBlockName
|
|
)
|
|
|
|
expect(Object.values(result.blocks)[0].name).toBe('Agent 5')
|
|
})
|
|
|
|
it('should preserve names for multiple blocks when no conflicts', () => {
|
|
const blocksToCopy = {
|
|
'block-1': createAgentBlock({
|
|
id: 'block-1',
|
|
name: 'Agent 1',
|
|
position: { x: 100, y: 100 },
|
|
}),
|
|
'block-2': createFunctionBlock({
|
|
id: 'block-2',
|
|
name: 'Function 3',
|
|
position: { x: 200, y: 100 },
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
{},
|
|
getUniqueBlockName
|
|
)
|
|
|
|
const newBlocks = Object.values(result.blocks)
|
|
const agentBlock = newBlocks.find((b) => b.type === 'agent')
|
|
const functionBlock = newBlocks.find((b) => b.type === 'function')
|
|
|
|
expect(agentBlock!.name).toBe('Agent 1')
|
|
expect(functionBlock!.name).toBe('Function 3')
|
|
})
|
|
|
|
it('should handle mixed conflicts: preserve non-conflicting, increment conflicting', () => {
|
|
const existingBlocks = {
|
|
existing: createAgentBlock({ id: 'existing', name: 'Agent 1' }),
|
|
}
|
|
|
|
const blocksToCopy = {
|
|
'block-1': createAgentBlock({
|
|
id: 'block-1',
|
|
name: 'Agent 1',
|
|
position: { x: 100, y: 100 },
|
|
}),
|
|
'block-2': createFunctionBlock({
|
|
id: 'block-2',
|
|
name: 'Function 1',
|
|
position: { x: 200, y: 100 },
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
existingBlocks,
|
|
getUniqueBlockName
|
|
)
|
|
|
|
const newBlocks = Object.values(result.blocks)
|
|
const agentBlock = newBlocks.find((b) => b.type === 'agent')
|
|
const functionBlock = newBlocks.find((b) => b.type === 'function')
|
|
|
|
expect(agentBlock!.name).toBe('Agent 2')
|
|
expect(functionBlock!.name).toBe('Function 1')
|
|
})
|
|
|
|
it('should detect conflicts case-insensitively', () => {
|
|
const existingBlocks = {
|
|
existing: createBlock({ id: 'existing', name: 'api 1' }),
|
|
}
|
|
|
|
const blocksToCopy = {
|
|
'block-1': createBlock({
|
|
id: 'block-1',
|
|
name: 'API 1',
|
|
position: { x: 100, y: 100 },
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
existingBlocks,
|
|
getUniqueBlockName
|
|
)
|
|
|
|
expect(Object.values(result.blocks)[0].name).toBe('API 2')
|
|
})
|
|
|
|
it('should preserve name without number suffix when no conflict', () => {
|
|
const blocksToCopy = {
|
|
'block-1': createBlock({
|
|
id: 'block-1',
|
|
name: 'Custom Block',
|
|
position: { x: 100, y: 100 },
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
{},
|
|
getUniqueBlockName
|
|
)
|
|
|
|
expect(Object.values(result.blocks)[0].name).toBe('Custom Block')
|
|
})
|
|
|
|
it('should avoid collisions between pasted blocks themselves', () => {
|
|
const blocksToCopy = {
|
|
'block-1': createAgentBlock({
|
|
id: 'block-1',
|
|
name: 'Agent 1',
|
|
position: { x: 100, y: 100 },
|
|
}),
|
|
'block-2': createAgentBlock({
|
|
id: 'block-2',
|
|
name: 'Agent 1',
|
|
position: { x: 200, y: 100 },
|
|
}),
|
|
}
|
|
|
|
const result = regenerateBlockIds(
|
|
blocksToCopy,
|
|
[],
|
|
{},
|
|
{},
|
|
{},
|
|
positionOffset,
|
|
{},
|
|
getUniqueBlockName
|
|
)
|
|
|
|
const newBlocks = Object.values(result.blocks)
|
|
const names = newBlocks.map((b) => b.name)
|
|
|
|
expect(names).toHaveLength(2)
|
|
expect(new Set(names).size).toBe(2)
|
|
expect(names).toContain('Agent 1')
|
|
expect(names).toContain('Agent 2')
|
|
})
|
|
})
|
|
|
|
describe('regenerateBlockIds — cloned webhook path', () => {
|
|
const positionOffset = { x: 50, y: 50 }
|
|
const sourceId = 'webhook-source'
|
|
const deployedPath = 'webhook-source'
|
|
|
|
function pasteOne(block: Partial<BlockFactoryOptions>, values?: Record<string, unknown>) {
|
|
const blocks = { [sourceId]: createBlock({ id: sourceId, ...block }) }
|
|
const result = regenerateBlockIds(
|
|
blocks,
|
|
[],
|
|
{},
|
|
{},
|
|
values ? { [sourceId]: values } : {},
|
|
positionOffset,
|
|
{},
|
|
getUniqueBlockName
|
|
)
|
|
return { newId: Object.keys(result.blocks)[0], result }
|
|
}
|
|
|
|
/**
|
|
* The reported bug. A Webhook Trigger added from the toolbar carries `triggerMode: true`
|
|
* (confirmed against production rows), and after a deploy its `triggerPath` holds the registered
|
|
* path — its own block id. A clone that copies that value renders the SOURCE's URL.
|
|
*/
|
|
it('clears triggerPath on a pasted webhook trigger (triggerMode true)', () => {
|
|
const { newId, result } = pasteOne(
|
|
{
|
|
type: 'generic_webhook',
|
|
name: 'Webhook 1',
|
|
triggerMode: true,
|
|
subBlocks: {
|
|
triggerPath: { id: 'triggerPath', type: 'short-input', value: deployedPath },
|
|
},
|
|
},
|
|
{ triggerPath: deployedPath }
|
|
)
|
|
|
|
expect(newId).not.toBe(sourceId)
|
|
// Both sources must be cleared: the value map overrides the structure in mergeSubblockState.
|
|
expect(result.blocks[newId].subBlocks.triggerPath?.value).toBeNull()
|
|
expect(result.subBlockValues[newId].triggerPath).toBeNull()
|
|
})
|
|
|
|
/** Rows written by the API/import path can carry `triggerMode: false`; same requirement. */
|
|
it('clears triggerPath on a pasted webhook trigger (triggerMode false)', () => {
|
|
const { newId, result } = pasteOne(
|
|
{
|
|
type: 'generic_webhook',
|
|
name: 'Webhook 1',
|
|
triggerMode: false,
|
|
subBlocks: {
|
|
triggerPath: { id: 'triggerPath', type: 'short-input', value: deployedPath },
|
|
},
|
|
},
|
|
{ triggerPath: deployedPath }
|
|
)
|
|
|
|
expect(result.blocks[newId].subBlocks.triggerPath?.value).toBeNull()
|
|
expect(result.subBlockValues[newId].triggerPath).toBeNull()
|
|
})
|
|
|
|
it('clears it when the path lives only in the value map', () => {
|
|
const { newId, result } = pasteOne(
|
|
{ type: 'generic_webhook', name: 'Webhook 1', triggerMode: true, subBlocks: {} },
|
|
{ triggerPath: deployedPath }
|
|
)
|
|
|
|
expect(result.subBlockValues[newId].triggerPath).toBeNull()
|
|
})
|
|
|
|
it('clears it when the block has no value-map entry at all', () => {
|
|
const { newId, result } = pasteOne({
|
|
type: 'generic_webhook',
|
|
name: 'Webhook 1',
|
|
triggerMode: true,
|
|
subBlocks: {
|
|
triggerPath: { id: 'triggerPath', type: 'short-input', value: deployedPath },
|
|
},
|
|
})
|
|
|
|
expect(result.blocks[newId].subBlocks.triggerPath?.value).toBeNull()
|
|
})
|
|
|
|
/**
|
|
* `webhookId` is a user-entered action field on Attio, Vercel, and Discord — and Attio/Vercel are
|
|
* trigger-capable, so any predicate keyed on trigger-ness would wipe it in trigger mode. It is
|
|
* deliberately NOT cleared: nothing reads it as trigger state.
|
|
*/
|
|
it('preserves a user-entered webhookId on a trigger-capable block in trigger mode', () => {
|
|
const { newId, result } = pasteOne(
|
|
{
|
|
type: 'attio',
|
|
name: 'Attio 1',
|
|
triggerMode: true,
|
|
subBlocks: {
|
|
webhookId: { id: 'webhookId', type: 'short-input', value: 'attio-wh-42' },
|
|
},
|
|
},
|
|
{ webhookId: 'attio-wh-42' }
|
|
)
|
|
|
|
expect(result.blocks[newId].subBlocks.webhookId?.value).toBe('attio-wh-42')
|
|
expect(result.subBlockValues[newId].webhookId).toBe('attio-wh-42')
|
|
})
|
|
|
|
it('preserves a user-entered webhookId on an action block', () => {
|
|
const { newId, result } = pasteOne(
|
|
{
|
|
type: 'discord',
|
|
name: 'Discord 1',
|
|
triggerMode: false,
|
|
subBlocks: {
|
|
webhookId: { id: 'webhookId', type: 'short-input', value: '1234567890' },
|
|
webhookToken: { id: 'webhookToken', type: 'short-input', value: 'tok_abc' },
|
|
},
|
|
},
|
|
{ webhookId: '1234567890', webhookToken: 'tok_abc' }
|
|
)
|
|
|
|
expect(result.subBlockValues[newId].webhookId).toBe('1234567890')
|
|
expect(result.subBlockValues[newId].webhookToken).toBe('tok_abc')
|
|
})
|
|
|
|
/** Trigger configuration is user setup and must survive the copy. */
|
|
it('preserves trigger configuration on a cloned trigger block', () => {
|
|
const { newId, result } = pasteOne(
|
|
{
|
|
type: 'generic_webhook',
|
|
name: 'Webhook 1',
|
|
triggerMode: true,
|
|
subBlocks: {
|
|
triggerPath: { id: 'triggerPath', type: 'short-input', value: deployedPath },
|
|
triggerConfig: { id: 'triggerConfig', type: 'short-input', value: { labelIds: ['a'] } },
|
|
triggerId: { id: 'triggerId', type: 'short-input', value: 'generic_webhook' },
|
|
token: { id: 'token', type: 'short-input', value: 'user-secret' },
|
|
},
|
|
},
|
|
{
|
|
triggerPath: deployedPath,
|
|
triggerConfig: { labelIds: ['a'] },
|
|
triggerId: 'generic_webhook',
|
|
token: 'user-secret',
|
|
}
|
|
)
|
|
|
|
expect(result.subBlockValues[newId].triggerPath).toBeNull()
|
|
expect(result.subBlockValues[newId].triggerConfig).toEqual({ labelIds: ['a'] })
|
|
expect(result.subBlockValues[newId].triggerId).toBe('generic_webhook')
|
|
expect(result.subBlockValues[newId].token).toBe('user-secret')
|
|
})
|
|
})
|