d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
409 lines
13 KiB
TypeScript
409 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
collectInputFormatFiles,
|
|
createDefaultInputFormatField,
|
|
extractInputFieldsFromBlocks,
|
|
isFileFieldType,
|
|
normalizeInputFormatValue,
|
|
parseInputFormatFiles,
|
|
} from '@/lib/workflows/input-format'
|
|
|
|
describe('extractInputFieldsFromBlocks', () => {
|
|
it.concurrent('returns empty array for null blocks', () => {
|
|
expect(extractInputFieldsFromBlocks(null)).toEqual([])
|
|
})
|
|
|
|
it.concurrent('returns empty array for undefined blocks', () => {
|
|
expect(extractInputFieldsFromBlocks(undefined)).toEqual([])
|
|
})
|
|
|
|
it.concurrent('returns empty array when no trigger block exists', () => {
|
|
const blocks = {
|
|
'block-1': { type: 'agent', subBlocks: {} },
|
|
'block-2': { type: 'function', subBlocks: {} },
|
|
}
|
|
expect(extractInputFieldsFromBlocks(blocks)).toEqual([])
|
|
})
|
|
|
|
it.concurrent('extracts fields from start_trigger block', () => {
|
|
const blocks = {
|
|
'trigger-1': {
|
|
type: 'start_trigger',
|
|
subBlocks: {
|
|
inputFormat: {
|
|
value: [
|
|
{ name: 'query', type: 'string' },
|
|
{ name: 'count', type: 'number' },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
expect(extractInputFieldsFromBlocks(blocks)).toEqual([
|
|
{ name: 'query', type: 'string' },
|
|
{ name: 'count', type: 'number' },
|
|
])
|
|
})
|
|
|
|
it.concurrent('preserves a stable field id when present, omits it when absent', () => {
|
|
const blocks = {
|
|
'trigger-1': {
|
|
type: 'start_trigger',
|
|
subBlocks: {
|
|
inputFormat: {
|
|
value: [
|
|
{ id: 'fld-1', name: 'query', type: 'string' },
|
|
{ name: 'count', type: 'number' },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
expect(extractInputFieldsFromBlocks(blocks)).toEqual([
|
|
{ id: 'fld-1', name: 'query', type: 'string' },
|
|
{ name: 'count', type: 'number' },
|
|
])
|
|
})
|
|
|
|
it.concurrent('extracts fields from input_trigger block', () => {
|
|
const blocks = {
|
|
'trigger-1': {
|
|
type: 'input_trigger',
|
|
subBlocks: {
|
|
inputFormat: {
|
|
value: [{ name: 'message', type: 'string' }],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
expect(extractInputFieldsFromBlocks(blocks)).toEqual([{ name: 'message', type: 'string' }])
|
|
})
|
|
|
|
it.concurrent('extracts fields from starter block', () => {
|
|
const blocks = {
|
|
'trigger-1': {
|
|
type: 'starter',
|
|
subBlocks: {
|
|
inputFormat: {
|
|
value: [{ name: 'input', type: 'string' }],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
expect(extractInputFieldsFromBlocks(blocks)).toEqual([{ name: 'input', type: 'string' }])
|
|
})
|
|
|
|
it.concurrent('defaults type to string when not provided', () => {
|
|
const blocks = {
|
|
'trigger-1': {
|
|
type: 'start_trigger',
|
|
subBlocks: {
|
|
inputFormat: {
|
|
value: [{ name: 'field1' }, { name: 'field2', type: 'number' }],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
expect(extractInputFieldsFromBlocks(blocks)).toEqual([
|
|
{ name: 'field1', type: 'string' },
|
|
{ name: 'field2', type: 'number' },
|
|
])
|
|
})
|
|
|
|
it.concurrent('filters out fields with empty names', () => {
|
|
const blocks = {
|
|
'trigger-1': {
|
|
type: 'start_trigger',
|
|
subBlocks: {
|
|
inputFormat: {
|
|
value: [
|
|
{ name: '', type: 'string' },
|
|
{ name: 'valid', type: 'string' },
|
|
{ name: ' ' },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
expect(extractInputFieldsFromBlocks(blocks)).toEqual([{ name: 'valid', type: 'string' }])
|
|
})
|
|
|
|
it.concurrent('filters out non-object fields', () => {
|
|
const blocks = {
|
|
'trigger-1': {
|
|
type: 'start_trigger',
|
|
subBlocks: {
|
|
inputFormat: {
|
|
value: [null, undefined, 'string', 123, { name: 'valid', type: 'string' }],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
expect(extractInputFieldsFromBlocks(blocks)).toEqual([{ name: 'valid', type: 'string' }])
|
|
})
|
|
|
|
it.concurrent('extracts from legacy config.params.inputFormat location', () => {
|
|
const blocks = {
|
|
'trigger-1': {
|
|
type: 'start_trigger',
|
|
config: {
|
|
params: {
|
|
inputFormat: [{ name: 'legacy_field', type: 'string' }],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
expect(extractInputFieldsFromBlocks(blocks)).toEqual([{ name: 'legacy_field', type: 'string' }])
|
|
})
|
|
|
|
it.concurrent('prefers subBlocks over config.params', () => {
|
|
const blocks = {
|
|
'trigger-1': {
|
|
type: 'start_trigger',
|
|
subBlocks: {
|
|
inputFormat: {
|
|
value: [{ name: 'primary', type: 'string' }],
|
|
},
|
|
},
|
|
config: {
|
|
params: {
|
|
inputFormat: [{ name: 'legacy', type: 'string' }],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
expect(extractInputFieldsFromBlocks(blocks)).toEqual([{ name: 'primary', type: 'string' }])
|
|
})
|
|
|
|
it.concurrent('returns empty array when inputFormat is not an array', () => {
|
|
const blocks = {
|
|
'trigger-1': {
|
|
type: 'start_trigger',
|
|
subBlocks: {
|
|
inputFormat: {
|
|
value: 'not-an-array',
|
|
},
|
|
},
|
|
},
|
|
}
|
|
expect(extractInputFieldsFromBlocks(blocks)).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('normalizeInputFormatValue', () => {
|
|
it.concurrent('returns empty array for null', () => {
|
|
expect(normalizeInputFormatValue(null)).toEqual([])
|
|
})
|
|
|
|
it.concurrent('returns empty array for undefined', () => {
|
|
expect(normalizeInputFormatValue(undefined)).toEqual([])
|
|
})
|
|
|
|
it.concurrent('returns empty array for empty array', () => {
|
|
expect(normalizeInputFormatValue([])).toEqual([])
|
|
})
|
|
|
|
it.concurrent('returns empty array for non-array values', () => {
|
|
expect(normalizeInputFormatValue('string')).toEqual([])
|
|
expect(normalizeInputFormatValue(123)).toEqual([])
|
|
expect(normalizeInputFormatValue({ name: 'test' })).toEqual([])
|
|
})
|
|
|
|
it.concurrent('filters fields with valid names', () => {
|
|
const input = [
|
|
{ name: 'valid1', type: 'string' },
|
|
{ name: 'valid2', type: 'number' },
|
|
]
|
|
expect(normalizeInputFormatValue(input)).toEqual(input)
|
|
})
|
|
|
|
it.concurrent('filters out fields without names', () => {
|
|
const input = [{ type: 'string' }, { name: 'valid', type: 'string' }, { value: 'test' }]
|
|
expect(normalizeInputFormatValue(input)).toEqual([{ name: 'valid', type: 'string' }])
|
|
})
|
|
|
|
it.concurrent('filters out fields with empty names', () => {
|
|
const input = [
|
|
{ name: '', type: 'string' },
|
|
{ name: ' ', type: 'string' },
|
|
{ name: 'valid', type: 'string' },
|
|
]
|
|
expect(normalizeInputFormatValue(input)).toEqual([{ name: 'valid', type: 'string' }])
|
|
})
|
|
|
|
it.concurrent('filters out null and undefined fields', () => {
|
|
const input = [null, undefined, { name: 'valid', type: 'string' }]
|
|
expect(normalizeInputFormatValue(input)).toEqual([{ name: 'valid', type: 'string' }])
|
|
})
|
|
|
|
it.concurrent('preserves all properties of valid fields', () => {
|
|
const input = [
|
|
{
|
|
name: 'field1',
|
|
type: 'string',
|
|
label: 'Field 1',
|
|
description: 'A test field',
|
|
placeholder: 'Enter value',
|
|
required: true,
|
|
value: 'default',
|
|
},
|
|
]
|
|
expect(normalizeInputFormatValue(input)).toEqual(input)
|
|
})
|
|
})
|
|
|
|
describe('isFileFieldType', () => {
|
|
it.concurrent('matches the canonical file[] type', () => {
|
|
expect(isFileFieldType('file[]')).toBe(true)
|
|
})
|
|
|
|
it.concurrent('does not match legacy variants or other types (no behavior change)', () => {
|
|
expect(isFileFieldType('files')).toBe(false)
|
|
expect(isFileFieldType('file')).toBe(false)
|
|
expect(isFileFieldType('image')).toBe(false)
|
|
expect(isFileFieldType('string')).toBe(false)
|
|
expect(isFileFieldType('array')).toBe(false)
|
|
expect(isFileFieldType(undefined)).toBe(false)
|
|
expect(isFileFieldType(null)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('parseInputFormatFiles', () => {
|
|
const file = {
|
|
id: 'f1',
|
|
name: 'doc.pdf',
|
|
url: '/api/files/serve/workspace%2Fws-1%2F1700000000000-doc.pdf?context=workspace',
|
|
key: 'key',
|
|
size: 10,
|
|
type: 'application/pdf',
|
|
}
|
|
|
|
it.concurrent('parses a JSON string of run-ready files', () => {
|
|
expect(parseInputFormatFiles(JSON.stringify([file]))).toEqual([file])
|
|
})
|
|
|
|
it.concurrent('accepts an already-materialized array', () => {
|
|
expect(parseInputFormatFiles([file])).toEqual([file])
|
|
})
|
|
|
|
it.concurrent('returns empty for blank, invalid, or non-array values', () => {
|
|
expect(parseInputFormatFiles('')).toEqual([])
|
|
expect(parseInputFormatFiles(' ')).toEqual([])
|
|
expect(parseInputFormatFiles(undefined)).toEqual([])
|
|
expect(parseInputFormatFiles('not json')).toEqual([])
|
|
expect(parseInputFormatFiles('{"name":"x"}')).toEqual([])
|
|
})
|
|
|
|
it.concurrent('drops legacy entries missing id/url (base64 placeholder, raw text)', () => {
|
|
expect(
|
|
parseInputFormatFiles(
|
|
JSON.stringify([{ data: '<base64>', type: 'file', name: 'document.pdf', mime: 'x' }])
|
|
)
|
|
).toEqual([])
|
|
expect(parseInputFormatFiles(JSON.stringify([{ name: 'doc.pdf', path: '/legacy' }]))).toEqual(
|
|
[]
|
|
)
|
|
})
|
|
|
|
it.concurrent('keeps only the valid files in a mixed array', () => {
|
|
expect(parseInputFormatFiles(JSON.stringify([file, { name: 'bad' }]))).toEqual([file])
|
|
})
|
|
|
|
it.concurrent('rejects partial files missing the run-ready size/type', () => {
|
|
expect(parseInputFormatFiles(JSON.stringify([{ id: 'x', name: 'a.pdf', url: '/u' }]))).toEqual(
|
|
[]
|
|
)
|
|
expect(
|
|
parseInputFormatFiles(
|
|
JSON.stringify([{ id: 'x', name: 'a.pdf', url: '/u', size: Number.NaN, type: 'x' }])
|
|
)
|
|
).toEqual([])
|
|
})
|
|
|
|
it.concurrent('rejects files with no key and a non-internal url (key unrecoverable)', () => {
|
|
const { key, ...noKey } = file
|
|
const external = { ...noKey, url: 'https://example.com/x.pdf' }
|
|
expect(parseInputFormatFiles(JSON.stringify([external]))).toEqual([])
|
|
expect(parseInputFormatFiles(JSON.stringify([{ ...external, key: '' }]))).toEqual([])
|
|
})
|
|
|
|
it.concurrent('accepts a key-less file when the url is an internal serve url', () => {
|
|
const { key, ...noKey } = file
|
|
expect(parseInputFormatFiles(JSON.stringify([noKey]))).toEqual([noKey])
|
|
})
|
|
|
|
it.concurrent('rejects a key-less file whose internal url yields no key', () => {
|
|
const { key, ...noKey } = file
|
|
expect(parseInputFormatFiles(JSON.stringify([{ ...noKey, url: '/api/files/serve/' }]))).toEqual(
|
|
[]
|
|
)
|
|
})
|
|
|
|
it.concurrent('rejects empty-string id/name/url/type (executor treats them as falsy)', () => {
|
|
expect(parseInputFormatFiles(JSON.stringify([{ ...file, id: '' }]))).toEqual([])
|
|
expect(parseInputFormatFiles(JSON.stringify([{ ...file, name: '' }]))).toEqual([])
|
|
expect(parseInputFormatFiles(JSON.stringify([{ ...file, url: '' }]))).toEqual([])
|
|
expect(parseInputFormatFiles(JSON.stringify([{ ...file, type: '' }]))).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('collectInputFormatFiles', () => {
|
|
const file = {
|
|
id: 'f1',
|
|
name: 'doc.pdf',
|
|
url: '/api/files/serve/workspace%2Fws-1%2F1700000000000-doc.pdf?context=workspace',
|
|
key: 'key',
|
|
size: 10,
|
|
type: 'application/pdf',
|
|
}
|
|
|
|
it.concurrent('returns empty for non-array values', () => {
|
|
expect(collectInputFormatFiles(null)).toEqual([])
|
|
expect(collectInputFormatFiles('nope')).toEqual([])
|
|
})
|
|
|
|
it.concurrent('collects files only from file[] fields, ignoring other types', () => {
|
|
const value = [
|
|
{ name: 'query', type: 'string', value: 'hi' },
|
|
{ name: 'a', type: 'file[]', value: JSON.stringify([file]) },
|
|
{ name: 'b', type: 'file[]', value: JSON.stringify([{ ...file, id: 'f2' }]) },
|
|
{ name: 'legacy', type: 'files', value: JSON.stringify([{ ...file, id: 'ignored' }]) },
|
|
]
|
|
expect(collectInputFormatFiles(value).map((f) => f.id)).toEqual(['f1', 'f2'])
|
|
})
|
|
|
|
it.concurrent('ignores legacy/unparseable file values', () => {
|
|
const value = [
|
|
{ name: 'a', type: 'file[]', value: 'C:/Users/x/budget.xlsx' },
|
|
{ name: 'b', type: 'file[]', value: '[{"data":"<base64>"}]' },
|
|
{ name: 'c', type: 'file[]', value: '' },
|
|
]
|
|
expect(collectInputFormatFiles(value)).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('createDefaultInputFormatField', () => {
|
|
it.concurrent('creates an empty field with the canonical default shape', () => {
|
|
const field = createDefaultInputFormatField()
|
|
expect(field).toEqual({
|
|
id: expect.any(String),
|
|
name: '',
|
|
type: 'string',
|
|
value: '',
|
|
collapsed: false,
|
|
})
|
|
expect(field.id.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it.concurrent('omits description so it is not persisted by default', () => {
|
|
expect('description' in createDefaultInputFormatField()).toBe(false)
|
|
})
|
|
|
|
it.concurrent('returns a fresh id and a new object on each call', () => {
|
|
const first = createDefaultInputFormatField()
|
|
const second = createDefaultInputFormatField()
|
|
expect(first.id).not.toBe(second.id)
|
|
expect(first).not.toBe(second)
|
|
})
|
|
})
|