@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.
- package/__tests__/auth-env-secret-preservation.test.ts +35 -0
- package/__tests__/auth-nextauth-url.test.ts +28 -0
- package/__tests__/auth-session-cost.test.ts +614 -0
- package/__tests__/custom-js-params-env-invariance.test.ts +134 -0
- package/__tests__/data-node-not-awaited.test.ts +644 -0
- package/__tests__/element-trigger-static-attr-replace.test.ts +99 -0
- package/__tests__/element-visible-trigger-scoping.test.ts +296 -0
- package/__tests__/payment-charge-user-handler-serialization.test.ts +7 -5
- package/__tests__/payment-redirect-and-order-notification.test.ts +6 -4
- package/__tests__/raw-sql-param-binding.test.ts +82 -0
- package/__tests__/reserved-word-state-binding.test.ts +112 -0
- package/__tests__/runtime-stringified-boolean-truthiness.test.ts +76 -0
- package/__tests__/strip-event-handler-attrs.test.ts +59 -0
- package/__tests__/workflow-auth-guard.test.ts +277 -0
- package/dist/cjs/api-route-generator.d.ts +3 -3
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +136 -11
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/auth-generator.d.ts +67 -1
- package/dist/cjs/auth-generator.d.ts.map +1 -1
- package/dist/cjs/auth-generator.js +458 -20
- package/dist/cjs/auth-generator.js.map +1 -1
- package/dist/cjs/await-result.d.ts +31 -0
- package/dist/cjs/await-result.d.ts.map +1 -0
- package/dist/cjs/await-result.js +50 -0
- package/dist/cjs/await-result.js.map +1 -0
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +162 -3
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/nodes/account/account-get-current.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-get-current.js +44 -11
- package/dist/cjs/nodes/account/account-get-current.js.map +1 -1
- package/dist/cjs/nodes/account/account-login.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-login.js +10 -1
- package/dist/cjs/nodes/account/account-login.js.map +1 -1
- package/dist/cjs/raw-sql-param-binding.d.ts.map +1 -1
- package/dist/cjs/raw-sql-param-binding.js +32 -0
- package/dist/cjs/raw-sql-param-binding.js.map +1 -1
- package/dist/cjs/segment-splitter.d.ts.map +1 -1
- package/dist/cjs/segment-splitter.js +15 -7
- package/dist/cjs/segment-splitter.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/workflow-auth-generator.d.ts +26 -0
- package/dist/cjs/workflow-auth-generator.d.ts.map +1 -0
- package/dist/cjs/workflow-auth-generator.js +166 -0
- package/dist/cjs/workflow-auth-generator.js.map +1 -0
- package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-component-plugin.js +107 -16
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/cjs/workflow-project-plugin.d.ts +2 -1
- package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-project-plugin.js +170 -20
- package/dist/cjs/workflow-project-plugin.js.map +1 -1
- package/dist/esm/api-route-generator.d.ts +3 -3
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +136 -11
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/auth-generator.d.ts +67 -1
- package/dist/esm/auth-generator.d.ts.map +1 -1
- package/dist/esm/auth-generator.js +453 -19
- package/dist/esm/auth-generator.js.map +1 -1
- package/dist/esm/await-result.d.ts +31 -0
- package/dist/esm/await-result.d.ts.map +1 -0
- package/dist/esm/await-result.js +45 -0
- package/dist/esm/await-result.js.map +1 -0
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +162 -3
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/nodes/account/account-get-current.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-get-current.js +44 -11
- package/dist/esm/nodes/account/account-get-current.js.map +1 -1
- package/dist/esm/nodes/account/account-login.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-login.js +10 -1
- package/dist/esm/nodes/account/account-login.js.map +1 -1
- package/dist/esm/raw-sql-param-binding.d.ts.map +1 -1
- package/dist/esm/raw-sql-param-binding.js +32 -0
- package/dist/esm/raw-sql-param-binding.js.map +1 -1
- package/dist/esm/segment-splitter.d.ts.map +1 -1
- package/dist/esm/segment-splitter.js +15 -7
- package/dist/esm/segment-splitter.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/workflow-auth-generator.d.ts +26 -0
- package/dist/esm/workflow-auth-generator.d.ts.map +1 -0
- package/dist/esm/workflow-auth-generator.js +161 -0
- package/dist/esm/workflow-auth-generator.js.map +1 -0
- package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-component-plugin.js +108 -17
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/dist/esm/workflow-project-plugin.d.ts +2 -1
- package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-project-plugin.js +169 -20
- package/dist/esm/workflow-project-plugin.js.map +1 -1
- package/package.json +4 -4
- package/src/api-route-generator.ts +139 -11
- package/src/auth-generator.ts +471 -19
- package/src/await-result.ts +52 -0
- package/src/executor-generator.ts +162 -3
- package/src/nodes/account/account-get-current.ts +44 -9
- package/src/nodes/account/account-login.ts +12 -1
- package/src/raw-sql-param-binding.ts +33 -0
- package/src/segment-splitter.ts +15 -7
- package/src/workflow-auth-generator.ts +187 -0
- package/src/workflow-component-plugin.ts +129 -20
- 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
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
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
|
|
66
|
-
const body = handlerSource.slice(fnStart,
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
})
|