@teleporthq/teleport-plugin-next-workflows 0.43.62 → 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.
Files changed (81) hide show
  1. package/__tests__/data-update-item-runtime.test.ts +39 -0
  2. package/__tests__/page-revalidate-node.test.ts +276 -0
  3. package/__tests__/payment-refund.test.ts +135 -0
  4. package/__tests__/state-same-run-write-then-read.test.ts +225 -0
  5. package/dist/cjs/nodes/analytics/commerce-tracking.d.ts +46 -0
  6. package/dist/cjs/nodes/analytics/commerce-tracking.d.ts.map +1 -0
  7. package/dist/cjs/nodes/analytics/commerce-tracking.js +94 -0
  8. package/dist/cjs/nodes/analytics/commerce-tracking.js.map +1 -0
  9. package/dist/cjs/nodes/cache/page-revalidate.d.ts +3 -0
  10. package/dist/cjs/nodes/cache/page-revalidate.d.ts.map +1 -0
  11. package/dist/cjs/nodes/cache/page-revalidate.js +125 -0
  12. package/dist/cjs/nodes/cache/page-revalidate.js.map +1 -0
  13. package/dist/cjs/nodes/cart/cart-add-item.d.ts.map +1 -1
  14. package/dist/cjs/nodes/cart/cart-add-item.js +22 -1
  15. package/dist/cjs/nodes/cart/cart-add-item.js.map +1 -1
  16. package/dist/cjs/nodes/cart/cart-remove-item.d.ts.map +1 -1
  17. package/dist/cjs/nodes/cart/cart-remove-item.js +23 -1
  18. package/dist/cjs/nodes/cart/cart-remove-item.js.map +1 -1
  19. package/dist/cjs/nodes/data/data-create-item.d.ts.map +1 -1
  20. package/dist/cjs/nodes/data/data-create-item.js +42 -1
  21. package/dist/cjs/nodes/data/data-create-item.js.map +1 -1
  22. package/dist/cjs/nodes/data/data-update-item.d.ts.map +1 -1
  23. package/dist/cjs/nodes/data/data-update-item.js +38 -3
  24. package/dist/cjs/nodes/data/data-update-item.js.map +1 -1
  25. package/dist/cjs/nodes/index.d.ts.map +1 -1
  26. package/dist/cjs/nodes/index.js +3 -1
  27. package/dist/cjs/nodes/index.js.map +1 -1
  28. package/dist/cjs/nodes/payment/payment-refund.d.ts +3 -0
  29. package/dist/cjs/nodes/payment/payment-refund.d.ts.map +1 -0
  30. package/dist/cjs/nodes/payment/payment-refund.js +446 -0
  31. package/dist/cjs/nodes/payment/payment-refund.js.map +1 -0
  32. package/dist/cjs/secret-collector.d.ts.map +1 -1
  33. package/dist/cjs/secret-collector.js +3 -0
  34. package/dist/cjs/secret-collector.js.map +1 -1
  35. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  36. package/dist/cjs/workflow-component-plugin.js +23 -3
  37. package/dist/cjs/workflow-component-plugin.js.map +1 -1
  38. package/dist/esm/nodes/analytics/commerce-tracking.d.ts +46 -0
  39. package/dist/esm/nodes/analytics/commerce-tracking.d.ts.map +1 -0
  40. package/dist/esm/nodes/analytics/commerce-tracking.js +90 -0
  41. package/dist/esm/nodes/analytics/commerce-tracking.js.map +1 -0
  42. package/dist/esm/nodes/cache/page-revalidate.d.ts +3 -0
  43. package/dist/esm/nodes/cache/page-revalidate.d.ts.map +1 -0
  44. package/dist/esm/nodes/cache/page-revalidate.js +122 -0
  45. package/dist/esm/nodes/cache/page-revalidate.js.map +1 -0
  46. package/dist/esm/nodes/cart/cart-add-item.d.ts.map +1 -1
  47. package/dist/esm/nodes/cart/cart-add-item.js +22 -1
  48. package/dist/esm/nodes/cart/cart-add-item.js.map +1 -1
  49. package/dist/esm/nodes/cart/cart-remove-item.d.ts.map +1 -1
  50. package/dist/esm/nodes/cart/cart-remove-item.js +23 -1
  51. package/dist/esm/nodes/cart/cart-remove-item.js.map +1 -1
  52. package/dist/esm/nodes/data/data-create-item.d.ts.map +1 -1
  53. package/dist/esm/nodes/data/data-create-item.js +42 -1
  54. package/dist/esm/nodes/data/data-create-item.js.map +1 -1
  55. package/dist/esm/nodes/data/data-update-item.d.ts.map +1 -1
  56. package/dist/esm/nodes/data/data-update-item.js +38 -3
  57. package/dist/esm/nodes/data/data-update-item.js.map +1 -1
  58. package/dist/esm/nodes/index.d.ts.map +1 -1
  59. package/dist/esm/nodes/index.js +3 -1
  60. package/dist/esm/nodes/index.js.map +1 -1
  61. package/dist/esm/nodes/payment/payment-refund.d.ts +3 -0
  62. package/dist/esm/nodes/payment/payment-refund.d.ts.map +1 -0
  63. package/dist/esm/nodes/payment/payment-refund.js +443 -0
  64. package/dist/esm/nodes/payment/payment-refund.js.map +1 -0
  65. package/dist/esm/secret-collector.d.ts.map +1 -1
  66. package/dist/esm/secret-collector.js +3 -0
  67. package/dist/esm/secret-collector.js.map +1 -1
  68. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  69. package/dist/esm/workflow-component-plugin.js +23 -3
  70. package/dist/esm/workflow-component-plugin.js.map +1 -1
  71. package/package.json +4 -4
  72. package/src/nodes/analytics/commerce-tracking.ts +93 -0
  73. package/src/nodes/cache/page-revalidate.ts +140 -0
  74. package/src/nodes/cart/cart-add-item.ts +35 -1
  75. package/src/nodes/cart/cart-remove-item.ts +36 -1
  76. package/src/nodes/data/data-create-item.ts +44 -1
  77. package/src/nodes/data/data-update-item.ts +39 -3
  78. package/src/nodes/index.ts +4 -0
  79. package/src/nodes/payment/payment-refund.ts +512 -0
  80. package/src/secret-collector.ts +3 -0
  81. package/src/workflow-component-plugin.ts +23 -3
@@ -99,6 +99,45 @@ describe('data-update-item runtime output + null-safety', () => {
99
99
  expect(result.affected).toBe(0)
100
100
  })
101
101
 
102
+ /*
103
+ A value that could not cross the client→server hand-off arrives as the
104
+ pruner's placeholder. Written through, it REPLACES the stored content with
105
+ `{"__truncated":true}` — which is how a rich-text field carrying an inlined
106
+ image emptied a whole custom page's block tree. The column keeps what it has
107
+ instead, exactly like an undefined mapping.
108
+ */
109
+ it('keeps the stored value when the new one arrived truncated', async () => {
110
+ const calls = installFetch({ id: 'r1', updatedCount: 1 })
111
+ await handler(
112
+ {
113
+ tableName: 'teleport_pages',
114
+ filters: [{ field: 'id', operator: '=', value: 'p1' }],
115
+ columnMappings: [
116
+ { column: 'blocks', value: { __truncated: true, type: 'string' } },
117
+ { column: 'title', value: 'Launch' },
118
+ ],
119
+ },
120
+ {}
121
+ )
122
+ expect(calls).toHaveLength(1)
123
+ const sentMappings = calls[0].body.columnMappings as Array<{ column: string }>
124
+ expect(sentMappings.map((m) => m.column)).toEqual(['title'])
125
+ })
126
+
127
+ it('keeps the stored value when the new one could not be serialized', async () => {
128
+ const calls = installFetch({ id: 'r1', updatedCount: 1 })
129
+ const result = (await handler(
130
+ {
131
+ tableName: 'teleport_pages',
132
+ filters: [{ field: 'id', operator: '=', value: 'p1' }],
133
+ columnMappings: { blocks: { __serializationError: true } },
134
+ },
135
+ {}
136
+ )) as Record<string, unknown>
137
+ expect(calls).toHaveLength(0)
138
+ expect(result.updatedCount).toBe(0)
139
+ })
140
+
102
141
  it('preserves an explicit null columnMapping (intentional clear ≠ undefined)', async () => {
103
142
  const calls = installFetch({ id: 'r1', updatedCount: 1 })
104
143
  await handler(
@@ -0,0 +1,276 @@
1
+ import { nodeRegistry } from '../src'
2
+
3
+ interface RevalidateResult {
4
+ revalidated?: string[]
5
+ warning?: string
6
+ success?: boolean
7
+ error?: unknown
8
+ }
9
+
10
+ interface FetchCall {
11
+ url: string
12
+ init: {
13
+ method: string
14
+ headers: Record<string, string>
15
+ body: string
16
+ }
17
+ }
18
+
19
+ const source = () => nodeRegistry['page-revalidate'].generateHandler()
20
+
21
+ /**
22
+ * Runs the emitted handler the way the generated app does: as runtime source in
23
+ * a scope that provides only `globalThis`, `window` and `fetch`. `process` is
24
+ * reached through the injected `globalThis`, which is exactly how the handler
25
+ * has to read it to survive the browser webpack.
26
+ */
27
+ const runHandler = async (
28
+ config: Record<string, unknown>,
29
+ options: {
30
+ secret?: string
31
+ context?: Record<string, unknown>
32
+ // tslint:disable-next-line:no-any
33
+ respond?: (call: FetchCall) => any
34
+ calls?: FetchCall[]
35
+ } = {}
36
+ ): Promise<RevalidateResult> => {
37
+ const env = options.secret === undefined ? {} : { TQ_CACHE_SECRET: options.secret }
38
+ const calls = options.calls || []
39
+
40
+ // tslint:disable-next-line:no-any
41
+ const fetchImpl = async (url: string, init: any) => {
42
+ const call: FetchCall = { url, init }
43
+ calls.push(call)
44
+ if (!options.respond) {
45
+ throw new Error('offline')
46
+ }
47
+ const payload = options.respond(call)
48
+ return {
49
+ ok: payload.ok !== false,
50
+ json: async () => payload.body,
51
+ }
52
+ }
53
+
54
+ // tslint:disable-next-line:function-constructor
55
+ const factory = new Function(
56
+ 'globalThis',
57
+ 'window',
58
+ 'fetch',
59
+ `${source()}; return page_revalidate;`
60
+ )
61
+ const fn = factory({ process: { env } }, undefined, fetchImpl)
62
+
63
+ return fn(config, options.context || { __baseUrl: 'https://site.test' })
64
+ }
65
+
66
+ const expectNotFatal = (result: RevalidateResult) => {
67
+ // Mirrors `isFatalNodeResult` in the emitted runtime: `success: false`, a
68
+ // string `error`, or `error: true` HALTS the workflow.
69
+ expect(result.success).toBeUndefined()
70
+ expect(typeof result.error).not.toBe('string')
71
+ expect(result.error).not.toBe(true)
72
+ }
73
+
74
+ describe('page-revalidate node', () => {
75
+ it('is registered and runs on the server', () => {
76
+ expect(nodeRegistry['page-revalidate']).toBeDefined()
77
+ expect(nodeRegistry['page-revalidate'].nodeType).toBe('page-revalidate')
78
+ // `res.revalidate` only exists inside a server route, and server-side puts
79
+ // the rebuild in the same segment as the write it follows.
80
+ expect(nodeRegistry['page-revalidate'].executionEnv).toBe('server')
81
+ })
82
+
83
+ /**
84
+ * Handlers ship as runtime source and are re-bundled by webpack, which
85
+ * rewrites `require(`.
86
+ */
87
+ it('never requires a module', () => {
88
+ expect(source()).not.toMatch(/(?<![\w.])require\s*\(/)
89
+ })
90
+
91
+ describe('path resolution', () => {
92
+ it('posts the configured paths to the revalidate route', async () => {
93
+ const calls: FetchCall[] = []
94
+ const result = await runHandler(
95
+ { paths: ['/pages/welcome', '/pages/about'] },
96
+ {
97
+ secret: 'sekret',
98
+ calls,
99
+ respond: () => ({ body: { ok: true, revalidated: ['/pages/welcome'], failed: [] } }),
100
+ }
101
+ )
102
+
103
+ expect(calls).toHaveLength(1)
104
+ expect(calls[0].url).toBe('https://site.test/api/tq-revalidate')
105
+ expect(calls[0].init.method).toBe('POST')
106
+ expect(calls[0].init.headers['x-tq-cache-secret']).toBe('sekret')
107
+ expect(JSON.parse(calls[0].init.body)).toEqual({
108
+ paths: ['/pages/welcome', '/pages/about'],
109
+ })
110
+ expect(result.revalidated).toEqual(['/pages/welcome'])
111
+ expect(result.warning).toBeUndefined()
112
+ })
113
+
114
+ /**
115
+ * A path built from a database column routinely arrives as the bare slug,
116
+ * and `res.revalidate` only accepts a site-root-relative path.
117
+ */
118
+ it('gives a bare slug a leading slash', async () => {
119
+ const calls: FetchCall[] = []
120
+ await runHandler(
121
+ { paths: ['pages/welcome'] },
122
+ { secret: 'sekret', calls, respond: () => ({ body: { ok: true } }) }
123
+ )
124
+
125
+ expect(JSON.parse(calls[0].init.body).paths).toEqual(['/pages/welcome'])
126
+ })
127
+
128
+ /**
129
+ * An update revalidates the slug the row HAD and the slug it now has — and
130
+ * when the slug did not change, those are the same string. Rebuilding it
131
+ * twice would double the work for no effect.
132
+ */
133
+ it('drops blanks, unresolved refs and duplicates', async () => {
134
+ const calls: FetchCall[] = []
135
+ await runHandler(
136
+ {
137
+ paths: [
138
+ '/pages/welcome',
139
+ ' ',
140
+ '/pages/welcome',
141
+ null,
142
+ { type: 'workflowContext', nodeId: 'n1', path: ['value'] },
143
+ 'pages/welcome',
144
+ ],
145
+ },
146
+ { secret: 'sekret', calls, respond: () => ({ body: { ok: true } }) }
147
+ )
148
+
149
+ expect(JSON.parse(calls[0].init.body).paths).toEqual(['/pages/welcome'])
150
+ })
151
+
152
+ /*
153
+ * A step that rebuilds several rows at once resolves ONE reference to an
154
+ * array of slugs — a bulk delete cannot know at build time how many rows the
155
+ * admin will select, so it cannot configure a path per row.
156
+ */
157
+ it('flattens an array of paths resolved from a single reference', async () => {
158
+ const calls: FetchCall[] = []
159
+ await runHandler(
160
+ { paths: [['/pages/welcome', '/pages/pricing'], '/pages/about'] },
161
+ { secret: 'sekret', calls, respond: () => ({ body: { ok: true } }) }
162
+ )
163
+
164
+ expect(JSON.parse(calls[0].init.body).paths).toEqual([
165
+ '/pages/welcome',
166
+ '/pages/pricing',
167
+ '/pages/about',
168
+ ])
169
+ })
170
+
171
+ it('accepts a single path configured as a bare string', async () => {
172
+ const calls: FetchCall[] = []
173
+ await runHandler(
174
+ { paths: '/pages/welcome' },
175
+ { secret: 'sekret', calls, respond: () => ({ body: { ok: true } }) }
176
+ )
177
+
178
+ expect(JSON.parse(calls[0].init.body).paths).toEqual(['/pages/welcome'])
179
+ })
180
+
181
+ it('forwards the internal request headers the deployment protection needs', async () => {
182
+ const calls: FetchCall[] = []
183
+ await runHandler(
184
+ { paths: ['/pages/welcome'] },
185
+ {
186
+ secret: 'sekret',
187
+ calls,
188
+ context: {
189
+ __baseUrl: 'https://site.test',
190
+ __internalHeaders: { cookie: '_vercel_jwt=abc' },
191
+ },
192
+ respond: () => ({ body: { ok: true } }),
193
+ }
194
+ )
195
+
196
+ expect(calls[0].init.headers.cookie).toBe('_vercel_jwt=abc')
197
+ })
198
+ })
199
+
200
+ /**
201
+ * THE regression this node is shaped around. The runtime treats
202
+ * `success: false` / a string `error` as FATAL, so a page that could not be
203
+ * rebuilt would halt the workflow AFTER the row was already written — no
204
+ * success toast, no navigation, and an error for an action that worked. Every
205
+ * failure mode below has to report a warning instead.
206
+ */
207
+ describe('never reports failure in a shape the runtime treats as fatal', () => {
208
+ it('with no paths configured', async () => {
209
+ const calls: FetchCall[] = []
210
+ const result = await runHandler({}, { secret: 'sekret', calls })
211
+
212
+ expect(result.revalidated).toEqual([])
213
+ expect(result.warning).toContain('No page paths')
214
+ expect(calls).toHaveLength(0)
215
+ expectNotFatal(result)
216
+ })
217
+
218
+ it('with every configured path blank', async () => {
219
+ const result = await runHandler({ paths: ['', ' '] }, { secret: 'sekret' })
220
+
221
+ expect(result.revalidated).toEqual([])
222
+ expectNotFatal(result)
223
+ })
224
+
225
+ it('with no secret configured', async () => {
226
+ const calls: FetchCall[] = []
227
+ const result = await runHandler({ paths: ['/pages/welcome'] }, { calls })
228
+
229
+ expect(result.revalidated).toEqual([])
230
+ expect(result.warning).toContain('TQ_CACHE_SECRET')
231
+ // Nothing is sent without the credential the route requires.
232
+ expect(calls).toHaveLength(0)
233
+ expectNotFatal(result)
234
+ })
235
+
236
+ it('when the route answers with an error status', async () => {
237
+ const result = await runHandler(
238
+ { paths: ['/pages/welcome'] },
239
+ { secret: 'sekret', respond: () => ({ ok: false, body: { error: 'Unauthorized' } }) }
240
+ )
241
+
242
+ expect(result.revalidated).toEqual([])
243
+ expect(result.warning).toBe('Unauthorized')
244
+ expectNotFatal(result)
245
+ })
246
+
247
+ it('when the route is unreachable', async () => {
248
+ const result = await runHandler({ paths: ['/pages/welcome'] }, { secret: 'sekret' })
249
+
250
+ expect(result.revalidated).toEqual([])
251
+ expect(result.warning).toBe('offline')
252
+ expectNotFatal(result)
253
+ })
254
+
255
+ /**
256
+ * `res.revalidate` throws for a path that was never statically generated —
257
+ * the normal case for a page whose row was just deleted. The paths that DID
258
+ * rebuild are still reported.
259
+ */
260
+ it('when some paths could not be rebuilt', async () => {
261
+ const result = await runHandler(
262
+ { paths: ['/pages/welcome', '/pages/gone'] },
263
+ {
264
+ secret: 'sekret',
265
+ respond: () => ({
266
+ body: { ok: true, revalidated: ['/pages/welcome'], failed: ['/pages/gone'] },
267
+ }),
268
+ }
269
+ )
270
+
271
+ expect(result.revalidated).toEqual(['/pages/welcome'])
272
+ expect(result.warning).toContain('/pages/gone')
273
+ expectNotFatal(result)
274
+ })
275
+ })
276
+ })
@@ -0,0 +1,135 @@
1
+ import { loadHandler, HandlerFn } from './_helpers/load-handler'
2
+ import { paymentRefund } from '../src/nodes/payment/payment-refund'
3
+ import { resolveHandlerEntryName } from '../src/nodes/types'
4
+ import { collectSecrets } from '../src/secret-collector'
5
+
6
+ interface RefundResult {
7
+ success: boolean
8
+ refundId: string
9
+ amount: number
10
+ currency: string
11
+ status: string
12
+ error: string
13
+ }
14
+
15
+ describe('payment-refund', () => {
16
+ const handler: HandlerFn = loadHandler('payment-refund')
17
+ const source = paymentRefund.generateHandler()
18
+
19
+ it('is a SERVER node that continues the workflow', () => {
20
+ expect(paymentRefund.executionEnv).toBe('server')
21
+ // Unlike a charge, a refund must NOT be terminal: the steps that record it,
22
+ // restock it and email the buyer all run after it.
23
+ expect(paymentRefund.isTerminal).toBe(false)
24
+ })
25
+
26
+ it('has its provider credentials collected into the generated env', () => {
27
+ // Without this the exported project ships with no key for the refund node
28
+ // and every refund fails at runtime with "not configured".
29
+ const secrets = collectSecrets({
30
+ workflows: {
31
+ wf1: {
32
+ nodes: [
33
+ {
34
+ id: 'node-1',
35
+ type: 'payment-refund',
36
+ config: { secretKey: 'sk_test_1', clientId: 'id_1', clientSecret: 'cs_1' },
37
+ },
38
+ ],
39
+ },
40
+ },
41
+ } as never)
42
+ expect(secrets.map((secret) => secret.fieldName).sort()).toEqual([
43
+ 'clientId',
44
+ 'clientSecret',
45
+ 'secretKey',
46
+ ])
47
+ })
48
+
49
+ it('resolves its own entry point out of the concatenated source', () => {
50
+ // The entry has to be the FIRST statement-level two-parameter function, or
51
+ // the generated route calls a helper instead.
52
+ expect(resolveHandlerEntryName(source, 'payment-refund')).toBe('payment_refund')
53
+ })
54
+
55
+ it('reads process through globalThis, which webpack leaves alone', () => {
56
+ // A bare `process` is rewritten by the GUI's browser packer into a name
57
+ // that does not exist on the Vercel Node runtime.
58
+ expect(source).toContain('globalThis.process.env')
59
+ // …and nowhere is `process.env` read WITHOUT that prefix.
60
+ expect(source.replace(/globalThis\.process\.env/g, '')).not.toContain('process.env')
61
+ })
62
+
63
+ it('keeps each currency list in the SAME snapshot as the code that reads it', () => {
64
+ // A declaration in one `.toString()`'d function and its reference in
65
+ // another is the "Oo is not defined" bug — a minifier renames the two
66
+ // independently and the generated file references a name it never declares.
67
+ // Slicing the emitted source per function is the only way to check that.
68
+ for (const fnName of ['toProviderMinorUnits', 'fromProviderMinorUnits']) {
69
+ const start = source.indexOf(`function ${fnName}`)
70
+ expect(start).toBeGreaterThanOrEqual(0)
71
+ const next = source.indexOf('\nfunction ', start + 1)
72
+ const body = next === -1 ? source.slice(start) : source.slice(start, next)
73
+ expect(body).toContain('zeroDecimalCurrencies = [')
74
+ expect(body).toContain('threeDecimalCurrencies = [')
75
+ expect(body).toContain('zeroDecimalCurrencies.indexOf')
76
+ }
77
+ })
78
+
79
+ it('refuses without a payment reference instead of calling a provider', async () => {
80
+ const result = (await handler({ currency: 'usd' }, {})) as RefundResult
81
+ expect(result.success).toBe(false)
82
+ expect(result.error).toMatch(/no payment reference/i)
83
+ expect(result.refundId).toBe('')
84
+ })
85
+
86
+ it('reports missing Stripe credentials as an actionable error', async () => {
87
+ const saved = process.env.STRIPE_SECRET_KEY
88
+ delete process.env.STRIPE_SECRET_KEY
89
+ try {
90
+ const result = (await handler(
91
+ { providerType: 'stripe', paymentReference: 'pi_1', currency: 'usd' },
92
+ {}
93
+ )) as RefundResult
94
+ expect(result.success).toBe(false)
95
+ expect(result.error).toContain('STRIPE_SECRET_KEY')
96
+ // The shape stays constant on every path, so a downstream node can bind
97
+ // to it without knowing which branch ran.
98
+ expect(result).toMatchObject({ refundId: '', amount: 0, status: '' })
99
+ } finally {
100
+ if (saved !== undefined) {
101
+ process.env.STRIPE_SECRET_KEY = saved
102
+ }
103
+ }
104
+ })
105
+
106
+ it('reports missing PayPal credentials without attempting a network call', async () => {
107
+ const savedId = process.env.PAYPAL_CLIENT_ID
108
+ const savedSecret = process.env.PAYPAL_CLIENT_SECRET
109
+ delete process.env.PAYPAL_CLIENT_ID
110
+ delete process.env.PAYPAL_CLIENT_SECRET
111
+ try {
112
+ const result = (await handler(
113
+ { providerType: 'paypal', paymentReference: 'cap_1', currency: 'usd' },
114
+ {}
115
+ )) as RefundResult
116
+ expect(result.success).toBe(false)
117
+ expect(result.error).toMatch(/PayPal credentials are not configured/i)
118
+ } finally {
119
+ if (savedId !== undefined) {
120
+ process.env.PAYPAL_CLIENT_ID = savedId
121
+ }
122
+ if (savedSecret !== undefined) {
123
+ process.env.PAYPAL_CLIENT_SECRET = savedSecret
124
+ }
125
+ }
126
+ })
127
+
128
+ it('always returns an answer rather than throwing, so bookkeeping can still run', async () => {
129
+ // A declined refund is a branch the workflow takes, not a crash that takes
130
+ // the whole segment — including the steps that record what happened — down.
131
+ await expect(
132
+ handler({ providerType: 'stripe', paymentReference: 'pi_1', currency: 'usd' }, {})
133
+ ).resolves.toBeDefined()
134
+ })
135
+ })
@@ -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
+ })