Files
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

623 lines
22 KiB
TypeScript

/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ForkSyncBlocker } from '@/lib/api/contracts/workspace-fork'
const {
mockComputePlan,
mockBuildCopySelection,
mockHasCopySelection,
mockCopyUnmapped,
mockCollectBlockers,
mockLoadBlockMap,
mockBuildBlockIdResolver,
mockResolveFolderMapping,
mockUpsertPromoteRun,
mockLoadSourceDeployedStates,
mockGetUsersWithPermissions,
mockGetMcpServerMeta,
mockCreateTransform,
mockSumForkCopyBytes,
mockAssertForkStorageHeadroom,
} = vi.hoisted(() => ({
mockComputePlan: vi.fn(),
mockBuildCopySelection: vi.fn(),
mockHasCopySelection: vi.fn(),
mockCopyUnmapped: vi.fn(),
mockCollectBlockers: vi.fn(),
mockLoadBlockMap: vi.fn(),
mockBuildBlockIdResolver: vi.fn(),
mockResolveFolderMapping: vi.fn(),
mockUpsertPromoteRun: vi.fn(),
mockLoadSourceDeployedStates: vi.fn(),
mockGetUsersWithPermissions: vi.fn(),
mockGetMcpServerMeta: vi.fn(),
mockCreateTransform: vi.fn(),
mockSumForkCopyBytes: vi.fn(),
mockAssertForkStorageHeadroom: vi.fn(),
}))
vi.mock('@/lib/workflows/deployment-outbox', () => ({
enqueueWorkflowUndeploySideEffects: vi.fn(),
processWorkflowDeploymentOutboxEvent: vi.fn(),
}))
vi.mock('@/lib/workflows/orchestration/deploy', () => ({
performFullDeploy: vi.fn(async () => ({ success: true })),
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
undeployWorkflow: vi.fn(async () => ({ success: true })),
}))
vi.mock('@/ee/workspace-forking/lib/background-work/store', () => ({
startBackgroundWork: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/copy/content-copy-runner', () => ({
hasForkContentToCopy: vi.fn(() => false),
scheduleForkContentCopy: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-workflows', () => ({
copyWorkflowStateIntoTarget: vi.fn(),
loadTargetDraftSubBlocks: vi.fn(async () => new Map()),
loadWorkflowNameRegistry: vi.fn(async () => new Map()),
resolveForkFolderMapping: mockResolveFolderMapping,
}))
vi.mock('@/ee/workspace-forking/lib/copy/storage-quota', () => ({
sumForkCopyBytes: mockSumForkCopyBytes,
assertForkStorageHeadroom: mockAssertForkStorageHeadroom,
}))
vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({
getActiveDeploymentVersionNumbers: vi.fn(async () => new Map()),
loadSourceDeployedStates: mockLoadSourceDeployedStates,
}))
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
acquireForkEdgeLock: vi.fn(),
acquireForkTargetLock: vi.fn(),
setForkLockTimeout: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({
loadForkBlockMap: mockLoadBlockMap,
reconcileForkBlockPairs: vi.fn(),
toForkBlockPairs: vi.fn(() => []),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/dependent-value-store', () => ({
loadForkDependentValues: vi.fn(async () => []),
reconcileForkDependentValues: vi.fn(),
// Faithful mirror of the real pure translation (unit-tested in dependent-value-store.test.ts),
// so promote's apply/reconcile paths exercise the actual source-doc-id rewrite.
translateForkDependentValues: vi.fn(
(
values: Array<{ value: string }>,
resolve: (kind: string, sourceId: string) => string | null | undefined
) =>
values.map((entry) => {
if (entry.value === '') return entry
const translated = resolve('knowledge-document', entry.value)
return translated != null && translated !== entry.value
? { ...entry, value: translated }
: entry
})
),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
deleteWorkflowIdentityByIds: vi.fn(),
upsertEdgeMappings: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/promote/cleared-refs', () => ({
collectForkSyncBlockers: mockCollectBlockers,
}))
vi.mock('@/ee/workspace-forking/lib/promote/copy-unmapped', () => ({
// Faithful mirror of the real overlay so a copy's id maps resolve through the augmented
// resolver (the dependent-value translation and MCP meta read depend on it).
augmentForkResolver: vi.fn(
(
base: (kind: string, sourceId: string) => string | null | undefined,
extra: Map<string, Map<string, string>>
) =>
(kind: string, sourceId: string) =>
extra.get(kind)?.get(sourceId) ?? base(kind, sourceId)
),
buildPromoteCopySelection: mockBuildCopySelection,
copyPromoteUnmappedResources: mockCopyUnmapped,
hasPromoteCopySelection: mockHasCopySelection,
}))
vi.mock('@/ee/workspace-forking/lib/promote/promote-plan', () => ({
computeForkPromotePlan: mockComputePlan,
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-chats', () => ({
copyForkChatDeployments: vi.fn(async () => ({ created: 0 })),
}))
vi.mock('@/ee/workspace-forking/lib/copy/workflow-mcp-attachments', () => ({
reconcileForkWorkflowMcpAttachments: vi.fn(async () => ({ affectedServerIds: [] })),
}))
vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
notifyMcpToolServers: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/promote/promote-run-store', () => ({
upsertPromoteRun: mockUpsertPromoteRun,
}))
vi.mock('@/ee/workspace-forking/lib/mapping/resources', () => ({
getMcpServerMetaByIds: mockGetMcpServerMeta,
}))
vi.mock('@/ee/workspace-forking/lib/remap/block-identity', () => ({
buildForkBlockIdResolver: mockBuildBlockIdResolver,
}))
vi.mock('@/ee/workspace-forking/lib/remap/remap-references', () => ({
createForkSubBlockTransform: mockCreateTransform,
}))
vi.mock('@/ee/workspace-forking/lib/socket', () => ({
notifyForkWorkflowChanged: vi.fn(),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUsersWithPermissions: mockGetUsersWithPermissions,
}))
import { db } from '@sim/db'
import { copyWorkflowStateIntoTarget } from '@/ee/workspace-forking/lib/copy/copy-workflows'
import { reconcileForkDependentValues } from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
import { promoteFork } from '@/ee/workspace-forking/lib/promote/promote'
import type { ForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan'
const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' }
const EMPTY_SELECTION = {
customTools: [],
skills: [],
tables: [],
knowledgeBases: [],
files: [],
}
function makePlan(overrides: Partial<ForkPromotePlan> = {}): ForkPromotePlan {
return {
childWorkspaceId: EDGE.childWorkspaceId,
sourceWorkspaceId: 'src-ws',
targetWorkspaceId: 'tgt-ws',
direction: 'push',
resolver: () => null,
items: [],
workflowIdMap: new Map(),
archivedTargetIds: [],
archivedTargets: [],
references: [],
unmappedRequired: [],
unmappedOptional: [],
mcpReauthServerIds: [],
inlineSecretSources: [],
copyableUnmapped: [],
willUpdate: 0,
willCreate: 0,
willArchive: 0,
...overrides,
}
}
const BLOCKER: ForkSyncBlocker = {
workflowName: 'Caller',
blockLabel: 'Table Block',
fieldLabel: 'Table',
kind: 'table',
sourceId: 'tbl-1',
sourceLabel: 'Orders',
reason: 'unmapped-copyable',
}
function promoteParams() {
return {
edge: EDGE as never,
sourceWorkspaceId: 'src-ws',
targetWorkspaceId: 'tgt-ws',
direction: 'push' as const,
userId: 'user-1',
}
}
/** A copy result carrying no content/id maps, for tests that only need the copy to run. */
function emptyCopyResult() {
return {
contentPlan: {
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'tgt-ws',
userId: 'user-1',
tables: [],
knowledgeBases: [],
skills: [],
documents: [],
},
copyIdMapByKind: new Map(),
contentRefMaps: {},
blobTasks: [],
}
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(db.transaction).mockImplementation(
async (cb: (tx: unknown) => unknown) => cb({}) as never
)
mockGetUsersWithPermissions.mockResolvedValue([])
mockLoadSourceDeployedStates.mockResolvedValue({
deployedWorkflows: [],
sourceStates: new Map(),
})
mockComputePlan.mockResolvedValue(makePlan())
mockBuildCopySelection.mockReturnValue({
selection: EMPTY_SELECTION,
willResolve: new Set<string>(),
})
mockHasCopySelection.mockReturnValue(false)
mockCollectBlockers.mockResolvedValue([])
mockLoadBlockMap.mockResolvedValue(new Map())
mockBuildBlockIdResolver.mockReturnValue((_wf: string, blockId: string) => blockId)
mockResolveFolderMapping.mockResolvedValue(new Map())
mockUpsertPromoteRun.mockResolvedValue('run-1')
mockGetMcpServerMeta.mockResolvedValue(new Map())
mockCreateTransform.mockReturnValue((subBlocks: unknown) => subBlocks)
mockSumForkCopyBytes.mockResolvedValue(0)
mockAssertForkStorageHeadroom.mockResolvedValue(undefined)
})
describe('promoteFork gates', () => {
it('blocks an over-quota copy selection before any lock, read, or write', async () => {
mockSumForkCopyBytes.mockResolvedValue(999_999)
mockAssertForkStorageHeadroom.mockRejectedValue(
new Error(
'Not enough storage to copy the selected resources. Storage limit exceeded. Used: 10.50GB, Limit: 10GB'
)
)
await expect(
promoteFork({
...promoteParams(),
copyResources: { files: ['workspace/src-ws/key-1'], knowledgeBases: ['kb-1'] },
})
).rejects.toThrow('Not enough storage to copy the selected resources')
expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 999_999 })
// Fails fast: no source-state loads, no locked transaction, no writes of any kind.
expect(mockLoadSourceDeployedStates).not.toHaveBeenCalled()
expect(db.transaction).not.toHaveBeenCalled()
expect(mockUpsertPromoteRun).not.toHaveBeenCalled()
})
it('sums the requested copy selection bytes against the SOURCE workspace (files by key, KBs by id)', async () => {
await promoteFork({
...promoteParams(),
copyResources: {
files: ['workspace/src-ws/key-1'],
knowledgeBases: ['kb-1'],
tables: ['tbl-1'],
},
})
expect(mockSumForkCopyBytes).toHaveBeenCalledTimes(1)
expect(mockSumForkCopyBytes).toHaveBeenCalledWith(expect.anything(), 'src-ws', {
fileKeys: ['workspace/src-ws/key-1'],
knowledgeBaseIds: ['kb-1'],
})
})
it('blocks on unmapped required credentials/secrets BEFORE the cleared-refs gate runs', async () => {
mockComputePlan.mockResolvedValue(
makePlan({
unmappedRequired: [
{ kind: 'credential', sourceId: 'c1', subBlockKey: 'credential', required: true },
],
})
)
const result = await promoteFork(promoteParams())
expect(result.blocked).toBe('unmapped')
expect(result.unmappedRequired).toEqual([
{ kind: 'credential', sourceId: 'c1', required: true, blockName: undefined },
])
expect(result.blockers).toEqual([])
expect(mockCollectBlockers).not.toHaveBeenCalled()
expect(mockResolveFolderMapping).not.toHaveBeenCalled()
expect(mockUpsertPromoteRun).not.toHaveBeenCalled()
})
it('blocks with the structured blocker list when references would clear, writing NOTHING', async () => {
mockCollectBlockers.mockResolvedValue([BLOCKER])
const result = await promoteFork(promoteParams())
expect(result.blocked).toBe('cleared-refs')
expect(result.blockers).toEqual([BLOCKER])
expect(result.promoteRunId).toBe('')
expect(result.updated).toBe(0)
expect(result.created).toBe(0)
expect(result.archived).toBe(0)
// Blocked before the first write: no folder creation, no resource copy, no undo point.
expect(mockResolveFolderMapping).not.toHaveBeenCalled()
expect(mockCopyUnmapped).not.toHaveBeenCalled()
expect(mockUpsertPromoteRun).not.toHaveBeenCalled()
})
it('evaluates the gate against the plan resolver overlaid with the copy selection', async () => {
const planResolver = vi.fn(() => 'plan-resolved')
mockComputePlan.mockResolvedValue(makePlan({ resolver: planResolver }))
mockBuildCopySelection.mockReturnValue({
selection: EMPTY_SELECTION,
willResolve: new Set(['table:t1']),
})
await promoteFork(promoteParams())
expect(mockCollectBlockers).toHaveBeenCalledTimes(1)
const gateParams = mockCollectBlockers.mock.calls[0][0]
// A copy-selected reference resolves through the overlay (never hits the plan resolver);
// everything else falls through to the plan's persisted-mapping resolver.
expect(gateParams.resolver('table', 't1')).toBe('t1')
expect(planResolver).not.toHaveBeenCalled()
expect(gateParams.resolver('table', 't2')).toBe('plan-resolved')
expect(planResolver).toHaveBeenCalledWith('table', 't2')
})
it('threads the SAME block-id resolver into the gate and the resource copy as the workflow writes', async () => {
// Copied tables' workflow-group outputs must land on the block ids the sync actually writes
// (persisted pairs preferred over derive), so the copy receives the resolver built from the
// loaded block map - the identical instance the cleared-refs gate uses.
const resolver = (_workflowId: string, blockId: string) => `pair-${blockId}`
mockBuildBlockIdResolver.mockReturnValue(resolver)
mockHasCopySelection.mockReturnValue(true)
mockCopyUnmapped.mockResolvedValue({
contentPlan: {
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'tgt-ws',
userId: 'user-1',
tables: [],
knowledgeBases: [],
skills: [],
documents: [],
},
copyIdMapByKind: new Map(),
contentRefMaps: {},
blobTasks: [],
})
await promoteFork(promoteParams())
expect(mockCopyUnmapped).toHaveBeenCalledTimes(1)
expect(mockCopyUnmapped.mock.calls[0][0].resolveBlockId).toBe(resolver)
expect(mockCollectBlockers.mock.calls[0][0].resolveBlockId).toBe(resolver)
})
it('proceeds when zero references would clear (empty blocker list)', async () => {
const plan = makePlan()
mockComputePlan.mockResolvedValue(plan)
const result = await promoteFork(promoteParams())
expect(result.blocked).toBeNull()
expect(result.blockers).toEqual([])
expect(result.promoteRunId).toBe('run-1')
expect(mockCollectBlockers).toHaveBeenCalledWith(
expect.objectContaining({
sourceWorkspaceId: 'src-ws',
items: plan.items,
workflowIdMap: plan.workflowIdMap,
})
)
expect(mockUpsertPromoteRun).toHaveBeenCalledTimes(1)
})
it("threads the plan's unmapped references into the gate so it can reuse the plan's scan", async () => {
const unmappedOptional = [
{ kind: 'table' as const, sourceId: 'tbl-1', subBlockKey: 'tbl', required: false },
]
mockComputePlan.mockResolvedValue(makePlan({ unmappedOptional }))
await promoteFork(promoteParams())
expect(mockCollectBlockers).toHaveBeenCalledWith(
expect.objectContaining({ planUnmapped: unmappedOptional })
)
})
it('batch-loads the mapped TARGET MCP server rows and threads them into the subblock transform', async () => {
// Two references resolving to the SAME target and one unmapped: the read must cover the
// distinct mapped target ids only (one bounded query, unmapped ids dropped).
const resolver = (kind: string, id: string) => {
if (kind !== 'mcp-server') return null
if (id === 'srv-a' || id === 'srv-b') return 'srv-tgt'
return null
}
mockComputePlan.mockResolvedValue(
makePlan({
resolver,
references: [
{ kind: 'mcp-server', sourceId: 'srv-a', subBlockKey: 'tools', required: false },
{ kind: 'mcp-server', sourceId: 'srv-b', subBlockKey: 'server', required: false },
{ kind: 'mcp-server', sourceId: 'srv-unmapped', subBlockKey: 'tools', required: false },
],
})
)
mockGetMcpServerMeta.mockResolvedValue(
new Map([['srv-tgt', { name: 'Target Server', url: 'https://target.example/mcp' }]])
)
await promoteFork(promoteParams())
expect(mockGetMcpServerMeta).toHaveBeenCalledTimes(1)
expect(mockGetMcpServerMeta).toHaveBeenCalledWith(expect.anything(), 'tgt-ws', ['srv-tgt'])
// The transform receives a lookup resolving the TARGET id to its row metadata, so remapped
// tool-input entries rewrite their embedded serverUrl/serverName from the target server.
expect(mockCreateTransform).toHaveBeenCalledTimes(1)
const [, transformOptions] = mockCreateTransform.mock.calls[0]
expect(transformOptions.resolveMcpServerMeta('srv-tgt')).toEqual({
name: 'Target Server',
url: 'https://target.example/mcp',
})
expect(transformOptions.resolveMcpServerMeta('srv-unknown')).toBeUndefined()
})
})
describe('promoteFork dependent values', () => {
it('unions the dependent-value picks into the copy discovery set (a re-picked document must be copied)', async () => {
mockComputePlan.mockResolvedValue(
makePlan({
references: [
{
kind: 'knowledge-document',
sourceId: 'doc-a',
subBlockKey: 'documentSelector',
required: false,
},
],
})
)
// No container selection: the document candidates alone must trigger the copy pass.
mockHasCopySelection.mockReturnValue(false)
mockCopyUnmapped.mockResolvedValue(emptyCopyResult())
await promoteFork({
...promoteParams(),
dependentValues: [
// Duplicates the plan's own scan -> deduped.
{ workflowId: 'wf-t', blockId: 'b1', subBlockKey: 'documentSelector', value: 'doc-a' },
// A fresh pick the source state does not reference -> must join the discovery set.
{ workflowId: 'wf-t', blockId: 'b2', subBlockKey: 'documentSelector', value: 'doc-b' },
// Cleared values are skipped; non-document values ride along (DB-filtered downstream).
{ workflowId: 'wf-t', blockId: 'b3', subBlockKey: 'folder', value: '' },
{ workflowId: 'wf-t', blockId: 'b4', subBlockKey: 'folder', value: 'INBOX' },
],
})
expect(mockCopyUnmapped).toHaveBeenCalledTimes(1)
expect(mockCopyUnmapped.mock.calls[0][0].referencedDocumentIds).toEqual([
'doc-a',
'doc-b',
'INBOX',
])
})
it('translates a source document id under a copy-resolved KB for BOTH the written state and the store', async () => {
const item = {
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
targetName: 'Flow',
mode: 'replace' as const,
sourceMeta: { name: 'Flow', description: null, folderId: null, sortOrder: 0 },
}
mockComputePlan.mockResolvedValue(makePlan({ items: [item] }))
mockLoadSourceDeployedStates.mockResolvedValue({
deployedWorkflows: [],
sourceStates: new Map([
['wf-src', { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} }],
]),
})
// The KB is copy-selected; the copy assigns the picked source document its copied id.
mockHasCopySelection.mockReturnValue(true)
mockCopyUnmapped.mockResolvedValue({
...emptyCopyResult(),
copyIdMapByKind: new Map([['knowledge-document', new Map([['doc-src', 'doc-copy']])]]),
})
vi.mocked(copyWorkflowStateIntoTarget).mockResolvedValue({
targetWorkflowId: 'wf-tgt',
mode: 'replace',
name: 'Flow',
blocksCount: 0,
edgesCount: 0,
subflowsCount: 0,
clearedDependents: [],
blockIdMapping: new Map(),
})
const result = await promoteFork({
...promoteParams(),
copyResources: { knowledgeBases: ['kb-src'] },
dependentValues: [
{
workflowId: 'wf-tgt',
blockId: 'blk-1',
subBlockKey: 'documentSelector',
value: 'doc-src',
},
],
})
expect(result.blocked).toBeNull()
// The apply map the workflow write receives carries the COPIED id: the dependent-value
// apply runs AFTER the reference remap and wins for its subblock, so a raw source id
// would clobber the remapped value in the written state.
expect(vi.mocked(copyWorkflowStateIntoTarget)).toHaveBeenCalledTimes(1)
const writeParams = vi.mocked(copyWorkflowStateIntoTarget).mock.calls[0][0]
expect(writeParams.dependentOverrides?.get('blk-1')?.get('documentSelector')).toBe('doc-copy')
// The store persists the translated value too, so the next sync (whose parent is then
// MAPPED via the persisted copy mapping) pre-fills a document id that resolves in the target.
expect(vi.mocked(reconcileForkDependentValues)).toHaveBeenCalledWith(
expect.anything(),
'child-ws',
['wf-tgt'],
[
{
targetWorkflowId: 'wf-tgt',
targetBlockId: 'blk-1',
subBlockKey: 'documentSelector',
value: 'doc-copy',
},
]
)
})
it('keeps a mapped parent dependent value verbatim (a target-space value never re-translates)', async () => {
const item = {
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
targetName: 'Flow',
mode: 'replace' as const,
sourceMeta: { name: 'Flow', description: null, folderId: null, sortOrder: 0 },
}
mockComputePlan.mockResolvedValue(makePlan({ items: [item] }))
mockLoadSourceDeployedStates.mockResolvedValue({
deployedWorkflows: [],
sourceStates: new Map([
['wf-src', { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} }],
]),
})
// The value joins the discovery candidates, so the copy pass runs - and resolves nothing.
mockCopyUnmapped.mockResolvedValue(emptyCopyResult())
vi.mocked(copyWorkflowStateIntoTarget).mockResolvedValue({
targetWorkflowId: 'wf-tgt',
mode: 'replace',
name: 'Flow',
blocksCount: 0,
edgesCount: 0,
subflowsCount: 0,
clearedDependents: [],
blockIdMapping: new Map(),
})
await promoteFork({
...promoteParams(),
dependentValues: [
{
workflowId: 'wf-tgt',
blockId: 'blk-1',
subBlockKey: 'documentSelector',
value: 'doc-tgt-existing',
},
],
})
const writeParams = vi.mocked(copyWorkflowStateIntoTarget).mock.calls[0][0]
expect(writeParams.dependentOverrides?.get('blk-1')?.get('documentSelector')).toBe(
'doc-tgt-existing'
)
expect(vi.mocked(reconcileForkDependentValues)).toHaveBeenCalledWith(
expect.anything(),
'child-ws',
['wf-tgt'],
[
{
targetWorkflowId: 'wf-tgt',
targetBlockId: 'blk-1',
subBlockKey: 'documentSelector',
value: 'doc-tgt-existing',
},
]
)
})
})