@teleporthq/teleport-plugin-next-workflows 0.43.46 → 0.43.48

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 (104) hide show
  1. package/__tests__/auth-env-secret-preservation.test.ts +35 -0
  2. package/__tests__/auth-nextauth-url.test.ts +28 -0
  3. package/__tests__/auth-session-cost.test.ts +614 -0
  4. package/__tests__/custom-js-params-env-invariance.test.ts +134 -0
  5. package/__tests__/data-node-not-awaited.test.ts +644 -0
  6. package/__tests__/element-trigger-static-attr-replace.test.ts +99 -0
  7. package/__tests__/element-visible-trigger-scoping.test.ts +296 -0
  8. package/__tests__/payment-charge-user-handler-serialization.test.ts +7 -5
  9. package/__tests__/payment-redirect-and-order-notification.test.ts +6 -4
  10. package/__tests__/raw-sql-param-binding.test.ts +82 -0
  11. package/__tests__/reserved-word-state-binding.test.ts +112 -0
  12. package/__tests__/runtime-stringified-boolean-truthiness.test.ts +76 -0
  13. package/__tests__/strip-event-handler-attrs.test.ts +59 -0
  14. package/__tests__/workflow-auth-guard.test.ts +277 -0
  15. package/dist/cjs/api-route-generator.d.ts +3 -3
  16. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  17. package/dist/cjs/api-route-generator.js +136 -11
  18. package/dist/cjs/api-route-generator.js.map +1 -1
  19. package/dist/cjs/auth-generator.d.ts +67 -1
  20. package/dist/cjs/auth-generator.d.ts.map +1 -1
  21. package/dist/cjs/auth-generator.js +458 -20
  22. package/dist/cjs/auth-generator.js.map +1 -1
  23. package/dist/cjs/await-result.d.ts +31 -0
  24. package/dist/cjs/await-result.d.ts.map +1 -0
  25. package/dist/cjs/await-result.js +50 -0
  26. package/dist/cjs/await-result.js.map +1 -0
  27. package/dist/cjs/executor-generator.d.ts.map +1 -1
  28. package/dist/cjs/executor-generator.js +162 -3
  29. package/dist/cjs/executor-generator.js.map +1 -1
  30. package/dist/cjs/nodes/account/account-get-current.d.ts.map +1 -1
  31. package/dist/cjs/nodes/account/account-get-current.js +44 -11
  32. package/dist/cjs/nodes/account/account-get-current.js.map +1 -1
  33. package/dist/cjs/nodes/account/account-login.d.ts.map +1 -1
  34. package/dist/cjs/nodes/account/account-login.js +10 -1
  35. package/dist/cjs/nodes/account/account-login.js.map +1 -1
  36. package/dist/cjs/raw-sql-param-binding.d.ts.map +1 -1
  37. package/dist/cjs/raw-sql-param-binding.js +32 -0
  38. package/dist/cjs/raw-sql-param-binding.js.map +1 -1
  39. package/dist/cjs/segment-splitter.d.ts.map +1 -1
  40. package/dist/cjs/segment-splitter.js +15 -7
  41. package/dist/cjs/segment-splitter.js.map +1 -1
  42. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  43. package/dist/cjs/workflow-auth-generator.d.ts +26 -0
  44. package/dist/cjs/workflow-auth-generator.d.ts.map +1 -0
  45. package/dist/cjs/workflow-auth-generator.js +166 -0
  46. package/dist/cjs/workflow-auth-generator.js.map +1 -0
  47. package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
  48. package/dist/cjs/workflow-component-plugin.js +107 -16
  49. package/dist/cjs/workflow-component-plugin.js.map +1 -1
  50. package/dist/cjs/workflow-project-plugin.d.ts +2 -1
  51. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  52. package/dist/cjs/workflow-project-plugin.js +170 -20
  53. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  54. package/dist/esm/api-route-generator.d.ts +3 -3
  55. package/dist/esm/api-route-generator.d.ts.map +1 -1
  56. package/dist/esm/api-route-generator.js +136 -11
  57. package/dist/esm/api-route-generator.js.map +1 -1
  58. package/dist/esm/auth-generator.d.ts +67 -1
  59. package/dist/esm/auth-generator.d.ts.map +1 -1
  60. package/dist/esm/auth-generator.js +453 -19
  61. package/dist/esm/auth-generator.js.map +1 -1
  62. package/dist/esm/await-result.d.ts +31 -0
  63. package/dist/esm/await-result.d.ts.map +1 -0
  64. package/dist/esm/await-result.js +45 -0
  65. package/dist/esm/await-result.js.map +1 -0
  66. package/dist/esm/executor-generator.d.ts.map +1 -1
  67. package/dist/esm/executor-generator.js +162 -3
  68. package/dist/esm/executor-generator.js.map +1 -1
  69. package/dist/esm/nodes/account/account-get-current.d.ts.map +1 -1
  70. package/dist/esm/nodes/account/account-get-current.js +44 -11
  71. package/dist/esm/nodes/account/account-get-current.js.map +1 -1
  72. package/dist/esm/nodes/account/account-login.d.ts.map +1 -1
  73. package/dist/esm/nodes/account/account-login.js +10 -1
  74. package/dist/esm/nodes/account/account-login.js.map +1 -1
  75. package/dist/esm/raw-sql-param-binding.d.ts.map +1 -1
  76. package/dist/esm/raw-sql-param-binding.js +32 -0
  77. package/dist/esm/raw-sql-param-binding.js.map +1 -1
  78. package/dist/esm/segment-splitter.d.ts.map +1 -1
  79. package/dist/esm/segment-splitter.js +15 -7
  80. package/dist/esm/segment-splitter.js.map +1 -1
  81. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  82. package/dist/esm/workflow-auth-generator.d.ts +26 -0
  83. package/dist/esm/workflow-auth-generator.d.ts.map +1 -0
  84. package/dist/esm/workflow-auth-generator.js +161 -0
  85. package/dist/esm/workflow-auth-generator.js.map +1 -0
  86. package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
  87. package/dist/esm/workflow-component-plugin.js +108 -17
  88. package/dist/esm/workflow-component-plugin.js.map +1 -1
  89. package/dist/esm/workflow-project-plugin.d.ts +2 -1
  90. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  91. package/dist/esm/workflow-project-plugin.js +169 -20
  92. package/dist/esm/workflow-project-plugin.js.map +1 -1
  93. package/package.json +4 -4
  94. package/src/api-route-generator.ts +139 -11
  95. package/src/auth-generator.ts +471 -19
  96. package/src/await-result.ts +52 -0
  97. package/src/executor-generator.ts +162 -3
  98. package/src/nodes/account/account-get-current.ts +44 -9
  99. package/src/nodes/account/account-login.ts +12 -1
  100. package/src/raw-sql-param-binding.ts +33 -0
  101. package/src/segment-splitter.ts +15 -7
  102. package/src/workflow-auth-generator.ts +187 -0
  103. package/src/workflow-component-plugin.ts +129 -20
  104. package/src/workflow-project-plugin.ts +186 -19
@@ -0,0 +1,99 @@
1
+ import { parse } from '@babel/parser'
2
+ import generate from '@babel/generator'
3
+ import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
4
+
5
+ // Regression guard for the "Resolved pill crashes with `func.apply is not a
6
+ // function`" bug.
7
+ //
8
+ // The alerts-inbox fixture carried a stray STATIC attr `onClick: ""` on
9
+ // `status-resolved-btn` (junk from the element author — behavior belongs in
10
+ // `events` / workflow triggers). The trigger injector saw an existing onClick
11
+ // attr, found its value was a string literal rather than an expression, and
12
+ // `continue`d — silently dropping the workflow wiring AND shipping
13
+ // `onClick=""`, which React invokes as a listener on click and crashes.
14
+ // The fix replaces a non-expression event prop with the workflow handler.
15
+
16
+ const clickWorkflow = (elementHtmlId: string) => ({
17
+ id: 'wf-resolved-pill',
18
+ name: 'Sync Status Filter Segment Copy 3',
19
+ trigger: {
20
+ type: 'event-element-clicked',
21
+ nodeId: 'trigger-click',
22
+ scope: 'element',
23
+ config: { elementHtmlId, eventType: 'click' },
24
+ },
25
+ nodes: [
26
+ {
27
+ id: 'update-1',
28
+ type: 'state-update-local-state',
29
+ config: { property: 'statusFilterSegment', value: 'Resolved' },
30
+ stepNumber: 1,
31
+ },
32
+ ],
33
+ edges: [{ id: 'e1', source: 'trigger-click', target: 'update-1' }],
34
+ })
35
+
36
+ const parseComponentChunk = (source: string) => {
37
+ const ast = parse(source, { plugins: ['jsx'] })
38
+ return {
39
+ type: 'chunk-type-ast',
40
+ name: 'jsx-component',
41
+ content: ast.program.body[0],
42
+ }
43
+ }
44
+
45
+ const runPlugin = async (componentSource: string) => {
46
+ const chunk = parseComponentChunk(componentSource)
47
+ const structure: any = {
48
+ uidl: {
49
+ name: 'AlertsInbox',
50
+ outputOptions: { pageId: 'page-alerts', fileName: 'alerts-inbox' },
51
+ stateDefinitions: {},
52
+ node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
53
+ },
54
+ chunks: [chunk],
55
+ options: {
56
+ workflows: {
57
+ workflows: { 'wf-resolved-pill': clickWorkflow('status-resolved-btn') },
58
+ customNodes: {},
59
+ },
60
+ },
61
+ dependencies: {},
62
+ }
63
+ const plugin = createNextWorkflowPlugin({ isPage: true })
64
+ await plugin(structure)
65
+ return generate(chunk.content as any).code
66
+ }
67
+
68
+ describe('element trigger wiring over a non-expression event prop', () => {
69
+ it('replaces a static onClick="" with the workflow handler call', async () => {
70
+ const code = await runPlugin(`const AlertsInbox = (props) => {
71
+ return (
72
+ <button id="status-resolved-btn" onClick="">Resolved</button>
73
+ )
74
+ }`)
75
+ expect(code).not.toContain('onClick=""')
76
+ expect(code).toContain(`__wfRef.current.elementTriggers["status-resolved-btn"]["onClick"]`)
77
+ })
78
+
79
+ it('still combines with an existing expression handler', async () => {
80
+ const code = await runPlugin(`const AlertsInbox = (props) => {
81
+ return (
82
+ <button id="status-resolved-btn" onClick={() => setStatusFilterSegment('Resolved')}>
83
+ Resolved
84
+ </button>
85
+ )
86
+ }`)
87
+ expect(code).toContain(`setStatusFilterSegment('Resolved')`)
88
+ expect(code).toContain(`__wfRef.current.elementTriggers["status-resolved-btn"]["onClick"]`)
89
+ })
90
+
91
+ it('adds the handler directly when the element has no event prop', async () => {
92
+ const code = await runPlugin(`const AlertsInbox = (props) => {
93
+ return (
94
+ <button id="status-resolved-btn">Resolved</button>
95
+ )
96
+ }`)
97
+ expect(code).toContain(`__wfRef.current.elementTriggers["status-resolved-btn"]["onClick"]`)
98
+ })
99
+ })
@@ -0,0 +1,296 @@
1
+ // `event-element-visible` is a LIFECYCLE trigger (IntersectionObserver, no React
2
+ // event prop) that is nonetheless bound to ONE element. Two defects followed
3
+ // from that, both shipped in run a15472af's cookie-consent banner:
4
+ //
5
+ // 1. The observer looked the element up by `config.nodeId` — the
6
+ // project-document id (`TQ_…`) — instead of `config.elementHtmlId`, the DOM
7
+ // id the generator actually emits. `getElementById` returned null on every
8
+ // page, so the observer was never constructed and the banner (whose only
9
+ // writer is that workflow) could never appear. 408 dead lookups across 24
10
+ // files.
11
+ //
12
+ // 2. Having no React prop, it fell into `lifecycleWorkflows`, which were
13
+ // activated unconditionally — bypassing the JSX-presence prune that is the
14
+ // only thing scoping element workflows to a page. One workflow shipped into
15
+ // EVERY generated file, 17 per page, including 404.js and logo.js.
16
+
17
+ import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
18
+
19
+ const HTML_ID = 'thq_container_pu-S'
20
+ const NODE_ID = 'TQ__X8oOlJm82'
21
+
22
+ const elementVisibleWorkflow = (config: Record<string, unknown>) => ({
23
+ id: 'wf-cookie-visible',
24
+ name: 'Cookie Consent Check on Load',
25
+ trigger: {
26
+ type: 'event-element-visible',
27
+ nodeId: 'trigger-element-visible',
28
+ scope: 'element',
29
+ config,
30
+ },
31
+ nodes: [
32
+ {
33
+ id: 'update-1',
34
+ type: 'state-update-local-state',
35
+ config: { property: 'cookieConsentVisible', value: true },
36
+ stepNumber: 1,
37
+ label: 'Show banner',
38
+ },
39
+ ],
40
+ edges: [{ id: 'e1', source: 'trigger-element-visible', target: 'update-1' }],
41
+ })
42
+
43
+ /** A JSX tree whose root element carries `id={HTML_ID}` — i.e. the page that owns it. */
44
+ const jsxWithElementId = (elementId: string | null) => ({
45
+ type: 'chunk-type-ast',
46
+ name: 'jsx-component',
47
+ content: {
48
+ type: 'VariableDeclaration',
49
+ declarations: [
50
+ {
51
+ type: 'VariableDeclarator',
52
+ init: {
53
+ type: 'ArrowFunctionExpression',
54
+ body: {
55
+ type: 'BlockStatement',
56
+ body: [
57
+ {
58
+ type: 'ReturnStatement',
59
+ argument: elementId
60
+ ? {
61
+ type: 'JSXElement',
62
+ openingElement: {
63
+ type: 'JSXOpeningElement',
64
+ name: { type: 'JSXIdentifier', name: 'div' },
65
+ attributes: [
66
+ {
67
+ type: 'JSXAttribute',
68
+ name: { type: 'JSXIdentifier', name: 'id' },
69
+ value: { type: 'StringLiteral', value: elementId },
70
+ },
71
+ ],
72
+ },
73
+ children: [],
74
+ }
75
+ : {
76
+ type: 'JSXElement',
77
+ openingElement: {
78
+ type: 'JSXOpeningElement',
79
+ name: { type: 'JSXIdentifier', name: 'div' },
80
+ attributes: [],
81
+ },
82
+ children: [],
83
+ },
84
+ },
85
+ ],
86
+ },
87
+ },
88
+ },
89
+ ],
90
+ },
91
+ })
92
+
93
+ const getWorkflowModule = async (
94
+ workflow: any,
95
+ renderedElementId: string | null
96
+ ): Promise<string | null> => {
97
+ const plugin = createNextWorkflowPlugin({ isPage: true })
98
+ const structure: any = {
99
+ uidl: {
100
+ name: 'Dashboard',
101
+ node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
102
+ stateDefinitions: { cookieConsentVisible: { type: 'boolean', defaultValue: false } },
103
+ },
104
+ chunks: [jsxWithElementId(renderedElementId)],
105
+ options: { workflows: { workflows: { [workflow.id]: workflow }, customNodes: {} } },
106
+ dependencies: {},
107
+ }
108
+ await plugin(structure)
109
+ const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module')
110
+ return moduleChunk ? String(moduleChunk.content) : null
111
+ }
112
+
113
+ describe('event-element-visible — element lookup', () => {
114
+ it('observes the DOM id, not the project-document node id', async () => {
115
+ const code = await getWorkflowModule(
116
+ elementVisibleWorkflow({ nodeId: NODE_ID, elementHtmlId: HTML_ID, once: true }),
117
+ HTML_ID
118
+ )
119
+ expect(code).not.toBeNull()
120
+ expect(code).toContain(`document.getElementById('${HTML_ID}')`)
121
+ expect(code).not.toContain(NODE_ID)
122
+ })
123
+
124
+ it('reports the DOM id in the trigger context too', async () => {
125
+ const code = await getWorkflowModule(
126
+ elementVisibleWorkflow({ nodeId: NODE_ID, elementHtmlId: HTML_ID, once: true }),
127
+ HTML_ID
128
+ )
129
+ expect(code).toContain(`elementId: '${HTML_ID}'`)
130
+ })
131
+
132
+ it('still falls back to nodeId when no html id was mapped', async () => {
133
+ const code = await getWorkflowModule(
134
+ elementVisibleWorkflow({ nodeId: NODE_ID, once: true }),
135
+ NODE_ID
136
+ )
137
+ expect(code).toContain(`document.getElementById('${NODE_ID}')`)
138
+ })
139
+ })
140
+
141
+ describe('event-element-visible — per-page scoping', () => {
142
+ it('is generated into the page that renders its element', async () => {
143
+ const code = await getWorkflowModule(
144
+ elementVisibleWorkflow({ nodeId: NODE_ID, elementHtmlId: HTML_ID, once: true }),
145
+ HTML_ID
146
+ )
147
+ expect(code).not.toBeNull()
148
+ expect(code).toContain('Element visible')
149
+ })
150
+
151
+ it('is NOT generated into a page that does not render its element', async () => {
152
+ // The 404 / logo case: 17 dead observers per file for a banner it never has.
153
+ const code = await getWorkflowModule(
154
+ elementVisibleWorkflow({ nodeId: NODE_ID, elementHtmlId: HTML_ID, once: true }),
155
+ 'thq_container_somewhere_else'
156
+ )
157
+ expect(code === null || !code.includes('Element visible')).toBe(true)
158
+ })
159
+
160
+ it('stays active when the trigger names no element at all (nothing to prune against)', async () => {
161
+ const code = await getWorkflowModule(
162
+ elementVisibleWorkflow({ once: true }),
163
+ 'thq_container_somewhere_else'
164
+ )
165
+ expect(code).not.toBeNull()
166
+ expect(code).toContain('Element visible')
167
+ })
168
+
169
+ it('leaves element-less lifecycle workflows (page-loaded) unconditionally active', async () => {
170
+ const pageLoaded = {
171
+ id: 'wf-page-loaded',
172
+ name: 'Init Page',
173
+ // `allPages` because an UNSCOPED page-loaded is deliberately skipped by
174
+ // pre-existing relevance filtering (workflow-component-plugin.ts:2653-2660)
175
+ // — unrelated to element scoping, but it would mask what this asserts.
176
+ trigger: {
177
+ type: 'event-page-loaded',
178
+ nodeId: 't1',
179
+ scope: 'page',
180
+ config: { allPages: true },
181
+ },
182
+ nodes: [
183
+ {
184
+ id: 'update-1',
185
+ type: 'state-update-local-state',
186
+ config: { property: 'cookieConsentVisible', value: true },
187
+ stepNumber: 1,
188
+ label: 'x',
189
+ },
190
+ ],
191
+ edges: [{ id: 'e1', source: 't1', target: 'update-1' }],
192
+ }
193
+ const code = await getWorkflowModule(pageLoaded, 'unrelated-element')
194
+ expect(code).not.toBeNull()
195
+ })
196
+ })
197
+
198
+ // Regression for the product-card `ReferenceError: __wfConfig_… is not defined`
199
+ // crash. The per-page prune removed the unmatched element-visible workflow from
200
+ // the CONFIG emission (`activeWorkflows`) but NOT from the lifecycle HANDLER
201
+ // emission — so the module still contained an IntersectionObserver that resolves
202
+ // its target with a DOCUMENT-WIDE `getElementById`, matches the shared cookie
203
+ // banner another component rendered, and calls `__execWf(__wfConfig_<id>)` for a
204
+ // config that was never declared. The single-workflow "not generated" test above
205
+ // can't catch it: with only the unmatched workflow the whole module is empty. The
206
+ // bug needs a SECOND, active workflow keeping the module alive — exactly the
207
+ // product card, which has real click handlers plus ~23 riding cookie observers.
208
+ describe('event-element-visible — config/handler integrity when the module is kept alive', () => {
209
+ // Unconditionally-active (no element target) — stands in for the card's real
210
+ // click workflows: it keeps the workflow module non-empty.
211
+ const keepAliveWorkflow = {
212
+ id: 'wf-keep-alive',
213
+ name: 'Init Card',
214
+ trigger: {
215
+ type: 'event-page-loaded',
216
+ nodeId: 't-alive',
217
+ scope: 'page',
218
+ config: { allPages: true },
219
+ },
220
+ nodes: [
221
+ {
222
+ id: 'a1',
223
+ type: 'state-update-local-state',
224
+ config: { property: 'cookieConsentVisible', value: true },
225
+ stepNumber: 1,
226
+ label: 'x',
227
+ },
228
+ ],
229
+ edges: [{ id: 'ea', source: 't-alive', target: 'a1' }],
230
+ }
231
+
232
+ const getModuleForWorkflows = async (
233
+ wfs: any[],
234
+ renderedElementId: string | null
235
+ ): Promise<string | null> => {
236
+ const plugin = createNextWorkflowPlugin({ isPage: true })
237
+ const workflowsMap: Record<string, any> = {}
238
+ for (const wf of wfs) {
239
+ workflowsMap[wf.id] = wf
240
+ }
241
+ const structure: any = {
242
+ uidl: {
243
+ name: 'ProductCard',
244
+ node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
245
+ stateDefinitions: { cookieConsentVisible: { type: 'boolean', defaultValue: false } },
246
+ },
247
+ chunks: [jsxWithElementId(renderedElementId)],
248
+ options: { workflows: { workflows: workflowsMap, customNodes: {} } },
249
+ dependencies: {},
250
+ }
251
+ await plugin(structure)
252
+ const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module')
253
+ return moduleChunk ? String(moduleChunk.content) : null
254
+ }
255
+
256
+ it('prunes an unmatched element-visible handler even when another workflow keeps the module alive', async () => {
257
+ const cookie = elementVisibleWorkflow({
258
+ nodeId: NODE_ID,
259
+ elementHtmlId: 'thq_not_on_this_card',
260
+ once: true,
261
+ })
262
+ const code = await getModuleForWorkflows([keepAliveWorkflow, cookie], HTML_ID)
263
+ expect(code).not.toBeNull()
264
+ // The keep-alive workflow is present, so the module really is non-empty.
265
+ expect(code).toContain('const __wfConfig_wf_keep_alive =')
266
+ // The unmatched cookie observer must be fully gone: no config, no handler,
267
+ // no reference to its element.
268
+ expect(code).not.toContain('__wfConfig_wf_cookie_visible')
269
+ expect(code).not.toContain('thq_not_on_this_card')
270
+ })
271
+
272
+ it('leaves no dangling __wfConfig_ reference (every used config is declared)', async () => {
273
+ const cookie = elementVisibleWorkflow({
274
+ nodeId: NODE_ID,
275
+ elementHtmlId: 'thq_not_on_this_card',
276
+ once: true,
277
+ })
278
+ const code = (await getModuleForWorkflows([keepAliveWorkflow, cookie], HTML_ID)) || ''
279
+ const declared = new Set(
280
+ Array.from(code.matchAll(/const (__wfConfig_[A-Za-z0-9_]+)\s*=/g)).map((m) => m[1])
281
+ )
282
+ const used = new Set(
283
+ Array.from(code.matchAll(/__execWf\(\s*(__wfConfig_[A-Za-z0-9_]+)/g)).map((m) => m[1])
284
+ )
285
+ const dangling = Array.from(used).filter((id) => !declared.has(id))
286
+ expect(dangling).toEqual([])
287
+ })
288
+
289
+ it('still emits the element-visible workflow on the card that DOES render its element', async () => {
290
+ const cookie = elementVisibleWorkflow({ nodeId: NODE_ID, elementHtmlId: HTML_ID, once: true })
291
+ const code = await getModuleForWorkflows([keepAliveWorkflow, cookie], HTML_ID)
292
+ expect(code).not.toBeNull()
293
+ expect(code).toContain('Element visible')
294
+ expect(code).toContain('const __wfConfig_wf_cookie_visible =')
295
+ })
296
+ })
@@ -44,9 +44,11 @@ describe('payment-charge-user handler serialization survives minification-style
44
44
  expect(fnStart).toBeGreaterThan(-1)
45
45
  // The next function declaration marks the end of toStripeMinorUnits's
46
46
  // body (functions are concatenated back-to-back by generateHandler()).
47
- const nextFnStart = handlerSource.indexOf('function chargeWithStripe', fnStart)
48
- expect(nextFnStart).toBeGreaterThan(fnStart)
49
- const body = handlerSource.slice(fnStart, nextFnStart)
47
+ // chargeWithStripe is `async function …` — the slice must stop BEFORE the
48
+ // `async` keyword or the extracted body ends with a dangling `async`.
49
+ const nextFn = /(?:async\s+)?function chargeWithStripe/.exec(handlerSource.slice(fnStart))
50
+ expect(nextFn).not.toBeNull()
51
+ const body = handlerSource.slice(fnStart, fnStart + nextFn!.index)
50
52
  expect(body).toContain('JPY')
51
53
  expect(body).toContain('BHD')
52
54
  })
@@ -62,8 +64,8 @@ describe('payment-charge-user handler serialization survives minification-style
62
64
  describe('toStripeMinorUnits — extracted from the serialized handler source', () => {
63
65
  const toStripeMinorUnits = (() => {
64
66
  const fnStart = handlerSource.indexOf('function toStripeMinorUnits')
65
- const nextFnStart = handlerSource.indexOf('function chargeWithStripe', fnStart)
66
- const body = handlerSource.slice(fnStart, nextFnStart)
67
+ const nextFn = /(?:async\s+)?function chargeWithStripe/.exec(handlerSource.slice(fnStart))
68
+ const body = handlerSource.slice(fnStart, fnStart + (nextFn as RegExpExecArray).index)
67
69
  return new Function(body + '\nreturn toStripeMinorUnits;')() as (
68
70
  major: unknown,
69
71
  currency: string
@@ -51,10 +51,12 @@ describe('payment-charge-user emits __redirectUrl alongside __terminal', () => {
51
51
  // reset-isPlacingOrder) are NOT executed before the buyer
52
52
  // leaves for Stripe — the cart should only clear on the
53
53
  // payment-success return URL.
54
- const stripeBlock = handlerSource.slice(
55
- handlerSource.indexOf('checkoutUrl: checkoutUrl'),
56
- handlerSource.indexOf('checkoutUrl: checkoutUrl') + 300
57
- )
54
+ // The source uses object shorthand (`{ checkoutUrl, ... }`), so anchor on
55
+ // the explicit __redirectUrl entry and assert __terminal sits in the same
56
+ // return object.
57
+ const anchor = handlerSource.indexOf('__redirectUrl: checkoutUrl')
58
+ expect(anchor).toBeGreaterThan(-1)
59
+ const stripeBlock = handlerSource.slice(Math.max(0, anchor - 300), anchor + 300)
58
60
  expect(stripeBlock).toContain('__terminal: true')
59
61
  expect(stripeBlock).toContain('__redirectUrl: checkoutUrl')
60
62
  })
@@ -107,6 +107,88 @@ describe('parameterizeWorkflowRawSqlInterpolations', () => {
107
107
  })
108
108
  })
109
109
 
110
+ describe('CASE/WHEN/THEN/ELSE and TRIM(... FROM ...) value positions', () => {
111
+ it('binds tokens after WHEN as positional parameters', () => {
112
+ const res = parameterizeRawSqlInterpolations(
113
+ "SELECT * FROM events WHERE (CASE WHEN {{ dateRange }} = 'today' THEN start_time < CURRENT_DATE WHEN {{ dateRange }} = '7d' THEN start_time < NOW() ELSE TRUE END)",
114
+ [{ name: 'dateRange', value: ref('get-range', 'value') }]
115
+ )
116
+ expect(res.residuals).toEqual([])
117
+ expect(res.query).toContain('CASE WHEN $1 =')
118
+ expect(res.query).toContain('WHEN $2 =')
119
+ expect(res.params).toHaveLength(2)
120
+ })
121
+
122
+ it('binds the operand of TRIM(BOTH FROM {{ token }})', () => {
123
+ const value = ref('get-status', 'value')
124
+ const res = parameterizeRawSqlInterpolations(
125
+ "SELECT * FROM t WHERE TRIM(BOTH FROM {{ status }}) IN ('', 'All')",
126
+ [{ name: 'status', value }]
127
+ )
128
+ expect(res.query).toBe("SELECT * FROM t WHERE TRIM(BOTH FROM $1) IN ('', 'All')")
129
+ expect(res.params).toEqual([value])
130
+ expect(res.residuals).toEqual([])
131
+ })
132
+
133
+ it('binds TRIM(LEADING/TRAILING ... FROM {{ token }}) variants', () => {
134
+ const value = ref('n', 'v')
135
+ for (const sql of [
136
+ "SELECT TRIM(LEADING '0' FROM {{ v }})",
137
+ 'SELECT TRIM(TRAILING FROM {{ v }})',
138
+ 'SELECT TRIM(FROM {{ v }})',
139
+ ]) {
140
+ const res = parameterizeRawSqlInterpolations(sql, [{ name: 'v', value }])
141
+ expect(res.residuals).toEqual([])
142
+ expect(res.params).toEqual([value])
143
+ }
144
+ })
145
+
146
+ it('still refuses a plain FROM {{ table }} identifier position', () => {
147
+ const res = parameterizeRawSqlInterpolations('SELECT * FROM {{ table }}', [
148
+ { name: 'table', value: ref('n', 't') },
149
+ ])
150
+ expect(res.residuals).toEqual(['{{ table }}'])
151
+ })
152
+
153
+ it('parameterizes the full alerts-filter fixture query with zero residuals', () => {
154
+ // Verbatim from examples/uidl-samples/project.json node `query-alerts` — the
155
+ // query this guard refused (8 residuals) before CASE/WHEN and TRIM(... FROM ...)
156
+ // were recognised as value positions.
157
+ const query =
158
+ "SELECT a.id, a.vehicle_id, a.driver_id, a.type_field, a.severity, a.occurred_at, a.resolved FROM alerts a WHERE (TRIM(BOTH FROM {{statusFilterSegment}}) IN ('', 'All') OR (CASE WHEN {{statusFilterSegment}} = 'Active' THEN a.resolved = false WHEN {{statusFilterSegment}} = 'Resolved' THEN a.resolved = true WHEN {{statusFilterSegment}} = 'Acknowledged' THEN EXISTS (SELECT 1 FROM alert_actions aa WHERE aa.alert_id = a.id AND aa.action_field = 'Acknowledged') ELSE false END)) AND ({{severityFilterSelect}} = 'All' OR a.severity = {{severityFilterSelect}}) AND (TRIM(BOTH FROM {{searchAlertsInput}}) = '' OR a.type_field ILIKE '%' || {{searchAlertsInput}} || '%') AND ({{timeRangeSelect}} = 'All Time' OR a.occurred_at >= CASE WHEN {{timeRangeSelect}} = 'Last 24h' THEN NOW() - INTERVAL '24 hours' WHEN {{timeRangeSelect}} = 'Last 7d' THEN NOW() - INTERVAL '7 days' WHEN {{timeRangeSelect}} = 'Last 30d' THEN NOW() - INTERVAL '30 days' ELSE a.occurred_at END) ORDER BY a.occurred_at DESC"
159
+ const status = ref('get-status', 'value')
160
+ const severity = ref('get-severity', 'value')
161
+ const search = ref('get-search', 'value')
162
+ const time = ref('get-time', 'value')
163
+ const res = parameterizeRawSqlInterpolations(query, [
164
+ { name: 'statusFilterSegment', value: status },
165
+ { name: 'severityFilterSelect', value: severity },
166
+ { name: 'searchAlertsInput', value: search },
167
+ { name: 'timeRangeSelect', value: time },
168
+ ])
169
+ expect(res.residuals).toEqual([])
170
+ expect(res.query).not.toContain('{{')
171
+ expect(res.params).toEqual([
172
+ status,
173
+ status,
174
+ status,
175
+ status,
176
+ severity,
177
+ severity,
178
+ search,
179
+ search,
180
+ time,
181
+ time,
182
+ time,
183
+ time,
184
+ ])
185
+ expect(res.query).toContain('TRIM(BOTH FROM $1)')
186
+ expect(res.query).toContain('CASE WHEN $2 =')
187
+ expect(res.query).toContain("ILIKE '%' || $8 || '%'")
188
+ expect(res.query).toContain('$12')
189
+ })
190
+ })
191
+
110
192
  describe('parameterizeAllWorkflowRawSql', () => {
111
193
  it('walks both workflows and customNodes (id-keyed records)', () => {
112
194
  const value = ref('n', 'ids')
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The page-level `__createWorkflowHandlers(stateSetters, stateTypes, stateValuesRef)`
3
+ * maps are keyed BY THE UIDL STATE NAME — that is what the runtime looks up when
4
+ * a node config says `{"property": "class"}` or a `state-batch-update` carries
5
+ * `{"key": "class"}`. Both shapes appear verbatim in the run that broke.
6
+ *
7
+ * So the fix for the reserved-word crash has to be asymmetric:
8
+ * KEY → the UIDL name, ALWAYS (renaming it silently breaks every workflow
9
+ * binding written against it — `__stateNameMap` has no camel→original
10
+ * entry, so the runtime would just warn "no setter for class" and skip)
11
+ * VALUE → the sanitised React binding, because `const [class, …]` is a
12
+ * SyntaxError and the whole project build dies on it.
13
+ */
14
+
15
+ import generate from '@babel/generator'
16
+ import * as types from '@babel/types'
17
+ import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
18
+
19
+ /** `const Page = (props) => { return null }` — the shape the plugin injects into. */
20
+ const buildComponentChunkContent = (): types.VariableDeclaration =>
21
+ types.variableDeclaration('const', [
22
+ types.variableDeclarator(
23
+ types.identifier('AddCharacter'),
24
+ types.arrowFunctionExpression(
25
+ [types.identifier('props')],
26
+ types.blockStatement([types.returnStatement(types.nullLiteral())])
27
+ )
28
+ ),
29
+ ])
30
+
31
+ const buildStructure = (stateNames: string[]): any => {
32
+ const triggerNodeId = 'trigger-1'
33
+ const workflow = {
34
+ id: 'wf-1',
35
+ name: 'Batch update from the form',
36
+ trigger: {
37
+ type: 'event-page-loaded',
38
+ nodeId: triggerNodeId,
39
+ scope: 'page',
40
+ config: { pageId: 'page-1' },
41
+ },
42
+ nodes: [
43
+ {
44
+ id: 'batch-1',
45
+ type: 'state-batch-update',
46
+ config: {
47
+ scope: 'local',
48
+ updates: stateNames.map((name) => ({ key: name, value: '' })),
49
+ },
50
+ stepNumber: 1,
51
+ label: 'Batch State Update',
52
+ },
53
+ ],
54
+ edges: [{ id: 'e', source: triggerNodeId, target: 'batch-1' }],
55
+ }
56
+ return {
57
+ uidl: {
58
+ name: 'AddCharacter',
59
+ outputOptions: { pageId: 'page-1', fileName: 'add-character' },
60
+ node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
61
+ stateDefinitions: Object.fromEntries(
62
+ stateNames.map((name) => [name, { type: 'string', defaultValue: '' }])
63
+ ),
64
+ },
65
+ chunks: [
66
+ {
67
+ type: 'chunk-type-ast',
68
+ name: 'jsx-component',
69
+ content: buildComponentChunkContent(),
70
+ },
71
+ ],
72
+ options: { workflows: { workflows: { 'wf-1': workflow }, customNodes: {} } },
73
+ dependencies: {},
74
+ }
75
+ }
76
+
77
+ const generateComponentCode = async (stateNames: string[]): Promise<string> => {
78
+ const plugin = createNextWorkflowPlugin({ isPage: true })
79
+ const structure = buildStructure(stateNames)
80
+ await plugin(structure as any)
81
+ const componentChunk = (structure.chunks as any[]).find((c: any) => c.name === 'jsx-component')
82
+ // The maps are injected into the component BODY, not the module chunk.
83
+ return generate(componentChunk.content).code
84
+ }
85
+
86
+ describe('reserved-word state in the workflow runtime maps', () => {
87
+ it('keys every map by the UIDL name so `config.property` still resolves', async () => {
88
+ const code = await generateComponentCode(['class', 'characterName'])
89
+ // `{ class: … }` is a legal object key even though `class` is reserved.
90
+ expect(code).toMatch(/class:\s*setClass/)
91
+ expect(code).toMatch(/class:\s*["']string["']/)
92
+ })
93
+
94
+ it('binds the VALUE to the sanitised identifier the state hook declared', async () => {
95
+ const code = await generateComponentCode(['class', 'characterName'])
96
+ // __wfStateRef.current = { class: class_ } — never `class: class`.
97
+ expect(code).toMatch(/class:\s*class_/)
98
+ expect(code).not.toMatch(/class:\s*class\b(?!_)/)
99
+ })
100
+
101
+ it('leaves ordinary state names byte-identical', async () => {
102
+ const code = await generateComponentCode(['class', 'characterName'])
103
+ expect(code).toMatch(/characterName:\s*characterName/)
104
+ expect(code).toMatch(/characterName:\s*setCharacterName/)
105
+ })
106
+
107
+ it('quotes a key that is not identifier syntax instead of emitting it bare', async () => {
108
+ const code = await generateComponentCode(['my key'])
109
+ expect(code).toContain('"my key"')
110
+ expect(code).not.toMatch(/[^"']my key:/)
111
+ })
112
+ })