@teleporthq/teleport-plugin-next-workflows 0.43.45 → 0.43.47

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 (112) hide show
  1. package/__tests__/_helpers/run-generated-middleware.ts +130 -0
  2. package/__tests__/auth-env-secret-preservation.test.ts +35 -0
  3. package/__tests__/auth-nextauth-url.test.ts +28 -0
  4. package/__tests__/auth-session-cost.test.ts +614 -0
  5. package/__tests__/custom-js-params-env-invariance.test.ts +134 -0
  6. package/__tests__/data-node-not-awaited.test.ts +644 -0
  7. package/__tests__/element-trigger-static-attr-replace.test.ts +99 -0
  8. package/__tests__/element-visible-trigger-scoping.test.ts +296 -0
  9. package/__tests__/form-submitted-trigger-context.test.ts +100 -0
  10. package/__tests__/middleware-guest-order-details.test.ts +258 -0
  11. package/__tests__/middleware-row-owned-pages.test.ts +32 -9
  12. package/__tests__/payment-charge-user-handler-serialization.test.ts +7 -5
  13. package/__tests__/payment-redirect-and-order-notification.test.ts +6 -4
  14. package/__tests__/raw-sql-param-binding.test.ts +82 -0
  15. package/__tests__/reserved-word-state-binding.test.ts +112 -0
  16. package/__tests__/strip-event-handler-attrs.test.ts +59 -0
  17. package/__tests__/workflow-auth-guard.test.ts +277 -0
  18. package/dist/cjs/api-route-generator.d.ts +3 -3
  19. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  20. package/dist/cjs/api-route-generator.js +136 -11
  21. package/dist/cjs/api-route-generator.js.map +1 -1
  22. package/dist/cjs/auth-generator.d.ts +67 -1
  23. package/dist/cjs/auth-generator.d.ts.map +1 -1
  24. package/dist/cjs/auth-generator.js +573 -31
  25. package/dist/cjs/auth-generator.js.map +1 -1
  26. package/dist/cjs/await-result.d.ts +31 -0
  27. package/dist/cjs/await-result.d.ts.map +1 -0
  28. package/dist/cjs/await-result.js +50 -0
  29. package/dist/cjs/await-result.js.map +1 -0
  30. package/dist/cjs/executor-generator.d.ts.map +1 -1
  31. package/dist/cjs/executor-generator.js +150 -1
  32. package/dist/cjs/executor-generator.js.map +1 -1
  33. package/dist/cjs/nodes/account/account-get-current.d.ts.map +1 -1
  34. package/dist/cjs/nodes/account/account-get-current.js +44 -11
  35. package/dist/cjs/nodes/account/account-get-current.js.map +1 -1
  36. package/dist/cjs/nodes/account/account-login.d.ts.map +1 -1
  37. package/dist/cjs/nodes/account/account-login.js +10 -1
  38. package/dist/cjs/nodes/account/account-login.js.map +1 -1
  39. package/dist/cjs/raw-sql-param-binding.d.ts.map +1 -1
  40. package/dist/cjs/raw-sql-param-binding.js +32 -0
  41. package/dist/cjs/raw-sql-param-binding.js.map +1 -1
  42. package/dist/cjs/segment-splitter.d.ts.map +1 -1
  43. package/dist/cjs/segment-splitter.js +15 -7
  44. package/dist/cjs/segment-splitter.js.map +1 -1
  45. package/dist/cjs/trigger-generator.js +7 -1
  46. package/dist/cjs/trigger-generator.js.map +1 -1
  47. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  48. package/dist/cjs/workflow-auth-generator.d.ts +26 -0
  49. package/dist/cjs/workflow-auth-generator.d.ts.map +1 -0
  50. package/dist/cjs/workflow-auth-generator.js +166 -0
  51. package/dist/cjs/workflow-auth-generator.js.map +1 -0
  52. package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
  53. package/dist/cjs/workflow-component-plugin.js +124 -17
  54. package/dist/cjs/workflow-component-plugin.js.map +1 -1
  55. package/dist/cjs/workflow-project-plugin.d.ts +2 -1
  56. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  57. package/dist/cjs/workflow-project-plugin.js +170 -20
  58. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  59. package/dist/esm/api-route-generator.d.ts +3 -3
  60. package/dist/esm/api-route-generator.d.ts.map +1 -1
  61. package/dist/esm/api-route-generator.js +136 -11
  62. package/dist/esm/api-route-generator.js.map +1 -1
  63. package/dist/esm/auth-generator.d.ts +67 -1
  64. package/dist/esm/auth-generator.d.ts.map +1 -1
  65. package/dist/esm/auth-generator.js +568 -30
  66. package/dist/esm/auth-generator.js.map +1 -1
  67. package/dist/esm/await-result.d.ts +31 -0
  68. package/dist/esm/await-result.d.ts.map +1 -0
  69. package/dist/esm/await-result.js +45 -0
  70. package/dist/esm/await-result.js.map +1 -0
  71. package/dist/esm/executor-generator.d.ts.map +1 -1
  72. package/dist/esm/executor-generator.js +150 -1
  73. package/dist/esm/executor-generator.js.map +1 -1
  74. package/dist/esm/nodes/account/account-get-current.d.ts.map +1 -1
  75. package/dist/esm/nodes/account/account-get-current.js +44 -11
  76. package/dist/esm/nodes/account/account-get-current.js.map +1 -1
  77. package/dist/esm/nodes/account/account-login.d.ts.map +1 -1
  78. package/dist/esm/nodes/account/account-login.js +10 -1
  79. package/dist/esm/nodes/account/account-login.js.map +1 -1
  80. package/dist/esm/raw-sql-param-binding.d.ts.map +1 -1
  81. package/dist/esm/raw-sql-param-binding.js +32 -0
  82. package/dist/esm/raw-sql-param-binding.js.map +1 -1
  83. package/dist/esm/segment-splitter.d.ts.map +1 -1
  84. package/dist/esm/segment-splitter.js +15 -7
  85. package/dist/esm/segment-splitter.js.map +1 -1
  86. package/dist/esm/trigger-generator.js +7 -1
  87. package/dist/esm/trigger-generator.js.map +1 -1
  88. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  89. package/dist/esm/workflow-auth-generator.d.ts +26 -0
  90. package/dist/esm/workflow-auth-generator.d.ts.map +1 -0
  91. package/dist/esm/workflow-auth-generator.js +161 -0
  92. package/dist/esm/workflow-auth-generator.js.map +1 -0
  93. package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
  94. package/dist/esm/workflow-component-plugin.js +125 -18
  95. package/dist/esm/workflow-component-plugin.js.map +1 -1
  96. package/dist/esm/workflow-project-plugin.d.ts +2 -1
  97. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  98. package/dist/esm/workflow-project-plugin.js +169 -20
  99. package/dist/esm/workflow-project-plugin.js.map +1 -1
  100. package/package.json +4 -4
  101. package/src/api-route-generator.ts +139 -11
  102. package/src/auth-generator.ts +600 -30
  103. package/src/await-result.ts +52 -0
  104. package/src/executor-generator.ts +150 -1
  105. package/src/nodes/account/account-get-current.ts +44 -9
  106. package/src/nodes/account/account-login.ts +12 -1
  107. package/src/raw-sql-param-binding.ts +33 -0
  108. package/src/segment-splitter.ts +15 -7
  109. package/src/trigger-generator.ts +7 -1
  110. package/src/workflow-auth-generator.ts +187 -0
  111. package/src/workflow-component-plugin.ts +146 -21
  112. 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
+ })
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The form-submitted trigger must expose its fields where generated validators
3
+ * actually look for them.
4
+ *
5
+ * THE DEFECT (run 4b049fe6, reported: "when I update a guild and click Save
6
+ * changes I get 'Guild Name is required' even though the name is added"):
7
+ *
8
+ * Edit Guild Submit → validate-form:
9
+ * const form = params[0] || {}
10
+ * if (!form["name"]) return { isValid: false, error: "Guild Name is required" }
11
+ *
12
+ * `params[0]` is the trigger — the executor writes `context[triggerNodeId]`
13
+ * first — and the trigger carried the fields one level down under `formData`.
14
+ * `params[0].name` was `undefined`, so every save reported the filled-in name as
15
+ * missing. `general-extract-form-data` has always returned its fields flat for
16
+ * exactly this reason; the trigger now matches it.
17
+ *
18
+ * These tests EXECUTE the generated handler source rather than asserting on
19
+ * strings, so they fail if the emitted expression ever stops behaving.
20
+ */
21
+
22
+ /** Evaluate the `triggerContext = …` expression the generator emits. */
23
+ function evaluateTriggerContext(
24
+ source: string,
25
+ formData: Record<string, unknown>
26
+ ): Record<string, unknown> {
27
+ const match = /const triggerContext = ([\s\S]*?);\n/.exec(source)
28
+ if (!match) {
29
+ throw new Error('no triggerContext assignment found in generated source')
30
+ }
31
+ const expression = match[1]
32
+ // The generated code reads `event.target`; substitute a stand-in.
33
+ .replace(/event\.target/g, '__el')
34
+ const fn = new Function(
35
+ 'formData',
36
+ '__el',
37
+ `const Date = { now: () => 1234 }; return (${expression});`
38
+ )
39
+ return fn(formData, { tagName: 'FORM' }) as Record<string, unknown>
40
+ }
41
+
42
+ describe('event-form-submitted trigger context', () => {
43
+ const SOURCE = [
44
+ 'async function(event) {',
45
+ ' event.preventDefault();',
46
+ ' const formData = {};',
47
+ ' const fd = new FormData(event.target);',
48
+ ' fd.forEach(function(v, k) { formData[k] = v; });',
49
+ " const triggerContext = Object.assign({}, formData, { fields: formData, formData: formData, formId: 'edit-guild-form', triggerElement: event.target, element: event.target, timestamp: Date.now() });",
50
+ '',
51
+ ].join('\n')
52
+
53
+ it('exposes each submitted field at the top level', () => {
54
+ const ctx = evaluateTriggerContext(SOURCE, { name: 'Order of the Light', server: 'Area 52' })
55
+ expect(ctx.name).toBe('Order of the Light')
56
+ expect(ctx.server).toBe('Area 52')
57
+ })
58
+
59
+ it('still exposes them under formData and fields', () => {
60
+ const ctx = evaluateTriggerContext(SOURCE, { name: 'Order of the Light' })
61
+ expect((ctx.formData as Record<string, unknown>).name).toBe('Order of the Light')
62
+ expect((ctx.fields as Record<string, unknown>).name).toBe('Order of the Light')
63
+ })
64
+
65
+ it('keeps the reserved keys even when a field is named after one', () => {
66
+ // The spread comes FIRST, so a field literally called `formId` or `element`
67
+ // cannot shadow the runtime's own keys.
68
+ const ctx = evaluateTriggerContext(SOURCE, {
69
+ formId: 'attacker',
70
+ element: 'attacker',
71
+ timestamp: 'attacker',
72
+ formData: 'attacker',
73
+ })
74
+ expect(ctx.formId).toBe('edit-guild-form')
75
+ expect(ctx.element).toEqual({ tagName: 'FORM' })
76
+ expect(ctx.timestamp).toBe(1234)
77
+ expect(typeof ctx.formData).toBe('object')
78
+ })
79
+
80
+ it('reproduces the exact Edit Guild validator, which now passes', () => {
81
+ const ctx = evaluateTriggerContext(SOURCE, {
82
+ name: 'Order of the Light',
83
+ server: 'Area 52',
84
+ leader_account: 'Thrall',
85
+ visibility: 'Public',
86
+ })
87
+ const validate = (params: unknown[]) => {
88
+ const form = (params[0] || {}) as Record<string, string>
89
+ const required = ['name', 'server', 'leader_account', 'visibility']
90
+ for (const key of required) {
91
+ const value = form[key]
92
+ if (!value || (typeof value === 'string' && value.trim() === '')) {
93
+ return { isValid: false, error: `${key} is required` }
94
+ }
95
+ }
96
+ return { isValid: true, error: '' }
97
+ }
98
+ expect(validate([ctx])).toEqual({ isValid: true, error: '' })
99
+ })
100
+ })