@teleporthq/teleport-plugin-next-workflows 0.43.29 → 0.43.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/__tests__/_helpers/load-handler.ts +11 -1
  2. package/__tests__/file-storage-upload-picked-files.test.ts +135 -0
  3. package/__tests__/graph-utils-topological-order.test.ts +108 -0
  4. package/__tests__/runtime-operator-aliases.test.ts +180 -0
  5. package/__tests__/runtime-template-token-resolution.test.ts +225 -0
  6. package/__tests__/state-update-array-type-guard.test.ts +172 -0
  7. package/dist/cjs/executor-generator.d.ts.map +1 -1
  8. package/dist/cjs/executor-generator.js +134 -5
  9. package/dist/cjs/executor-generator.js.map +1 -1
  10. package/dist/cjs/graph-utils.d.ts +7 -0
  11. package/dist/cjs/graph-utils.d.ts.map +1 -1
  12. package/dist/cjs/graph-utils.js +52 -1
  13. package/dist/cjs/graph-utils.js.map +1 -1
  14. package/dist/cjs/nodes/data/data-count.d.ts.map +1 -1
  15. package/dist/cjs/nodes/data/data-count.js +16 -0
  16. package/dist/cjs/nodes/data/data-count.js.map +1 -1
  17. package/dist/cjs/nodes/data/data-create-item.d.ts.map +1 -1
  18. package/dist/cjs/nodes/data/data-create-item.js +20 -0
  19. package/dist/cjs/nodes/data/data-create-item.js.map +1 -1
  20. package/dist/cjs/nodes/data/data-delete-item.d.ts.map +1 -1
  21. package/dist/cjs/nodes/data/data-delete-item.js +19 -0
  22. package/dist/cjs/nodes/data/data-delete-item.js.map +1 -1
  23. package/dist/cjs/nodes/data/data-select.d.ts.map +1 -1
  24. package/dist/cjs/nodes/data/data-select.js +18 -0
  25. package/dist/cjs/nodes/data/data-select.js.map +1 -1
  26. package/dist/cjs/nodes/data/data-update-item.d.ts.map +1 -1
  27. package/dist/cjs/nodes/data/data-update-item.js +36 -0
  28. package/dist/cjs/nodes/data/data-update-item.js.map +1 -1
  29. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  30. package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
  31. package/dist/cjs/workflow-component-plugin.js +56 -9
  32. package/dist/cjs/workflow-component-plugin.js.map +1 -1
  33. package/dist/esm/executor-generator.d.ts.map +1 -1
  34. package/dist/esm/executor-generator.js +134 -5
  35. package/dist/esm/executor-generator.js.map +1 -1
  36. package/dist/esm/graph-utils.d.ts +7 -0
  37. package/dist/esm/graph-utils.d.ts.map +1 -1
  38. package/dist/esm/graph-utils.js +50 -0
  39. package/dist/esm/graph-utils.js.map +1 -1
  40. package/dist/esm/nodes/data/data-count.d.ts.map +1 -1
  41. package/dist/esm/nodes/data/data-count.js +16 -0
  42. package/dist/esm/nodes/data/data-count.js.map +1 -1
  43. package/dist/esm/nodes/data/data-create-item.d.ts.map +1 -1
  44. package/dist/esm/nodes/data/data-create-item.js +20 -0
  45. package/dist/esm/nodes/data/data-create-item.js.map +1 -1
  46. package/dist/esm/nodes/data/data-delete-item.d.ts.map +1 -1
  47. package/dist/esm/nodes/data/data-delete-item.js +19 -0
  48. package/dist/esm/nodes/data/data-delete-item.js.map +1 -1
  49. package/dist/esm/nodes/data/data-select.d.ts.map +1 -1
  50. package/dist/esm/nodes/data/data-select.js +18 -0
  51. package/dist/esm/nodes/data/data-select.js.map +1 -1
  52. package/dist/esm/nodes/data/data-update-item.d.ts.map +1 -1
  53. package/dist/esm/nodes/data/data-update-item.js +36 -0
  54. package/dist/esm/nodes/data/data-update-item.js.map +1 -1
  55. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  56. package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
  57. package/dist/esm/workflow-component-plugin.js +56 -9
  58. package/dist/esm/workflow-component-plugin.js.map +1 -1
  59. package/package.json +2 -2
  60. package/src/executor-generator.ts +134 -5
  61. package/src/graph-utils.ts +52 -0
  62. package/src/nodes/data/data-count.ts +20 -0
  63. package/src/nodes/data/data-create-item.ts +19 -0
  64. package/src/nodes/data/data-delete-item.ts +23 -0
  65. package/src/nodes/data/data-select.ts +22 -0
  66. package/src/nodes/data/data-update-item.ts +38 -0
  67. package/src/workflow-component-plugin.ts +61 -3
@@ -16,7 +16,17 @@ import { nodeRegistry } from '../../src'
16
16
  //
17
17
  // (The polyfills are the verbatim TS 4.x emit, kept in sync with the
18
18
  // upstream `__awaiter` / `__generator` definitions tslib ships.)
19
- const TS_EMIT_HELPERS = `var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
19
+ const TS_EMIT_HELPERS = `var __assign = (this && this.__assign) || function () {
20
+ __assign = Object.assign || function(t) {
21
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
22
+ s = arguments[i];
23
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
24
+ }
25
+ return t;
26
+ };
27
+ return __assign.apply(this, arguments);
28
+ };
29
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
20
30
  function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
21
31
  return new (P || (P = Promise))(function (resolve, reject) {
22
32
  function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
@@ -0,0 +1,135 @@
1
+ // Pins the file-storage-upload contract the tq-form-file-input primitive
2
+ // relies on: the form-submit workflow does state-get-local-state(<state>) ->
3
+ // file-storage-upload(file = <PickedFile[]>), so the handler must accept an
4
+ // ARRAY of `{ name, size, type, lastModified, dataURL }` POJOs (the exact
5
+ // shape browser-pick-files emits) and convert every dataURL to a Blob before
6
+ // POSTing the FormData.
7
+
8
+ import { Blob as NodeBlob } from 'buffer'
9
+ import { loadHandler, HandlerFn } from './_helpers/load-handler'
10
+
11
+ // jest 26's node sandbox exposes none of the browser upload globals the
12
+ // handler uses (FormData/Blob/atob/fetch), so the test installs minimal
13
+ // stand-ins: Node's own Blob, a Buffer-backed atob, and a FormData stub
14
+ // that records appends the way the assertions need them.
15
+ class FormDataStub {
16
+ public appended: Array<{ key: string; value: unknown; fileName?: string }> = []
17
+
18
+ public append(key: string, value: unknown, fileName?: string): void {
19
+ this.appended.push({ key, value, fileName })
20
+ }
21
+
22
+ public getAll(key: string): Array<{ value: unknown; fileName?: string }> {
23
+ return this.appended
24
+ .filter((entry) => entry.key === key)
25
+ .map((entry) => ({ value: entry.value, fileName: entry.fileName }))
26
+ }
27
+ }
28
+
29
+ const toDataURL = (mime: string, content: string): string =>
30
+ `data:${mime};base64,${Buffer.from(content).toString('base64')}`
31
+
32
+ const blobText = async (value: unknown): Promise<string> =>
33
+ Buffer.from(await (value as InstanceType<typeof NodeBlob>).arrayBuffer()).toString()
34
+
35
+ describe('file-storage-upload: PickedFile array from state', () => {
36
+ let fetchMock: jest.Mock
37
+ let handler: HandlerFn
38
+
39
+ beforeEach(() => {
40
+ handler = loadHandler('file-storage-upload')
41
+ fetchMock = jest.fn(async () => ({
42
+ ok: true,
43
+ json: async () => ({
44
+ files: [
45
+ { id: '1', name: 'a.png', url: 'https://storage.example/a.png' },
46
+ { id: '2', name: 'b.txt', url: 'https://storage.example/b.txt' },
47
+ ],
48
+ }),
49
+ }))
50
+ ;(globalThis as any).fetch = fetchMock
51
+ ;(globalThis as any).FormData = FormDataStub
52
+ ;(globalThis as any).Blob = NodeBlob
53
+ ;(globalThis as any).atob = (encoded: string) =>
54
+ Buffer.from(encoded, 'base64').toString('binary')
55
+ })
56
+
57
+ afterEach(() => {
58
+ delete (globalThis as any).fetch
59
+ delete (globalThis as any).FormData
60
+ delete (globalThis as any).Blob
61
+ delete (globalThis as any).atob
62
+ })
63
+
64
+ it('converts every PickedFile dataURL to a Blob and appends all of them', async () => {
65
+ const pickedFiles = [
66
+ {
67
+ name: 'a.png',
68
+ size: 9,
69
+ type: 'image/png',
70
+ lastModified: 1,
71
+ dataURL: toDataURL('image/png', 'png-bytes'),
72
+ },
73
+ {
74
+ name: 'b.txt',
75
+ size: 9,
76
+ type: 'text/plain',
77
+ lastModified: 2,
78
+ dataURL: toDataURL('text/plain', 'txt-bytes'),
79
+ },
80
+ ]
81
+
82
+ const result = (await handler({ file: pickedFiles }, {})) as { files: Array<{ url: string }> }
83
+
84
+ expect(fetchMock).toHaveBeenCalledTimes(1)
85
+ const [url, init] = fetchMock.mock.calls[0]
86
+ expect(url).toBe('/api/runtime-storage/upload')
87
+ const entries = (init.body as FormDataStub).getAll('file')
88
+ expect(entries).toHaveLength(2)
89
+ expect(entries[0].fileName).toBe('a.png')
90
+ expect((entries[0].value as InstanceType<typeof NodeBlob>).type).toBe('image/png')
91
+ expect(await blobText(entries[0].value)).toBe('png-bytes')
92
+ expect(entries[1].fileName).toBe('b.txt')
93
+ expect((entries[1].value as InstanceType<typeof NodeBlob>).type).toBe('text/plain')
94
+ expect(await blobText(entries[1].value)).toBe('txt-bytes')
95
+
96
+ expect(result.files.map((f) => f.url)).toEqual([
97
+ 'https://storage.example/a.png',
98
+ 'https://storage.example/b.txt',
99
+ ])
100
+ })
101
+
102
+ it('skips array entries without a usable dataURL but uploads the rest', async () => {
103
+ const pickedFiles = [
104
+ { name: 'broken.png', size: 1, type: 'image/png', lastModified: 1 },
105
+ {
106
+ name: 'ok.png',
107
+ size: 9,
108
+ type: 'image/png',
109
+ lastModified: 2,
110
+ dataURL: toDataURL('image/png', 'ok-bytes'),
111
+ },
112
+ ]
113
+
114
+ await handler({ file: pickedFiles }, {})
115
+
116
+ const entries = (fetchMock.mock.calls[0][1].body as FormDataStub).getAll('file')
117
+ expect(entries).toHaveLength(1)
118
+ expect(entries[0].fileName).toBe('ok.png')
119
+ })
120
+
121
+ it('returns an error without fetching when no array entry is convertible', async () => {
122
+ const result = await handler({ file: [{ name: 'x.png', type: 'image/png' }] }, {})
123
+
124
+ expect(result).toEqual({ files: [], error: 'Invalid file data' })
125
+ expect(fetchMock).not.toHaveBeenCalled()
126
+ })
127
+
128
+ it('never throws for an empty selection — the submit chain needs no if-gate', async () => {
129
+ // An untouched form-file-input state is [] and reaches the node as-is.
130
+ const result = await handler({ file: [] }, {})
131
+
132
+ expect(result).toEqual({ files: [], error: 'Invalid file data' })
133
+ expect(fetchMock).not.toHaveBeenCalled()
134
+ })
135
+ })
@@ -0,0 +1,108 @@
1
+ import { getTopologicalOrder, collectWorkflowContextNodeIds } from '../src/graph-utils'
2
+ import type { UIDLWorkflowNode, UIDLWorkflowEdge } from '@teleporthq/teleport-types'
3
+
4
+ const node = (
5
+ id: string,
6
+ type: string,
7
+ stepNumber: number,
8
+ config: Record<string, unknown> = {}
9
+ ): UIDLWorkflowNode => ({
10
+ id,
11
+ type,
12
+ label: id,
13
+ config,
14
+ executionEnv: type === 'data-create-item' || type.startsWith('data-') ? 'server' : 'client',
15
+ stepNumber,
16
+ })
17
+
18
+ const wfCtx = (nodeId: string) => ({
19
+ type: 'workflowContext',
20
+ nodeId,
21
+ path: [nodeId, 'value'],
22
+ })
23
+
24
+ describe('collectWorkflowContextNodeIds', () => {
25
+ it('finds workflowContext node ids nested in arrays and objects', () => {
26
+ const config = {
27
+ columnMappings: [
28
+ { column: 'title', value: wfCtx('get-title') },
29
+ { column: 'type_field', value: wfCtx('get-type') },
30
+ ],
31
+ filters: [{ field: 'id', value: '{{Current Page Entity.id}}' }],
32
+ }
33
+ const out = new Set<string>()
34
+ collectWorkflowContextNodeIds(config, out)
35
+ expect(Array.from(out).sort()).toEqual(['get-title', 'get-type'])
36
+ })
37
+ })
38
+
39
+ describe('getTopologicalOrder — implicit workflowContext dependencies', () => {
40
+ it('orders a state-get BEFORE the data-create that reads it via columnMappings (no explicit edge)', () => {
41
+ // Reproduces run 0a5f554e: the create references get-state-type only through
42
+ // its config; there is NO explicit edge between them.
43
+ const nodes = [
44
+ node('trig', 'event-form-submitted', 0),
45
+ node('insert-title', 'data-create-item', 0, {
46
+ tableName: 'titles',
47
+ columnMappings: [{ column: 'type_field', value: wfCtx('get-state-type') }],
48
+ }),
49
+ node('get-state-type', 'state-get-local-state', 3, { property: 'typeField' }),
50
+ ]
51
+ const edges: UIDLWorkflowEdge[] = [{ id: 'e1', source: 'trig', target: 'get-state-type' }]
52
+
53
+ const order = getTopologicalOrder(nodes, edges)
54
+ expect(order.indexOf('get-state-type')).toBeLessThan(order.indexOf('insert-title'))
55
+ })
56
+
57
+ it('does not reorder a correctly wired linear workflow', () => {
58
+ const nodes = [
59
+ node('trig', 'event-element-clicked', 0),
60
+ node('get', 'state-get-local-state', 1, { property: 'q' }),
61
+ node('sel', 'data-select', 2, {
62
+ tableName: 't',
63
+ filters: [{ field: 'q', value: wfCtx('get') }],
64
+ }),
65
+ node('upd', 'state-update-local-state', 3, { property: 'rows' }),
66
+ ]
67
+ const edges: UIDLWorkflowEdge[] = [
68
+ { id: 'e1', source: 'trig', target: 'get' },
69
+ { id: 'e2', source: 'get', target: 'sel' },
70
+ { id: 'e3', source: 'sel', target: 'upd' },
71
+ ]
72
+ expect(getTopologicalOrder(nodes, edges)).toEqual(['trig', 'get', 'sel', 'upd'])
73
+ })
74
+
75
+ it('ignores a workflowContext ref to a control-flow (loop) node', () => {
76
+ // A node reading a loop's currentItem must NOT be forced after the loop via a
77
+ // synthetic data edge (loop bodies are handled by dedicated edge handles).
78
+ const nodes = [
79
+ node('trig', 'event-element-clicked', 0),
80
+ node('reader', 'data-create-item', 1, {
81
+ tableName: 't',
82
+ columnMappings: [
83
+ {
84
+ column: 'x',
85
+ value: { type: 'workflowContext', nodeId: 'loop', path: ['loop', 'currentItem'] },
86
+ },
87
+ ],
88
+ }),
89
+ node('loop', 'general-loop', 5, {}),
90
+ ]
91
+ const edges: UIDLWorkflowEdge[] = [{ id: 'e1', source: 'trig', target: 'reader' }]
92
+ // No virtual edge loop->reader is added, so reader (step 1) stays before loop (step 5).
93
+ const order = getTopologicalOrder(nodes, edges)
94
+ expect(order.indexOf('reader')).toBeLessThan(order.indexOf('loop'))
95
+ })
96
+
97
+ it('tolerates self / unknown refs without crashing', () => {
98
+ const nodes = [
99
+ node('trig', 'event-element-clicked', 0),
100
+ node('a', 'data-select', 1, {
101
+ filters: [{ value: wfCtx('a') }, { value: wfCtx('ghost') }],
102
+ }),
103
+ ]
104
+ const edges: UIDLWorkflowEdge[] = [{ id: 'e1', source: 'trig', target: 'a' }]
105
+ expect(() => getTopologicalOrder(nodes, edges)).not.toThrow()
106
+ expect(getTopologicalOrder(nodes, edges)).toEqual(['trig', 'a'])
107
+ })
108
+ })
@@ -0,0 +1,180 @@
1
+ import { generateSharedRuntimeUtilsCode } from '../src'
2
+
3
+ // Regression guard for the operator-vocabulary mismatch (FurniFlow run
4
+ // be21af83): the worker canonicalizes if-statement operators to snake_case
5
+ // (is_not_empty, greater_than) and the GUI emits camelCase named forms
6
+ // (startsWith, notContains), but the generated runtime only implemented the
7
+ // hyphenated spellings. Unknown operators fell through to `left == right`,
8
+ // INVERTING unary semantics — a correctly filled stock-movement form
9
+ // evaluated is_not_empty(value) as `value == undefined` → false → the
10
+ // submit silently no-oped and success/error toasts were swapped.
11
+
12
+ type SharedUtils = {
13
+ evaluateSingleComparison: (cfg: unknown, ctx: Record<string, unknown>) => boolean
14
+ evaluateCondition: (cfg: unknown, ctx: Record<string, unknown>) => boolean
15
+ normalizeComparisonOperator: (op: unknown) => string
16
+ }
17
+
18
+ function loadSharedRuntime(): SharedUtils {
19
+ const src = generateSharedRuntimeUtilsCode()
20
+ const wrapper: { exports: Record<string, unknown> } = { exports: {} }
21
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
22
+ new Function('module', 'exports', src)(wrapper, wrapper.exports)
23
+ return wrapper.exports as unknown as SharedUtils
24
+ }
25
+
26
+ describe('workflow runtime operator aliases', () => {
27
+ const utils = loadSharedRuntime()
28
+
29
+ it('normalizes snake_case, camelCase and named operators to the canonical form', () => {
30
+ expect(utils.normalizeComparisonOperator('is_not_empty')).toBe('is-not-empty')
31
+ expect(utils.normalizeComparisonOperator('is_empty')).toBe('is-empty')
32
+ expect(utils.normalizeComparisonOperator('not_equals')).toBe('not-equals')
33
+ expect(utils.normalizeComparisonOperator('greater_than')).toBe('greater-than')
34
+ expect(utils.normalizeComparisonOperator('less_than')).toBe('less-than')
35
+ expect(utils.normalizeComparisonOperator('greater_than_or_equal')).toBe('greater-than-or-equal')
36
+ expect(utils.normalizeComparisonOperator('less_than_or_equal')).toBe('less-than-or-equal')
37
+ expect(utils.normalizeComparisonOperator('starts_with')).toBe('starts-with')
38
+ expect(utils.normalizeComparisonOperator('startsWith')).toBe('starts-with')
39
+ expect(utils.normalizeComparisonOperator('ends_with')).toBe('ends-with')
40
+ expect(utils.normalizeComparisonOperator('endsWith')).toBe('ends-with')
41
+ expect(utils.normalizeComparisonOperator('not_contains')).toBe('not-contains')
42
+ expect(utils.normalizeComparisonOperator('notContains')).toBe('not-contains')
43
+ expect(utils.normalizeComparisonOperator('equals')).toBe('equals')
44
+ expect(utils.normalizeComparisonOperator('matchesRegex')).toBe('matches-regex')
45
+ // symbol operators pass through untouched
46
+ expect(utils.normalizeComparisonOperator('===')).toBe('===')
47
+ expect(utils.normalizeComparisonOperator('!=')).toBe('!=')
48
+ expect(utils.normalizeComparisonOperator('>')).toBe('>')
49
+ })
50
+
51
+ it('evaluates is_not_empty as a UNARY check (previously inverted)', () => {
52
+ // filled value → true (used to be `value == undefined` → false)
53
+ expect(
54
+ utils.evaluateSingleComparison({ leftValue: 'receipt', operator: 'is_not_empty' }, {})
55
+ ).toBe(true)
56
+ // empty value → false (used to be true)
57
+ expect(utils.evaluateSingleComparison({ leftValue: '', operator: 'is_not_empty' }, {})).toBe(
58
+ false
59
+ )
60
+ expect(
61
+ utils.evaluateSingleComparison({ leftValue: undefined, operator: 'is_not_empty' }, {})
62
+ ).toBe(false)
63
+ expect(utils.evaluateSingleComparison({ leftValue: [], operator: 'is_not_empty' }, {})).toBe(
64
+ false
65
+ )
66
+ })
67
+
68
+ it('evaluates is_empty / is_null / is_not_null / is_true / is_false', () => {
69
+ expect(utils.evaluateSingleComparison({ leftValue: '', operator: 'is_empty' }, {})).toBe(true)
70
+ expect(utils.evaluateSingleComparison({ leftValue: 'x', operator: 'is_empty' }, {})).toBe(false)
71
+ expect(utils.evaluateSingleComparison({ leftValue: null, operator: 'is_null' }, {})).toBe(true)
72
+ expect(utils.evaluateSingleComparison({ leftValue: '', operator: 'is_null' }, {})).toBe(false)
73
+ expect(utils.evaluateSingleComparison({ leftValue: 0, operator: 'is_not_null' }, {})).toBe(true)
74
+ expect(utils.evaluateSingleComparison({ leftValue: 'true', operator: 'is_true' }, {})).toBe(
75
+ true
76
+ )
77
+ expect(utils.evaluateSingleComparison({ leftValue: false, operator: 'is_false' }, {})).toBe(
78
+ true
79
+ )
80
+ })
81
+
82
+ it('evaluates named binary operators', () => {
83
+ expect(
84
+ utils.evaluateSingleComparison(
85
+ { leftValue: 5, rightValue: '3', operator: 'greater_than' },
86
+ {}
87
+ )
88
+ ).toBe(true)
89
+ expect(
90
+ utils.evaluateSingleComparison({ leftValue: 2, rightValue: '3', operator: 'less_than' }, {})
91
+ ).toBe(true)
92
+ expect(
93
+ utils.evaluateSingleComparison(
94
+ { leftValue: 3, rightValue: '3', operator: 'greater_than_or_equal' },
95
+ {}
96
+ )
97
+ ).toBe(true)
98
+ expect(
99
+ utils.evaluateSingleComparison(
100
+ { leftValue: 3, rightValue: '3', operator: 'less_than_or_equal' },
101
+ {}
102
+ )
103
+ ).toBe(true)
104
+ expect(
105
+ utils.evaluateSingleComparison(
106
+ { leftValue: 'a', rightValue: 'b', operator: 'not_equals' },
107
+ {}
108
+ )
109
+ ).toBe(true)
110
+ expect(
111
+ utils.evaluateSingleComparison(
112
+ { leftValue: 'receipt-1', rightValue: 'receipt', operator: 'startsWith' },
113
+ {}
114
+ )
115
+ ).toBe(true)
116
+ expect(
117
+ utils.evaluateSingleComparison(
118
+ { leftValue: 'receipt-1', rightValue: '-1', operator: 'ends_with' },
119
+ {}
120
+ )
121
+ ).toBe(true)
122
+ expect(
123
+ utils.evaluateSingleComparison(
124
+ { leftValue: 'abc', rightValue: 'z', operator: 'notContains' },
125
+ {}
126
+ )
127
+ ).toBe(true)
128
+ })
129
+
130
+ it('warns on a genuinely unknown operator instead of silently comparing', () => {
131
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
132
+ utils.evaluateSingleComparison({ leftValue: 1, rightValue: 1, operator: 'sorta-equalish' }, {})
133
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('sorta-equalish'))
134
+ warnSpy.mockRestore()
135
+ })
136
+
137
+ it('validate-input multiple-conditions AND from the FurniFlow run now passes for a filled form', () => {
138
+ // Deployed `Record Stock Movement.validate-input` shape (after server
139
+ // repair): conditions[] with leftValue/rightValue keys, snake_case ops
140
+ // and stringified numeric rights.
141
+ const context = {
142
+ 'get-type': { value: 'receipt', key: 'movementType' },
143
+ 'get-product': { value: 'b8b7e9aa-1111-4222-8333-abcdefabcdef', key: 'productId' },
144
+ 'get-qty': { value: 12, key: 'quantity' },
145
+ }
146
+ const config = {
147
+ conditionType: 'multiple-conditions',
148
+ logicOperator: 'AND',
149
+ conditions: [
150
+ {
151
+ leftValue: { type: 'workflowContext', nodeId: 'get-type', path: ['get-type', 'value'] },
152
+ operator: 'is_not_empty',
153
+ },
154
+ {
155
+ leftValue: {
156
+ type: 'workflowContext',
157
+ nodeId: 'get-product',
158
+ path: ['get-product', 'value'],
159
+ },
160
+ operator: 'is_not_empty',
161
+ },
162
+ {
163
+ leftValue: { type: 'workflowContext', nodeId: 'get-qty', path: ['get-qty', 'value'] },
164
+ operator: '>',
165
+ rightValue: '0',
166
+ },
167
+ ],
168
+ }
169
+ expect(utils.evaluateCondition(config, context)).toBe(true)
170
+
171
+ // Empty form → false (and the toast inversion scenario: is_not_empty on a
172
+ // missing insert id must be FALSE, not true)
173
+ const emptyContext = {
174
+ 'get-type': { value: '', key: 'movementType' },
175
+ 'get-product': { value: '', key: 'productId' },
176
+ 'get-qty': { value: 0, key: 'quantity' },
177
+ }
178
+ expect(utils.evaluateCondition(config, emptyContext)).toBe(false)
179
+ })
180
+ })
@@ -0,0 +1,225 @@
1
+ import { generateSharedRuntimeUtilsCode } from '../src'
2
+ import { loadHandler } from './_helpers/load-handler'
3
+
4
+ // Regression guard for the literal {{...}} template tokens that deployed
5
+ // node configs carry into the generated runtime (FurniFlow run be21af83):
6
+ // {{urlDifferentiator}} / {{Current Page Entity.id}} / {{page.entityId}}
7
+ // (details-page route param), {{state.X}} (trigger-time state snapshot) and
8
+ // {{Current User.id}} (GlobalContext bridge). No other substitution site
9
+ // exists in the generated app, so before this fix the literal token text
10
+ // reached SQL — breaking every edit/delete/save workflow on details pages.
11
+
12
+ const SENTINEL = '__TQ_UNRESOLVED_ROUTE_PARAM__'
13
+
14
+ type SharedUtils = {
15
+ resolveConfig: (cfg: unknown, ctx: Record<string, unknown>) => any
16
+ resolveTemplateTokenString: (
17
+ value: unknown,
18
+ ctx: Record<string, unknown>
19
+ ) => { matched: boolean; value?: unknown }
20
+ finalizeResolvedConfig: (nodeType: string, cfg: unknown) => string | null
21
+ executeNodes: (
22
+ nodes: unknown[],
23
+ edges: unknown[],
24
+ ctx: Record<string, unknown>,
25
+ handlers: Record<string, unknown>,
26
+ workflowConfig: Record<string, unknown>
27
+ ) => Promise<void>
28
+ }
29
+
30
+ function loadSharedRuntime(): SharedUtils {
31
+ const src = generateSharedRuntimeUtilsCode()
32
+ const wrapper: { exports: Record<string, unknown> } = { exports: {} }
33
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
34
+ new Function('module', 'exports', src)(wrapper, wrapper.exports)
35
+ return wrapper.exports as unknown as SharedUtils
36
+ }
37
+
38
+ describe('workflow runtime template-token resolution', () => {
39
+ const utils = loadSharedRuntime()
40
+
41
+ describe('{{state.X}}', () => {
42
+ it('resolves from the trigger-time state snapshot', () => {
43
+ const ctx = { __stateValues: { searchQuery: 'oak desk' } }
44
+ const resolved = utils.resolveConfig(
45
+ { filters: [{ field: 'name', value: '{{state.searchQuery}}', operator: 'ILIKE' }] },
46
+ ctx
47
+ )
48
+ expect(resolved.filters[0].value).toBe('oak desk')
49
+ })
50
+
51
+ it('resolves nested paths and degrades a missing state to null', () => {
52
+ const ctx = { __stateValues: { form: { qty: 3 } } }
53
+ expect(utils.resolveTemplateTokenString('{{state.form.qty}}', ctx)).toEqual({
54
+ matched: true,
55
+ value: 3,
56
+ })
57
+ expect(utils.resolveTemplateTokenString('{{state.missing}}', ctx)).toEqual({
58
+ matched: true,
59
+ value: null,
60
+ })
61
+ })
62
+
63
+ it('does NOT touch JS-expression tokens (ternaries etc.)', () => {
64
+ const ternary = "{{state.categorySelect === 'All' ? undefined : state.categorySelect}}"
65
+ expect(utils.resolveTemplateTokenString(ternary, {}).matched).toBe(false)
66
+ })
67
+ })
68
+
69
+ describe('{{urlDifferentiator}} / {{Current Page Entity.id}} / {{page.entityId}}', () => {
70
+ const routeCtx = {
71
+ __routeParams: { id: 'b8b7e9aa-1111-4222-8333-abcdefabcdef' },
72
+ __dynamicRouteParam: 'id',
73
+ }
74
+
75
+ it.each(['{{urlDifferentiator}}', '{{Current Page Entity.id}}', '{{page.entityId}}'])(
76
+ 'resolves %s to the dynamic-route param value',
77
+ (token) => {
78
+ const resolved = utils.resolveConfig(
79
+ { filters: [{ field: 'id', value: token, operator: '=' }] },
80
+ routeCtx
81
+ )
82
+ expect(resolved.filters[0].value).toBe('b8b7e9aa-1111-4222-8333-abcdefabcdef')
83
+ }
84
+ )
85
+
86
+ it('falls back to the "id" param when no __dynamicRouteParam is declared', () => {
87
+ const ctx = { __routeParams: { id: 'abc-123' } }
88
+ expect(utils.resolveTemplateTokenString('{{urlDifferentiator}}', ctx)).toEqual({
89
+ matched: true,
90
+ value: 'abc-123',
91
+ })
92
+ })
93
+
94
+ it('resolves to the sentinel on a page with no route params', () => {
95
+ expect(utils.resolveTemplateTokenString('{{Current Page Entity.id}}', {})).toEqual({
96
+ matched: true,
97
+ value: SENTINEL,
98
+ })
99
+ })
100
+ })
101
+
102
+ describe('{{Current User.id}}', () => {
103
+ it('resolves from the GlobalContext-bridged currentUser', () => {
104
+ const ctx = { __stateValues: { currentUser: { id: 'user-42', email: 'a@b.c' } } }
105
+ const resolved = utils.resolveConfig(
106
+ { columnMappings: [{ column: 'created_by_user_id', value: '{{Current User.id}}' }] },
107
+ ctx
108
+ )
109
+ expect(resolved.columnMappings[0].value).toBe('user-42')
110
+ })
111
+
112
+ it('resolves to null when no session exists', () => {
113
+ expect(utils.resolveTemplateTokenString('{{Current User.id}}', {})).toEqual({
114
+ matched: true,
115
+ value: null,
116
+ })
117
+ })
118
+ })
119
+
120
+ describe('finalizeResolvedConfig', () => {
121
+ it('turns an unresolved route param in a data-node filter into a validation error', () => {
122
+ const cfg = { filters: [{ field: 'id', value: SENTINEL, operator: '=' }] }
123
+ const error = utils.finalizeResolvedConfig('data-select', cfg)
124
+ expect(error).toContain('id')
125
+ expect(error).toContain('not available')
126
+ expect(cfg.filters[0].value).toBeNull()
127
+ })
128
+
129
+ it('degrades an unresolved route param outside filters to null without erroring', () => {
130
+ const cfg = { columnMappings: [{ column: 'product_id', value: SENTINEL }] }
131
+ expect(utils.finalizeResolvedConfig('data-create-item', cfg)).toBeNull()
132
+ expect(cfg.columnMappings[0].value).toBeNull()
133
+ })
134
+
135
+ it('leaves configs without sentinels untouched', () => {
136
+ const cfg = { filters: [{ field: 'id', value: null, operator: '=' }] }
137
+ expect(utils.finalizeResolvedConfig('data-select', cfg)).toBeNull()
138
+ expect(cfg.filters[0].value).toBeNull()
139
+ })
140
+ })
141
+
142
+ describe('executeNodes routes the validation error to the workflow error handler', () => {
143
+ it('throws (→ error handler) instead of calling the data handler with the literal', async () => {
144
+ const handlerCalls: unknown[] = []
145
+ const handlers = {
146
+ 'data-select': async (cfg: unknown) => {
147
+ handlerCalls.push(cfg)
148
+ return { rows: [] }
149
+ },
150
+ }
151
+ const nodes = [
152
+ {
153
+ id: 'fetch-product-data',
154
+ type: 'data-select',
155
+ stepNumber: 1,
156
+ config: {
157
+ tableName: 'products',
158
+ filters: [{ field: 'id', value: '{{urlDifferentiator}}', operator: '=' }],
159
+ },
160
+ },
161
+ ]
162
+ // No route params in context → the token resolves to the sentinel.
163
+ await expect(
164
+ utils.executeNodes(nodes, [], {}, handlers, { triggerNodeId: 't' })
165
+ ).rejects.toThrow(/not available/)
166
+ expect(handlerCalls).toHaveLength(0)
167
+ })
168
+ })
169
+
170
+ describe('generated data-node handlers reject the sentinel defensively', () => {
171
+ // The API-route segment executor resolves configs without the
172
+ // executeNodes finalize pass, so the handlers themselves must catch a
173
+ // sentinel that slipped through.
174
+ it('data-select returns an error result for a sentinel filter', async () => {
175
+ const handler = loadHandler('data-select')
176
+ const result = await handler(
177
+ { tableName: 'products', filters: [{ field: 'id', value: SENTINEL, operator: '=' }] },
178
+ {}
179
+ )
180
+ expect(result.error).toContain('not available')
181
+ expect(result.rows).toEqual([])
182
+ })
183
+
184
+ it('data-update-item returns an error result for a sentinel filter', async () => {
185
+ const handler = loadHandler('data-update-item')
186
+ const result = await handler(
187
+ {
188
+ tableName: 'products',
189
+ filters: [{ field: 'id', value: SENTINEL, operator: '=' }],
190
+ columnMappings: [{ column: 'name', value: 'X' }],
191
+ },
192
+ {}
193
+ )
194
+ expect(result.error).toContain('not available')
195
+ expect(result.updatedCount).toBe(0)
196
+ })
197
+
198
+ it('data-create-item nulls sentinel columnMappings so the INSERT survives', async () => {
199
+ const fetchMock = jest.fn(async () => ({
200
+ ok: true,
201
+ json: async () => ({ id: 'new-id', item: { id: 'new-id' } }),
202
+ }))
203
+ const originalFetch = (globalThis as any).fetch
204
+ ;(globalThis as any).fetch = fetchMock
205
+ try {
206
+ const handler = loadHandler('data-create-item')
207
+ const result = await handler(
208
+ {
209
+ tableName: 'stock_movements',
210
+ columnMappings: [
211
+ { column: 'movement_type', value: 'receipt' },
212
+ { column: 'product_id', value: SENTINEL },
213
+ ],
214
+ },
215
+ {}
216
+ )
217
+ expect(result.id).toBe('new-id')
218
+ const body = JSON.parse((fetchMock.mock.calls[0] as any[])[1].body)
219
+ expect(body.columnMappings[1].value).toBeNull()
220
+ } finally {
221
+ ;(globalThis as any).fetch = originalFetch
222
+ }
223
+ })
224
+ })
225
+ })