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

752 lines
24 KiB
TypeScript

/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { getBlock } from '@/blocks/registry'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import {
collectForkDependentReconfigs,
collectForkResourceUsages,
} from '@/ee/workspace-forking/lib/mapping/dependent-reconfigs'
import {
buildForkBlockIdResolver,
deriveForkBlockId,
EMPTY_FORK_BLOCK_MAP,
} from '@/ee/workspace-forking/lib/remap/block-identity'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
({ name: 'Test', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig
const sourceState = (
blockType: string,
subBlocks: Record<string, { value: unknown }>
): WorkflowState =>
({
blocks: { 'block-1': { id: 'block-1', type: blockType, name: 'Block', subBlocks } },
edges: [],
loops: {},
parallels: {},
variables: {},
}) as unknown as WorkflowState
const replaceItem = {
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
mode: 'replace' as const,
}
// No persisted block map in these unit tests, so the resolver derives - matching the
// `deriveForkBlockId(...)` ids the expectations assert.
const resolve = buildForkBlockIdResolver(true, EMPTY_FORK_BLOCK_MAP)
describe('collectForkDependentReconfigs', () => {
it("emits the active operation's credential-dependent selector (condition-gated)", () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{ id: 'operation', title: 'Operation', type: 'dropdown' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
required: true,
condition: { field: 'operation', value: 'read' },
},
// A different operation's variant -> excluded by its condition, not by emptiness.
{
id: 'otherFolder',
title: 'Move To Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
condition: { field: 'operation', value: 'move' },
},
])
)
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('gmail', {
credential: { value: 'cred-src' },
operation: { value: 'read' },
folder: { value: 'INBOX' },
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toEqual([
{
parentKind: 'credential',
parentSourceId: 'cred-src',
parentContextKey: 'oauthCredential',
targetWorkflowId: 'wf-tgt',
targetBlockId: deriveForkBlockId('wf-tgt', 'block-1'),
blockName: 'Block',
subBlockKey: 'folder',
selectorKey: 'gmail.labels',
title: 'Label',
currentValue: 'INBOX',
required: true,
consumesContextKeys: [],
context: {},
sourceValue: 'INBOX',
},
])
})
it('skips an anchor whose canonical pair is in advanced (manual) mode - the value passes through', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{
id: 'knowledgeBaseSelector',
title: 'Knowledge Base',
type: 'knowledge-base-selector',
canonicalParamId: 'knowledgeBaseId',
mode: 'basic',
},
{
id: 'manualKnowledgeBaseId',
title: 'KB ID',
type: 'short-input',
canonicalParamId: 'knowledgeBaseId',
mode: 'advanced',
},
{
id: 'documentSelector',
title: 'Document',
type: 'document-selector',
selectorKey: 'knowledge.documents',
dependsOn: ['knowledgeBaseSelector'],
required: true,
},
])
)
// Advanced mode active: the dormant basic selector is empty; the active manual id holds the KB.
const advancedState = {
blocks: {
'block-1': {
id: 'block-1',
type: 'knowledge',
name: 'Block',
data: { canonicalModes: { knowledgeBaseId: 'advanced' } },
subBlocks: {
knowledgeBaseSelector: { value: '' },
manualKnowledgeBaseId: { value: 'kb-active' },
documentSelector: { value: 'doc-1' },
},
},
},
edges: [],
loops: {},
parallels: {},
variables: {},
} as unknown as WorkflowState
const result = collectForkDependentReconfigs(
[replaceItem],
new Map([['wf-src', advancedState]]),
resolve
)
// Advanced (manual) mode: the manual KB id is user-owned and passes through verbatim on
// sync - it is never remapped, so its dependents are never cleared and there is nothing
// to re-pick. No reconfig is offered.
expect(result).toEqual([])
})
it('emits a knowledge-base-dependent document selector', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{
id: 'knowledgeBaseSelector',
title: 'Knowledge Base',
type: 'knowledge-base-selector',
canonicalParamId: 'knowledgeBaseId',
},
{
id: 'documentSelector',
title: 'Document',
type: 'document-selector',
canonicalParamId: 'documentId',
selectorKey: 'knowledge.documents',
dependsOn: ['knowledgeBaseSelector'],
required: true,
},
])
)
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('knowledge', {
knowledgeBaseSelector: { value: 'kb-src' },
documentSelector: { value: 'doc-src' },
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toEqual([
{
parentKind: 'knowledge-base',
parentSourceId: 'kb-src',
parentContextKey: 'knowledgeBaseId',
targetWorkflowId: 'wf-tgt',
targetBlockId: deriveForkBlockId('wf-tgt', 'block-1'),
blockName: 'Block',
subBlockKey: 'documentSelector',
selectorKey: 'knowledge.documents',
title: 'Document',
currentValue: 'doc-src',
required: true,
consumesContextKeys: [],
context: {},
sourceValue: 'doc-src',
},
])
})
it('offers an active credential selector even when the source left it empty', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
},
])
)
// The source has the credential but no label - the user must still be able to set one
// during the swap (a prior sync may have cleared it), so the selector is offered.
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('gmail', { credential: { value: 'cred-src' }, folder: { value: '' } }),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ subBlockKey: 'folder', parentSourceId: 'cred-src' })
})
it('still skips a selector whose parent credential is unset', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
},
])
)
const states = new Map<string, WorkflowState>([
['wf-src', sourceState('gmail', { credential: { value: '' }, folder: { value: 'INBOX' } })],
])
expect(collectForkDependentReconfigs([replaceItem], states, resolve)).toEqual([])
})
it('skips create-mode targets', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
},
])
)
const created = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('gmail', { credential: { value: 'cred-src' }, folder: { value: 'INBOX' } }),
],
])
expect(
collectForkDependentReconfigs(
[{ sourceWorkflowId: 'wf-src', targetWorkflowId: 'wf-tgt', mode: 'create' }],
created,
resolve
)
).toEqual([])
})
it('walks the transitive chain and tags the context key a re-pick provides', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'spreadsheetId',
title: 'Spreadsheet',
type: 'file-selector',
canonicalParamId: 'spreadsheetId',
selectorKey: 'google.drive',
dependsOn: ['credential'],
},
{
id: 'sheetName',
title: 'Sheet',
type: 'sheet-selector',
selectorKey: 'google.sheets',
dependsOn: ['spreadsheetId'],
required: true,
},
])
)
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('google_sheets', {
credential: { value: 'cred-src' },
spreadsheetId: { value: 'ss-src' },
sheetName: { value: 'Sheet1' },
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
// Both the spreadsheet (direct) and its sheet (transitive) are offered, in order.
expect(result.map((entry) => entry.subBlockKey)).toEqual(['spreadsheetId', 'sheetName'])
const spreadsheet = result.find((entry) => entry.subBlockKey === 'spreadsheetId')
expect(spreadsheet?.parentKind).toBe('credential')
expect(spreadsheet?.providesContextKey).toBe('spreadsheetId')
const sheet = result.find((entry) => entry.subBlockKey === 'sheetName')
expect(sheet?.parentKind).toBe('credential')
expect(sheet?.required).toBe(true)
// The sheet consumes the spreadsheet's key, so the modal gates it on that re-pick.
expect(sheet?.consumesContextKeys).toEqual(['spreadsheetId'])
// The source spreadsheet rides in context; the modal overlays the re-picked one.
expect(sheet?.context.spreadsheetId).toBe('ss-src')
})
it('emits a credential-dependent selector nested inside a tool-input tool', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail')
return blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
required: true,
},
])
return undefined as unknown as BlockConfig
})
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('agent', {
tools: {
value: [
{
type: 'gmail',
title: 'Gmail 1',
params: { credential: 'cred-src', folder: 'INBOX' },
},
],
},
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toEqual([
{
parentKind: 'credential',
parentSourceId: 'cred-src',
parentContextKey: 'oauthCredential',
targetWorkflowId: 'wf-tgt',
targetBlockId: deriveForkBlockId('wf-tgt', 'block-1'),
blockName: 'Block',
subBlockKey: 'tools[0].folder',
selectorKey: 'gmail.labels',
title: 'Label',
toolName: 'Gmail 1',
currentValue: 'INBOX',
required: true,
consumesContextKeys: [],
context: {},
sourceValue: 'INBOX',
},
])
})
it('honors a nested tool-scoped advanced override (manual mode passes through - no re-pick)', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail')
return blockWith([
{
id: 'credential',
title: 'Credential',
type: 'oauth-input',
canonicalParamId: 'credential',
mode: 'basic',
},
{
id: 'manualCredential',
title: 'Credential ID',
type: 'short-input',
canonicalParamId: 'credential',
mode: 'advanced',
},
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
required: true,
},
])
return undefined as unknown as BlockConfig
})
// Agent block with a nested gmail tool; the dormant basic credential holds a stale id while the
// tool-scoped `0:credential` override (when present) marks advanced as active.
const agentState = (canonicalModes?: Record<string, 'basic' | 'advanced'>) =>
({
blocks: {
'block-1': {
id: 'block-1',
type: 'agent',
name: 'Block',
data: canonicalModes ? { canonicalModes } : {},
subBlocks: {
tools: {
value: [
{
type: 'gmail',
title: 'Gmail 1',
params: {
credential: 'cred-stale',
manualCredential: 'cred-active',
folder: 'INBOX',
},
},
],
},
},
},
},
edges: [],
loops: {},
parallels: {},
variables: {},
}) as unknown as WorkflowState
// Scoped override present -> advanced (manual) mode: the manual credential passes through
// verbatim on sync, so its dependents are never cleared and no re-pick is offered.
const withOverride = collectForkDependentReconfigs(
[replaceItem],
new Map([['wf-src', agentState({ '0:credential': 'advanced' })]]),
resolve
)
expect(withOverride).toEqual([])
// Control: no override -> the value heuristic keeps the non-empty basic (unchanged behavior).
const heuristic = collectForkDependentReconfigs(
[replaceItem],
new Map([['wf-src', agentState()]]),
resolve
)
expect(heuristic).toHaveLength(1)
expect(heuristic[0]).toMatchObject({
parentSourceId: 'cred-stale',
subBlockKey: 'tools[0].folder',
})
})
it('regression: two same-type nested tools resolve their canonical-mode override independently', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail')
return blockWith([
{
id: 'credential',
title: 'Credential',
type: 'oauth-input',
canonicalParamId: 'credential',
mode: 'basic',
},
{
id: 'manualCredential',
title: 'Credential ID',
type: 'short-input',
canonicalParamId: 'credential',
mode: 'advanced',
},
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
required: true,
},
])
return undefined as unknown as BlockConfig
})
const states = new Map<string, WorkflowState>([
[
'wf-src',
{
blocks: {
'block-1': {
id: 'block-1',
type: 'agent',
name: 'Block',
// Tool #0 is scoped to advanced (manual mode passes through - no re-pick, per the
// test above); tool #1 is scoped to basic (re-pick offered on its own value). Both
// are the SAME tool type ("gmail") and the SAME canonicalId ("credential"), so only
// the per-instance index can tell them apart - before the fix both shared the single
// `gmail:credential` key, which would have made tool #1 advanced-active too (and
// therefore ALSO skipped, dropping the result to zero entries instead of one).
data: { canonicalModes: { '0:credential': 'advanced', '1:credential': 'basic' } },
subBlocks: {
tools: {
value: [
{
type: 'gmail',
title: 'Gmail 1',
params: {
credential: 'cred-0-stale',
manualCredential: 'cred-0-active',
folder: 'INBOX',
},
},
{
type: 'gmail',
title: 'Gmail 2',
params: {
credential: 'cred-1-basic',
manualCredential: 'cred-1-stale',
folder: 'SENT',
},
},
],
},
},
},
},
edges: [],
loops: {},
parallels: {},
variables: {},
} as unknown as WorkflowState,
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
parentSourceId: 'cred-1-basic',
subBlockKey: 'tools[1].folder',
})
})
it('offers a nested tool selector even when the source left it empty', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail')
return blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
},
])
return undefined as unknown as BlockConfig
})
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('agent', {
tools: {
value: [
{ type: 'gmail', title: 'Gmail 1', params: { credential: 'cred-src', folder: '' } },
],
},
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
subBlockKey: 'tools[0].folder',
title: 'Label',
toolName: 'Gmail 1',
})
})
it('evaluates a nested tool selector condition against the tool-level operation', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail')
return blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
// Active only under read - and `operation` lives at the tool level, not params.
condition: { field: 'operation', value: 'read_gmail' },
},
])
return undefined as unknown as BlockConfig
})
const reading = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('agent', {
tools: {
value: [
{
type: 'gmail',
title: 'Gmail',
operation: 'read_gmail',
params: { credential: 'cred-src', folder: 'INBOX' },
},
],
},
}),
],
])
expect(collectForkDependentReconfigs([replaceItem], reading, resolve)).toHaveLength(1)
// Same tool under a different operation -> the read-only label is gated off.
const sending = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('agent', {
tools: {
value: [
{
type: 'gmail',
title: 'Gmail',
operation: 'send_gmail',
params: { credential: 'cred-src', folder: 'INBOX' },
},
],
},
}),
],
])
expect(collectForkDependentReconfigs([replaceItem], sending, resolve)).toEqual([])
})
it('anchors on a table selector for its column dependents', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{
id: 'tableSelector',
title: 'Table',
type: 'table-selector',
canonicalParamId: 'tableId',
},
{
id: 'conflictColumnSelector',
title: 'Column',
type: 'column-selector',
canonicalParamId: 'conflictColumn',
selectorKey: 'table.columns',
dependsOn: ['tableSelector'],
},
])
)
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('table', {
tableSelector: { value: 'tbl-src' },
conflictColumnSelector: { value: 'col1' },
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
parentKind: 'table',
parentSourceId: 'tbl-src',
parentContextKey: 'tableId',
subBlockKey: 'conflictColumnSelector',
selectorKey: 'table.columns',
})
})
})
describe('collectForkResourceUsages', () => {
const usageItem = (
sourceWorkflowId: string,
targetWorkflowId: string,
name: string,
mode: 'create' | 'replace' = 'replace'
) => ({ sourceWorkflowId, targetWorkflowId, mode, sourceMeta: { name } })
// The reference scan reads each subblock entry's own `type`, so credential usages need
// typed entries (unlike the dependent collector, which keys off the block config).
const credentialState = (credentialId: string): WorkflowState =>
({
blocks: {
'block-1': {
id: 'block-1',
type: 'gmail',
name: 'Block',
subBlocks: { credential: { id: 'credential', type: 'oauth-input', value: credentialId } },
},
},
edges: [],
loops: {},
parallels: {},
variables: {},
}) as unknown as WorkflowState
it('lists each replace workflow a resource is used in, with its (target) name', () => {
const states = new Map<string, WorkflowState>([
['wf-a', credentialState('cred-src')],
['wf-b', credentialState('cred-src')],
])
const result = collectForkResourceUsages(
[usageItem('wf-a', 'wf-tgt-a', 'Workflow A'), usageItem('wf-b', 'wf-tgt-b', 'Workflow B')],
states
)
expect(result).toEqual([
{
parentKind: 'credential',
parentSourceId: 'cred-src',
workflows: [
{ workflowId: 'wf-tgt-a', workflowName: 'Workflow A' },
{ workflowId: 'wf-tgt-b', workflowName: 'Workflow B' },
],
},
])
})
it('includes create-mode targets (never-synced workflows count toward the next sync)', () => {
const states = new Map<string, WorkflowState>([['wf-a', credentialState('cred-src')]])
expect(
collectForkResourceUsages([usageItem('wf-a', 'wf-tgt-a', 'A', 'create')], states)
).toEqual([
{
parentKind: 'credential',
parentSourceId: 'cred-src',
workflows: [{ workflowId: 'wf-tgt-a', workflowName: 'A' }],
},
])
})
})