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
393 lines
18 KiB
TypeScript
393 lines
18 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
coerceObjectArray,
|
|
remapWorkflowReferencesInSubBlocks,
|
|
type SubBlockRecord,
|
|
} from '@/lib/workflows/persistence/remap-internal-ids'
|
|
|
|
describe('remapWorkflowReferencesInSubBlocks', () => {
|
|
const map = new Map([
|
|
['wf-src', 'wf-dst'],
|
|
['sub-src', 'sub-dst'],
|
|
])
|
|
|
|
it('remaps a top-level workflow-selector value', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
target: { id: 'target', type: 'workflow-selector', value: 'wf-src' },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
|
expect(result.target.value).toBe('wf-dst')
|
|
})
|
|
|
|
it('remaps a nested workflow_input tool workflowId in a tool-input array', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
tools: {
|
|
id: 'tools',
|
|
type: 'tool-input',
|
|
value: [
|
|
{ type: 'workflow_input', params: { workflowId: 'sub-src', inputMapping: '{}' } },
|
|
{ type: 'custom-tool', customToolId: 'ct-1' },
|
|
],
|
|
},
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
|
const tools = result.tools.value as Array<{ type: string; params?: { workflowId?: string } }>
|
|
expect(tools[0].params?.workflowId).toBe('sub-dst')
|
|
expect(tools[1]).toEqual({ type: 'custom-tool', customToolId: 'ct-1' })
|
|
})
|
|
|
|
it('handles a JSON-stringified tool-input value', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
tools: {
|
|
id: 'tools',
|
|
type: 'tool-input',
|
|
value: JSON.stringify([{ type: 'workflow_input', params: { workflowId: 'sub-src' } }]),
|
|
},
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
|
expect(result.tools.value).toBe(
|
|
JSON.stringify([{ type: 'workflow_input', params: { workflowId: 'sub-dst' } }])
|
|
)
|
|
})
|
|
|
|
it('leaves unknown workflow ids and non-workflow tools untouched', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
sel: { id: 'sel', type: 'workflow-selector', value: 'wf-unknown' },
|
|
tools: {
|
|
id: 'tools',
|
|
type: 'tool-input',
|
|
value: [{ type: 'workflow_input', params: { workflowId: 'wf-unknown' } }],
|
|
},
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
|
expect(result.sel.value).toBe('wf-unknown')
|
|
expect(result.tools).toBe(subBlocks.tools)
|
|
})
|
|
|
|
it('returns the input unchanged when the id map is empty', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
target: { id: 'target', type: 'workflow-selector', value: 'wf-src' },
|
|
}
|
|
expect(remapWorkflowReferencesInSubBlocks(subBlocks, new Map())).toBe(subBlocks)
|
|
})
|
|
|
|
it('clears an unmapped workflow-selector when clearUnmapped is set (cross-workspace)', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
sel: { id: 'sel', type: 'workflow-selector', value: 'wf-unknown' },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
expect(result.sel.value).toBe('')
|
|
})
|
|
|
|
it('drops an unmapped workflow_input tool when clearUnmapped is set', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
tools: {
|
|
id: 'tools',
|
|
type: 'tool-input',
|
|
value: [
|
|
{ type: 'workflow_input', params: { workflowId: 'wf-unknown' } },
|
|
{ type: 'workflow_input', params: { workflowId: 'sub-src' } },
|
|
],
|
|
},
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
const tools = result.tools.value as Array<{ params?: { workflowId?: string } }>
|
|
expect(tools).toHaveLength(1)
|
|
expect(tools[0].params?.workflowId).toBe('sub-dst')
|
|
})
|
|
|
|
it('clears the sibling inputMapping when an unmapped workflow selector is cleared (U10)', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-unknown' },
|
|
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
expect(result.workflowId.value).toBe('')
|
|
expect(result.inputMapping.value).toBe('')
|
|
})
|
|
|
|
it('keeps inputMapping when the workflow selector is remapped (not cleared)', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' },
|
|
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
expect(result.workflowId.value).toBe('wf-dst')
|
|
expect(result.inputMapping.value).toBe('{"a":"b"}')
|
|
})
|
|
|
|
// The `inputMapping` belongs to the ACTIVE canonical mode's workflow only. resolveCanonicalMode
|
|
// picks the active mode (block.data.canonicalModes override, else the value heuristic); the wipe
|
|
// fires iff the ACTIVE mode's workflow was removed by the remap. Only the SELECTOR is ever
|
|
// remapped/cleared - the manual member passes through verbatim - so an active-advanced (manual)
|
|
// mode never wipes inputMapping. clearUnmapped: true throughout.
|
|
it('keeps inputMapping: active basic valid + dormant advanced manual preserved (no override)', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' },
|
|
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' },
|
|
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
expect(result.workflowId.value).toBe('wf-dst')
|
|
// Manual member is user-owned: preserved verbatim (never cleared), even while dormant.
|
|
expect(result.manualWorkflowId.value).toBe('wf-unknown')
|
|
expect(result.inputMapping.value).toBe('{"a":"b"}')
|
|
})
|
|
|
|
it('keeps inputMapping: active advanced manual preserved (canonicalModes override) + dormant basic remapped', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' },
|
|
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' },
|
|
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, {
|
|
clearUnmapped: true,
|
|
canonicalModes: { workflowId: 'advanced' },
|
|
})
|
|
// Active advanced manual is preserved, so its inputMapping survives; the dormant basic selector
|
|
// still remaps.
|
|
expect(result.workflowId.value).toBe('wf-dst')
|
|
expect(result.manualWorkflowId.value).toBe('wf-unknown')
|
|
expect(result.inputMapping.value).toBe('{"a":"b"}')
|
|
})
|
|
|
|
it('wipes inputMapping: active basic selector cleared (heuristic) + dormant advanced manual preserved', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-unknown' },
|
|
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' },
|
|
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
expect(result.workflowId.value).toBe('')
|
|
// Manual preserved verbatim (not remapped to wf-dst); the active basic selector clearing is what
|
|
// wipes inputMapping.
|
|
expect(result.manualWorkflowId.value).toBe('wf-src')
|
|
expect(result.inputMapping.value).toBe('')
|
|
})
|
|
|
|
it('keeps inputMapping: active advanced manual preserved + basic empty (heuristic)', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
workflowId: { id: 'workflowId', type: 'workflow-selector', value: '' },
|
|
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' },
|
|
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
expect(result.manualWorkflowId.value).toBe('wf-unknown')
|
|
expect(result.inputMapping.value).toBe('{"a":"b"}')
|
|
})
|
|
|
|
it('keeps inputMapping: both modes valid (selector remapped, manual preserved)', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' },
|
|
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'sub-src' },
|
|
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
expect(result.workflowId.value).toBe('wf-dst')
|
|
expect(result.manualWorkflowId.value).toBe('sub-src')
|
|
expect(result.inputMapping.value).toBe('{"a":"b"}')
|
|
})
|
|
|
|
it('does not remap the advanced manualWorkflowId (manual is user-owned)', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
|
expect(result.manualWorkflowId.value).toBe('wf-src')
|
|
})
|
|
|
|
it('does not remap the manual comma-separated manualWorkflowIds list (manual is user-owned)', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
manualWorkflowIds: { id: 'manualWorkflowIds', type: 'short-input', value: 'wf-src, sub-src' },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
|
expect(result.manualWorkflowIds.value).toBe('wf-src, sub-src')
|
|
})
|
|
|
|
it('preserves the manual manualWorkflowIds list verbatim even under clearUnmapped', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
manualWorkflowIds: {
|
|
id: 'manualWorkflowIds',
|
|
type: 'short-input',
|
|
value: 'wf-src,wf-unknown',
|
|
},
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
expect(result.manualWorkflowIds.value).toBe('wf-src,wf-unknown')
|
|
})
|
|
|
|
// The advanced manual field is user-owned: ANY free-form value - env ref, literal id, tag, or
|
|
// arbitrary text - is preserved verbatim under clearUnmapped (active advanced), and its sibling
|
|
// inputMapping is never wiped. One passthrough covers every free-form edge case at once.
|
|
it.each([
|
|
['env ref', '{{MY_WORKFLOW_ID}}'],
|
|
['literal source-workspace id', 'wf-unknown'],
|
|
['block-output tag', '<start.workflowId>'],
|
|
['arbitrary text', 'not an id at all'],
|
|
])('preserves a manual %s value and its inputMapping (active advanced)', (_label, value) => {
|
|
const subBlocks: SubBlockRecord = {
|
|
workflowId: { id: 'workflowId', type: 'workflow-selector', value: '' },
|
|
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value },
|
|
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
expect(result.manualWorkflowId.value).toBe(value)
|
|
expect(result.inputMapping.value).toBe('{"a":"b"}')
|
|
})
|
|
|
|
// The one behavioral change vs. selector handling: a literal source-workspace id typed into the
|
|
// MANUAL field that WOULD map to a copied target is left AS-IS (not remapped), because manual is
|
|
// user-owned - while the SELECTOR with the same id still remaps to the copied target.
|
|
it('leaves a mapped literal id in the manual field as-is while the selector remaps it', () => {
|
|
const manualSubBlocks: SubBlockRecord = {
|
|
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' },
|
|
}
|
|
expect(
|
|
remapWorkflowReferencesInSubBlocks(manualSubBlocks, map, { clearUnmapped: true })
|
|
.manualWorkflowId.value
|
|
).toBe('wf-src')
|
|
|
|
const selectorSubBlocks: SubBlockRecord = {
|
|
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' },
|
|
}
|
|
expect(
|
|
remapWorkflowReferencesInSubBlocks(selectorSubBlocks, map, { clearUnmapped: true }).workflowId
|
|
.value
|
|
).toBe('wf-dst')
|
|
})
|
|
|
|
it('remaps a multi-select workflowSelector array', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
workflowSelector: { id: 'workflowSelector', type: 'dropdown', value: ['wf-src', 'sub-src'] },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
|
expect(result.workflowSelector.value).toEqual(['wf-dst', 'sub-dst'])
|
|
})
|
|
|
|
it('clears unmapped ids from the structured workflowSelector list under clearUnmapped', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
workflowSelector: {
|
|
id: 'workflowSelector',
|
|
type: 'dropdown',
|
|
value: ['wf-src', 'wf-unknown'],
|
|
},
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
expect(result.workflowSelector.value).toEqual(['wf-dst'])
|
|
})
|
|
|
|
// The sim workspace-event trigger's workflow filter: a multi-select `dropdown` with baseKey
|
|
// `workflowIds` whose options are workspace workflow ids - a structured (selector-sourced)
|
|
// list, remapped exactly like `workflowSelector`.
|
|
it('remaps the workspace-event trigger workflowIds dropdown list', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
workflowIds: { id: 'workflowIds', type: 'dropdown', value: ['wf-src', 'sub-src'] },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
|
expect(result.workflowIds.value).toEqual(['wf-dst', 'sub-dst'])
|
|
})
|
|
|
|
it('drops unmapped ids from the workflowIds dropdown under clearUnmapped', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
workflowIds: { id: 'workflowIds', type: 'dropdown', value: ['wf-src', 'wf-unknown'] },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
expect(result.workflowIds.value).toEqual(['wf-dst'])
|
|
})
|
|
|
|
// The TYPE gate: the legacy logs block's `workflowIds` is a free-form short-input (manual,
|
|
// user-owned), so it must pass through verbatim even though its baseKey matches.
|
|
it('leaves a short-input workflowIds (legacy logs block, user-owned) untouched under clearUnmapped', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
workflowIds: { id: 'workflowIds', type: 'short-input', value: 'wf-src,wf-unknown' },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
expect(result.workflowIds.value).toBe('wf-src,wf-unknown')
|
|
})
|
|
|
|
// The baseKey gate: dropdowns whose baseKey is neither `workflowSelector` nor `workflowIds`
|
|
// (event pickers, status filters, ...) hold non-workflow values and are never rewritten.
|
|
it('leaves other dropdowns untouched (only workflow-list baseKeys are remapped)', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
eventType: { id: 'eventType', type: 'dropdown', value: 'wf-src' },
|
|
level: { id: 'level', type: 'dropdown', value: ['wf-src'] },
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
expect(result.eventType.value).toBe('wf-src')
|
|
expect(result.level.value).toEqual(['wf-src'])
|
|
})
|
|
|
|
// create-fork scopes its workflow id map to the workflows ACTUALLY copied (deployed state loaded).
|
|
// With BOTH `wf-src` and `sub-src` copied, the SELECTOR varieties remap to the child ids; the
|
|
// free-form MANUAL varieties (`manualWorkflowId`, `manualWorkflowIds`) are user-owned and pass
|
|
// through verbatim, even under fork-create's clearUnmapped policy.
|
|
it('remaps selector varieties and preserves manual varieties when both workflows are copied (clearUnmapped)', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
selector: { id: 'selector', type: 'workflow-selector', value: 'wf-src' },
|
|
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
|
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'sub-src' },
|
|
manualWorkflowIds: { id: 'manualWorkflowIds', type: 'short-input', value: 'wf-src, sub-src' },
|
|
workflowSelector: { id: 'workflowSelector', type: 'dropdown', value: ['wf-src', 'sub-src'] },
|
|
tools: {
|
|
id: 'tools',
|
|
type: 'tool-input',
|
|
value: [
|
|
{ type: 'workflow_input', params: { workflowId: 'sub-src', inputMapping: '{}' } },
|
|
{ type: 'custom-tool', customToolId: 'ct-1' },
|
|
],
|
|
},
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
expect(result.selector.value).toBe('wf-dst')
|
|
expect(result.inputMapping.value).toBe('{"a":"b"}')
|
|
// Manual varieties pass through verbatim (not remapped to the child ids).
|
|
expect(result.manualWorkflowId.value).toBe('sub-src')
|
|
expect(result.manualWorkflowIds.value).toBe('wf-src, sub-src')
|
|
expect(result.workflowSelector.value).toEqual(['wf-dst', 'sub-dst'])
|
|
const tools = result.tools.value as Array<{ type: string; params?: { workflowId?: string } }>
|
|
expect(tools[0].params?.workflowId).toBe('sub-dst')
|
|
expect(tools[1]).toEqual({ type: 'custom-tool', customToolId: 'ct-1' })
|
|
})
|
|
|
|
// A deployed source workflow whose state failed to load is excluded from the scoped fork map, so a
|
|
// copied workflow's SELECTOR reference to it clears (never dangles at a never-created child id). The
|
|
// free-form manual list is user-owned and preserved verbatim.
|
|
it('clears selector references to a deployed-but-uncopied workflow (manual list preserved)', () => {
|
|
const subBlocks: SubBlockRecord = {
|
|
selector: { id: 'selector', type: 'workflow-selector', value: 'wf-uncopied' },
|
|
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
|
manualWorkflowIds: {
|
|
id: 'manualWorkflowIds',
|
|
type: 'short-input',
|
|
value: 'wf-src,wf-uncopied',
|
|
},
|
|
tools: {
|
|
id: 'tools',
|
|
type: 'tool-input',
|
|
value: [{ type: 'workflow_input', params: { workflowId: 'wf-uncopied' } }],
|
|
},
|
|
}
|
|
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
|
expect(result.selector.value).toBe('')
|
|
expect(result.inputMapping.value).toBe('')
|
|
expect(result.manualWorkflowIds.value).toBe('wf-src,wf-uncopied')
|
|
expect(result.tools.value as unknown[]).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('coerceObjectArray', () => {
|
|
it('returns arrays directly', () => {
|
|
expect(coerceObjectArray([{ a: 1 }])).toEqual({ array: [{ a: 1 }], wasString: false })
|
|
})
|
|
it('parses JSON-string arrays', () => {
|
|
expect(coerceObjectArray('[{"a":1}]')).toEqual({ array: [{ a: 1 }], wasString: true })
|
|
})
|
|
it('returns null for non-array values', () => {
|
|
expect(coerceObjectArray('hi')).toEqual({ array: null, wasString: false })
|
|
expect(coerceObjectArray(42)).toEqual({ array: null, wasString: false })
|
|
})
|
|
})
|