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
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
createLargeArrayManifest,
|
|
isLargeArrayManifest,
|
|
} from '@/lib/execution/payloads/large-array-manifest'
|
|
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
|
|
import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server'
|
|
import type { ResolutionContext } from './reference'
|
|
import { WorkflowResolver } from './workflow'
|
|
|
|
vi.mock('@/lib/workflows/variables/variable-manager', () => ({
|
|
VariableManager: {
|
|
resolveForExecution: vi.fn((value) => value),
|
|
},
|
|
}))
|
|
|
|
/**
|
|
* Creates a minimal ResolutionContext for testing.
|
|
* The WorkflowResolver only uses context.executionContext.workflowVariables,
|
|
* so we only need to provide that field.
|
|
*/
|
|
function createTestContext(workflowVariables: Record<string, any>): ResolutionContext {
|
|
return {
|
|
executionContext: {
|
|
workflowVariables,
|
|
workspaceId: 'workspace-1',
|
|
workflowId: 'workflow-1',
|
|
executionId: 'execution-1',
|
|
},
|
|
executionState: {},
|
|
currentNodeId: 'test-node',
|
|
} as ResolutionContext
|
|
}
|
|
|
|
describe('WorkflowResolver', () => {
|
|
describe('canResolve', () => {
|
|
it.concurrent('should return true for variable references', () => {
|
|
const resolver = new WorkflowResolver({})
|
|
expect(resolver.canResolve('<variable.myvar>')).toBe(true)
|
|
expect(resolver.canResolve('<variable.test>')).toBe(true)
|
|
})
|
|
|
|
it.concurrent('should return false for non-variable references', () => {
|
|
const resolver = new WorkflowResolver({})
|
|
expect(resolver.canResolve('<block.output>')).toBe(false)
|
|
expect(resolver.canResolve('<loop.index>')).toBe(false)
|
|
expect(resolver.canResolve('plain text')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('resolve with normalized matching', () => {
|
|
it.concurrent('should resolve variable with exact name match', () => {
|
|
const variables = {
|
|
'var-1': { id: 'var-1', name: 'myvar', type: 'plain', value: 'test-value' },
|
|
}
|
|
const resolver = new WorkflowResolver(variables)
|
|
|
|
const result = resolver.resolve('<variable.myvar>', createTestContext(variables))
|
|
expect(result).toBe('test-value')
|
|
})
|
|
|
|
it.concurrent('should resolve variable with normalized name (lowercase)', () => {
|
|
const variables = {
|
|
'var-1': { id: 'var-1', name: 'MyVar', type: 'plain', value: 'test-value' },
|
|
}
|
|
const resolver = new WorkflowResolver(variables)
|
|
|
|
const result = resolver.resolve('<variable.myvar>', createTestContext(variables))
|
|
expect(result).toBe('test-value')
|
|
})
|
|
|
|
it.concurrent('should resolve variable with normalized name (spaces removed)', () => {
|
|
const variables = {
|
|
'var-1': { id: 'var-1', name: 'My Variable', type: 'plain', value: 'test-value' },
|
|
}
|
|
const resolver = new WorkflowResolver(variables)
|
|
|
|
const result = resolver.resolve('<variable.myvariable>', createTestContext(variables))
|
|
expect(result).toBe('test-value')
|
|
})
|
|
|
|
it.concurrent(
|
|
'should resolve variable with fully normalized name (JIRA TEAM UUID case)',
|
|
() => {
|
|
const variables = {
|
|
'var-1': { id: 'var-1', name: 'JIRA TEAM UUID', type: 'plain', value: 'uuid-123' },
|
|
}
|
|
const resolver = new WorkflowResolver(variables)
|
|
|
|
const result = resolver.resolve('<variable.jirateamuuid>', createTestContext(variables))
|
|
expect(result).toBe('uuid-123')
|
|
}
|
|
)
|
|
|
|
it.concurrent('should resolve variable regardless of reference case', () => {
|
|
const variables = {
|
|
'var-1': { id: 'var-1', name: 'jirateamuuid', type: 'plain', value: 'uuid-123' },
|
|
}
|
|
const resolver = new WorkflowResolver(variables)
|
|
|
|
const result = resolver.resolve('<variable.JIRATEAMUUID>', createTestContext(variables))
|
|
expect(result).toBe('uuid-123')
|
|
})
|
|
|
|
it.concurrent('should resolve by variable ID (exact match)', () => {
|
|
const variables = {
|
|
'my-uuid-id': { id: 'my-uuid-id', name: 'Some Name', type: 'plain', value: 'id-value' },
|
|
}
|
|
const resolver = new WorkflowResolver(variables)
|
|
|
|
const result = resolver.resolve('<variable.my-uuid-id>', createTestContext(variables))
|
|
expect(result).toBe('id-value')
|
|
})
|
|
|
|
it.concurrent('should return undefined for non-existent variable', () => {
|
|
const variables = {
|
|
'var-1': { id: 'var-1', name: 'existing', type: 'plain', value: 'test' },
|
|
}
|
|
const resolver = new WorkflowResolver(variables)
|
|
|
|
const result = resolver.resolve('<variable.nonexistent>', createTestContext(variables))
|
|
expect(result).toBeUndefined()
|
|
})
|
|
|
|
it.concurrent('should handle nested path access', () => {
|
|
const variables = {
|
|
'var-1': {
|
|
id: 'var-1',
|
|
name: 'config',
|
|
type: 'object',
|
|
value: { nested: { value: 'deep' } },
|
|
},
|
|
}
|
|
const resolver = new WorkflowResolver(variables)
|
|
|
|
const result = resolver.resolve(
|
|
'<variable.config.nested.value>',
|
|
createTestContext(variables)
|
|
)
|
|
expect(result).toBe('deep')
|
|
})
|
|
|
|
it.concurrent('should resolve with mixed case and spaces in reference', () => {
|
|
const variables = {
|
|
'var-1': { id: 'var-1', name: 'api key', type: 'plain', value: 'secret-key' },
|
|
}
|
|
const resolver = new WorkflowResolver(variables)
|
|
|
|
const result = resolver.resolve('<variable.APIKEY>', createTestContext(variables))
|
|
expect(result).toBe('secret-key')
|
|
})
|
|
|
|
it.concurrent('should handle real-world variable naming patterns', () => {
|
|
const testCases = [
|
|
{ varName: 'User ID', refName: 'userid', value: 'user-123' },
|
|
{ varName: 'API Key', refName: 'apikey', value: 'key-456' },
|
|
{ varName: 'STRIPE SECRET KEY', refName: 'stripesecretkey', value: 'sk_test' },
|
|
{ varName: 'Database URL', refName: 'databaseurl', value: 'postgres://...' },
|
|
]
|
|
|
|
for (const { varName, refName, value } of testCases) {
|
|
const variables = {
|
|
'var-1': { id: 'var-1', name: varName, type: 'plain', value },
|
|
}
|
|
const resolver = new WorkflowResolver(variables)
|
|
|
|
const result = resolver.resolve(`<variable.${refName}>`, createTestContext(variables))
|
|
expect(result).toBe(value)
|
|
}
|
|
})
|
|
|
|
it('returns whole large workflow variable manifests only when refs are allowed', async () => {
|
|
const compacted = await compactExecutionPayload(
|
|
Array.from({ length: 100 }, (_, index) => ({
|
|
key: `SIM-${index}`,
|
|
summary: 'Issue summary that keeps each item small',
|
|
})),
|
|
{
|
|
thresholdBytes: 256,
|
|
workspaceId: 'workspace-1',
|
|
workflowId: 'workflow-1',
|
|
executionId: 'execution-1',
|
|
}
|
|
)
|
|
const variables = {
|
|
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: compacted },
|
|
}
|
|
const resolver = new WorkflowResolver(variables, navigatePathAsync)
|
|
const context = createTestContext(variables)
|
|
|
|
expect(() => resolver.resolve('<variable.issues>', context)).toThrow('too large to inline')
|
|
await expect(resolver.resolveAsync('<variable.issues>', context)).rejects.toThrow(
|
|
'too large to inline'
|
|
)
|
|
|
|
const allowedContext = { ...context, allowLargeValueRefs: true }
|
|
expect(isLargeArrayManifest(resolver.resolve('<variable.issues>', allowedContext))).toBe(true)
|
|
await expect(resolver.resolveAsync('<variable.issues>', allowedContext)).resolves.toEqual(
|
|
compacted
|
|
)
|
|
})
|
|
|
|
it('resolves nested paths through async large workflow variable navigation', async () => {
|
|
const compacted = await compactExecutionPayload(
|
|
Array.from({ length: 100 }, (_, index) => ({
|
|
key: `SIM-${index + 1}`,
|
|
fields: { summary: index === 0 ? 'Large issue' : 'Other issue' },
|
|
})),
|
|
{
|
|
thresholdBytes: 256,
|
|
workspaceId: 'workspace-1',
|
|
workflowId: 'workflow-1',
|
|
executionId: 'execution-1',
|
|
}
|
|
)
|
|
const variables = {
|
|
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: compacted },
|
|
}
|
|
const resolver = new WorkflowResolver(variables, navigatePathAsync)
|
|
|
|
await expect(
|
|
resolver.resolveAsync('<variable.issues.0.fields.summary>', createTestContext(variables))
|
|
).resolves.toBe('Large issue')
|
|
})
|
|
|
|
it('preserves large array manifest workflow variables without array coercion', async () => {
|
|
const manifest = await createLargeArrayManifest([{ key: 'SIM-1' }], {
|
|
workspaceId: 'workspace-1',
|
|
workflowId: 'workflow-1',
|
|
executionId: 'execution-1',
|
|
})
|
|
const variables = {
|
|
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: manifest },
|
|
}
|
|
const resolver = new WorkflowResolver(variables, navigatePathAsync)
|
|
|
|
const allowedContext = { ...createTestContext(variables), allowLargeValueRefs: true }
|
|
|
|
expect(resolver.resolve('<variable.issues>', allowedContext)).toEqual(manifest)
|
|
await expect(resolver.resolveAsync('<variable.issues>', allowedContext)).resolves.toEqual(
|
|
manifest
|
|
)
|
|
})
|
|
|
|
it('resolves bracket-indexed paths through manifest workflow variables', async () => {
|
|
const manifest = await createLargeArrayManifest([{ key: 'SIM-1' }], {
|
|
workspaceId: 'workspace-1',
|
|
workflowId: 'workflow-1',
|
|
executionId: 'execution-1',
|
|
})
|
|
const variables = {
|
|
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: manifest },
|
|
}
|
|
const resolver = new WorkflowResolver(variables, navigatePathAsync)
|
|
|
|
await expect(
|
|
resolver.resolveAsync('<variable.issues[0].key>', createTestContext(variables))
|
|
).resolves.toBe('SIM-1')
|
|
})
|
|
|
|
it('resolves manifest array length without materializing chunks', async () => {
|
|
const manifest = await createLargeArrayManifest([{ key: 'SIM-1' }, { key: 'SIM-2' }], {
|
|
workspaceId: 'workspace-1',
|
|
workflowId: 'workflow-1',
|
|
executionId: 'execution-1',
|
|
})
|
|
const variables = {
|
|
'var-1': { id: 'var-1', name: 'issues', type: 'array', value: manifest },
|
|
}
|
|
const resolver = new WorkflowResolver(variables, navigatePathAsync)
|
|
|
|
await expect(
|
|
resolver.resolveAsync('<variable.issues.length>', createTestContext(variables))
|
|
).resolves.toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('edge cases', () => {
|
|
it.concurrent('should handle empty workflow variables', () => {
|
|
const resolver = new WorkflowResolver({})
|
|
|
|
const result = resolver.resolve('<variable.anyvar>', createTestContext({}))
|
|
expect(result).toBeUndefined()
|
|
})
|
|
|
|
it.concurrent('should handle invalid reference format', () => {
|
|
const resolver = new WorkflowResolver({})
|
|
|
|
const result = resolver.resolve('<variable>', createTestContext({}))
|
|
expect(result).toBeUndefined()
|
|
})
|
|
|
|
it.concurrent('should handle null variable values in the map', () => {
|
|
const variables: Record<string, any> = {
|
|
'var-1': null,
|
|
'var-2': { id: 'var-2', name: 'valid', type: 'plain', value: 'exists' },
|
|
}
|
|
const resolver = new WorkflowResolver(variables)
|
|
|
|
const result = resolver.resolve('<variable.valid>', createTestContext(variables))
|
|
expect(result).toBe('exists')
|
|
})
|
|
|
|
it.concurrent('should handle variable with empty name', () => {
|
|
const variables = {
|
|
'var-1': { id: 'var-1', name: '', type: 'plain', value: 'empty-name' },
|
|
}
|
|
const resolver = new WorkflowResolver(variables)
|
|
|
|
// Empty name normalizes to empty string, which matches "<variable.>" reference
|
|
const result = resolver.resolve('<variable.>', createTestContext(variables))
|
|
expect(result).toBe('empty-name')
|
|
})
|
|
|
|
it.concurrent('should prefer name match over ID match when both could apply', () => {
|
|
const variables = {
|
|
apikey: { id: 'apikey', name: 'different', type: 'plain', value: 'by-id' },
|
|
'var-2': { id: 'var-2', name: 'apikey', type: 'plain', value: 'by-name' },
|
|
}
|
|
const resolver = new WorkflowResolver(variables)
|
|
|
|
const result = resolver.resolve('<variable.apikey>', createTestContext(variables))
|
|
expect(['by-id', 'by-name']).toContain(result)
|
|
})
|
|
})
|
|
})
|