Files
simstudioai--sim/apps/sim/lib/workflows/input-format.test.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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)
})
})