@teleporthq/teleport-plugin-next-workflows 0.43.63 → 0.43.64

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.
@@ -0,0 +1,225 @@
1
+ // A workflow that WRITES a state key and then READS it back in the same run
2
+ // used to get the value from before the write.
3
+ //
4
+ // `state-update-local-state` calls the React setter, which only schedules a
5
+ // render, and `state-get-local-state` deliberately reads `stateValuesRef`
6
+ // (the "latest committed" values) rather than the trigger-time snapshot. The
7
+ // ref is rebuilt from state on render, so between the write and the next
8
+ // render it still held the old value.
9
+ //
10
+ // The chat's option chips are exactly this sequence — write the chip's text
11
+ // into the input state, then invoke the node that reads it back — so clicking
12
+ // a chip re-sent the previous message, and the assistant answered the question
13
+ // before the one the visitor had just chosen.
14
+
15
+ import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
16
+
17
+ const extractFunctionSource = (haystack: string, funcDecl: string): string => {
18
+ const startIdx = haystack.indexOf(funcDecl)
19
+ if (startIdx === -1) {
20
+ throw new Error('Helper not found: ' + funcDecl)
21
+ }
22
+ let depth = 0
23
+ let i = haystack.indexOf('{', startIdx)
24
+ if (i === -1) {
25
+ throw new Error('No opening brace after ' + funcDecl)
26
+ }
27
+ for (; i < haystack.length; i++) {
28
+ const ch = haystack.charAt(i)
29
+ if (ch === '{') {
30
+ depth++
31
+ } else if (ch === '}') {
32
+ depth--
33
+ if (depth === 0) {
34
+ return haystack.slice(startIdx, i + 1)
35
+ }
36
+ }
37
+ }
38
+ throw new Error('Unbalanced braces for ' + funcDecl)
39
+ }
40
+
41
+ const buildStructure = (): any => {
42
+ const workflow = {
43
+ id: 'wf-1',
44
+ name: 'Test WF',
45
+ trigger: {
46
+ type: 'event-page-loaded',
47
+ nodeId: 'trigger-1',
48
+ scope: 'page',
49
+ config: { pageId: 'page-1' },
50
+ },
51
+ nodes: [
52
+ {
53
+ id: 'update-1',
54
+ type: 'state-update-local-state',
55
+ config: { property: 'chatInputValue', value: 'x' },
56
+ stepNumber: 1,
57
+ label: 'X',
58
+ },
59
+ ],
60
+ edges: [{ id: 'e', source: 'trigger-1', target: 'update-1' }],
61
+ }
62
+ return {
63
+ uidl: {
64
+ name: 'Page',
65
+ outputOptions: { pageId: 'page-1', fileName: 'page-1' },
66
+ node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
67
+ stateDefinitions: {
68
+ chatInputValue: { type: 'string', defaultValue: '' },
69
+ draft: { type: 'object', defaultValue: {} },
70
+ },
71
+ },
72
+ chunks: [
73
+ {
74
+ type: 'chunk-type-ast',
75
+ name: 'jsx-component',
76
+ content: {
77
+ type: 'VariableDeclaration',
78
+ declarations: [
79
+ {
80
+ type: 'VariableDeclarator',
81
+ init: {
82
+ type: 'ArrowFunctionExpression',
83
+ body: {
84
+ type: 'BlockStatement',
85
+ body: [{ type: 'ReturnStatement', argument: null }],
86
+ },
87
+ },
88
+ },
89
+ ],
90
+ },
91
+ },
92
+ ],
93
+ options: { workflows: { workflows: { 'wf-1': workflow }, customNodes: {} } },
94
+ dependencies: {},
95
+ }
96
+ }
97
+
98
+ interface Harness {
99
+ update: (config: Record<string, unknown>, context: unknown) => Promise<unknown>
100
+ batchUpdate: (config: Record<string, unknown>, context: unknown) => Promise<unknown>
101
+ read: (config: Record<string, unknown>, context: unknown) => Promise<{ value: unknown }>
102
+ /** What React has actually committed — only advances on "render". */
103
+ committed: Record<string, unknown>
104
+ render: () => void
105
+ }
106
+
107
+ /**
108
+ * Instantiates the emitted `__createWorkflowHandlers` against a fake React:
109
+ * setters record into `pending`, and the ref is only rebuilt from `pending`
110
+ * when `render()` is called — the timing that made the bug possible.
111
+ */
112
+ const buildHarness = async (): Promise<Harness> => {
113
+ const plugin = createNextWorkflowPlugin({ isPage: true })
114
+ const structure = buildStructure()
115
+ await plugin(structure as any)
116
+ const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module')
117
+ if (!moduleChunk) {
118
+ throw new Error('workflow-module chunk not emitted by plugin')
119
+ }
120
+ const moduleCode = String(moduleChunk.content)
121
+
122
+ // The factory returns only its trigger wiring, so capture the handler map
123
+ // itself at the point it is built.
124
+ const HANDLERS_DECL = 'const __handlers = Object.assign({}, workflowClientHandlers);'
125
+ const factorySource = extractFunctionSource(moduleCode, 'function __createWorkflowHandlers')
126
+ if (!factorySource.includes(HANDLERS_DECL)) {
127
+ throw new Error('handler map declaration moved; update this test')
128
+ }
129
+
130
+ const source = [
131
+ extractFunctionSource(moduleCode, 'function __resolveName'),
132
+ extractFunctionSource(moduleCode, 'function __defaultValueForType'),
133
+ extractFunctionSource(moduleCode, 'function __coerceValue'),
134
+ factorySource.replace(HANDLERS_DECL, HANDLERS_DECL + '\n __captureHandlers(__handlers);'),
135
+ 'return __createWorkflowHandlers;',
136
+ ].join('\n')
137
+
138
+ const stateTypes = { chatInputValue: 'string', draft: 'object' }
139
+ const committed: Record<string, unknown> = { chatInputValue: '', draft: {} }
140
+ const pending: Record<string, unknown> = { ...committed }
141
+ const stateSetters: Record<string, (v: unknown) => void> = {
142
+ chatInputValue: (v) => {
143
+ pending.chatInputValue = v
144
+ },
145
+ draft: (v) => {
146
+ pending.draft = v
147
+ },
148
+ }
149
+ // Rebuilt from committed state on every render, exactly like the emitted
150
+ // `__wfStateRef.current = { ... }` assignment.
151
+ const stateValuesRef = { current: { ...committed } }
152
+
153
+ let handlers: Record<string, (config: any, context: any) => Promise<any>> = {}
154
+ // `__handlers` starts as a copy of the shared client handlers; the state
155
+ // handlers under test are assigned over it, so an empty map is enough.
156
+ const factory = new Function('stateTypes', 'workflowClientHandlers', '__captureHandlers', source)(
157
+ stateTypes,
158
+ {},
159
+ (map: Record<string, any>) => {
160
+ handlers = map
161
+ }
162
+ )
163
+ factory(stateSetters, stateTypes, stateValuesRef)
164
+
165
+ return {
166
+ update: handlers['state-update-local-state'],
167
+ batchUpdate: handlers['state-batch-update'],
168
+ read: handlers['state-get-local-state'],
169
+ committed,
170
+ render: () => {
171
+ Object.assign(committed, pending)
172
+ stateValuesRef.current = { ...committed }
173
+ },
174
+ }
175
+ }
176
+
177
+ describe('state written and read back inside one workflow run', () => {
178
+ it('reads the value the run just wrote, before React has re-rendered', async () => {
179
+ const h = await buildHarness()
180
+ const context = { __stateValues: { chatInputValue: 'the previous message' } }
181
+
182
+ await h.update({ property: 'chatInputValue', value: 'the message just chosen' }, context)
183
+ const result = await h.read({ property: 'chatInputValue' }, context)
184
+
185
+ expect(result.value).toBe('the message just chosen')
186
+ })
187
+
188
+ it('still reads the written value after the render lands', async () => {
189
+ const h = await buildHarness()
190
+ const context = { __stateValues: { chatInputValue: '' } }
191
+
192
+ await h.update({ property: 'chatInputValue', value: 'chosen' }, context)
193
+ h.render()
194
+ const result = await h.read({ property: 'chatInputValue' }, context)
195
+
196
+ expect(result.value).toBe('chosen')
197
+ // The setter really ran; the ref write is an early mirror of it, not a
198
+ // substitute that would leave React holding something else.
199
+ expect(h.committed.chatInputValue).toBe('chosen')
200
+ })
201
+
202
+ it('applies to a batch write too', async () => {
203
+ const h = await buildHarness()
204
+ const context = { __stateValues: {} }
205
+
206
+ await h.batchUpdate({ updates: [{ key: 'chatInputValue', value: 'batched' }] }, context)
207
+ const result = await h.read({ property: 'chatInputValue' }, context)
208
+
209
+ expect(result.value).toBe('batched')
210
+ })
211
+
212
+ it('leaves a key the page does not own to the warning path', async () => {
213
+ const h = await buildHarness()
214
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
215
+ const context = { __stateValues: {} }
216
+
217
+ await h.update({ property: 'notOnThisPage', value: 'nope' }, context)
218
+ const result = await h.read({ property: 'notOnThisPage' }, context)
219
+
220
+ // No setter means no state write, so the ref must not claim otherwise.
221
+ expect(result.value).toBeUndefined()
222
+ expect(warn).toHaveBeenCalled()
223
+ warn.mockRestore()
224
+ })
225
+ })