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

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 (93) hide show
  1. package/__tests__/browser-speech-recognition.test.ts +625 -0
  2. package/__tests__/custom-event-component-scope.test.ts +143 -0
  3. package/__tests__/data-update-item-runtime.test.ts +39 -0
  4. package/__tests__/page-revalidate-node.test.ts +276 -0
  5. package/__tests__/payment-refund.test.ts +135 -0
  6. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  7. package/dist/cjs/api-route-generator.js +1 -0
  8. package/dist/cjs/api-route-generator.js.map +1 -1
  9. package/dist/cjs/nodes/analytics/commerce-tracking.d.ts +46 -0
  10. package/dist/cjs/nodes/analytics/commerce-tracking.d.ts.map +1 -0
  11. package/dist/cjs/nodes/analytics/commerce-tracking.js +94 -0
  12. package/dist/cjs/nodes/analytics/commerce-tracking.js.map +1 -0
  13. package/dist/cjs/nodes/browser/browser-speech-recognition.d.ts +3 -0
  14. package/dist/cjs/nodes/browser/browser-speech-recognition.d.ts.map +1 -0
  15. package/dist/cjs/nodes/browser/browser-speech-recognition.js +448 -0
  16. package/dist/cjs/nodes/browser/browser-speech-recognition.js.map +1 -0
  17. package/dist/cjs/nodes/cache/page-revalidate.d.ts +3 -0
  18. package/dist/cjs/nodes/cache/page-revalidate.d.ts.map +1 -0
  19. package/dist/cjs/nodes/cache/page-revalidate.js +125 -0
  20. package/dist/cjs/nodes/cache/page-revalidate.js.map +1 -0
  21. package/dist/cjs/nodes/cart/cart-add-item.d.ts.map +1 -1
  22. package/dist/cjs/nodes/cart/cart-add-item.js +22 -1
  23. package/dist/cjs/nodes/cart/cart-add-item.js.map +1 -1
  24. package/dist/cjs/nodes/cart/cart-remove-item.d.ts.map +1 -1
  25. package/dist/cjs/nodes/cart/cart-remove-item.js +23 -1
  26. package/dist/cjs/nodes/cart/cart-remove-item.js.map +1 -1
  27. package/dist/cjs/nodes/data/data-create-item.d.ts.map +1 -1
  28. package/dist/cjs/nodes/data/data-create-item.js +42 -1
  29. package/dist/cjs/nodes/data/data-create-item.js.map +1 -1
  30. package/dist/cjs/nodes/data/data-update-item.d.ts.map +1 -1
  31. package/dist/cjs/nodes/data/data-update-item.js +38 -3
  32. package/dist/cjs/nodes/data/data-update-item.js.map +1 -1
  33. package/dist/cjs/nodes/index.d.ts.map +1 -1
  34. package/dist/cjs/nodes/index.js +4 -1
  35. package/dist/cjs/nodes/index.js.map +1 -1
  36. package/dist/cjs/nodes/payment/payment-refund.d.ts +3 -0
  37. package/dist/cjs/nodes/payment/payment-refund.d.ts.map +1 -0
  38. package/dist/cjs/nodes/payment/payment-refund.js +446 -0
  39. package/dist/cjs/nodes/payment/payment-refund.js.map +1 -0
  40. package/dist/cjs/secret-collector.d.ts.map +1 -1
  41. package/dist/cjs/secret-collector.js +3 -0
  42. package/dist/cjs/secret-collector.js.map +1 -1
  43. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  44. package/dist/esm/api-route-generator.d.ts.map +1 -1
  45. package/dist/esm/api-route-generator.js +1 -0
  46. package/dist/esm/api-route-generator.js.map +1 -1
  47. package/dist/esm/nodes/analytics/commerce-tracking.d.ts +46 -0
  48. package/dist/esm/nodes/analytics/commerce-tracking.d.ts.map +1 -0
  49. package/dist/esm/nodes/analytics/commerce-tracking.js +90 -0
  50. package/dist/esm/nodes/analytics/commerce-tracking.js.map +1 -0
  51. package/dist/esm/nodes/browser/browser-speech-recognition.d.ts +3 -0
  52. package/dist/esm/nodes/browser/browser-speech-recognition.d.ts.map +1 -0
  53. package/dist/esm/nodes/browser/browser-speech-recognition.js +445 -0
  54. package/dist/esm/nodes/browser/browser-speech-recognition.js.map +1 -0
  55. package/dist/esm/nodes/cache/page-revalidate.d.ts +3 -0
  56. package/dist/esm/nodes/cache/page-revalidate.d.ts.map +1 -0
  57. package/dist/esm/nodes/cache/page-revalidate.js +122 -0
  58. package/dist/esm/nodes/cache/page-revalidate.js.map +1 -0
  59. package/dist/esm/nodes/cart/cart-add-item.d.ts.map +1 -1
  60. package/dist/esm/nodes/cart/cart-add-item.js +22 -1
  61. package/dist/esm/nodes/cart/cart-add-item.js.map +1 -1
  62. package/dist/esm/nodes/cart/cart-remove-item.d.ts.map +1 -1
  63. package/dist/esm/nodes/cart/cart-remove-item.js +23 -1
  64. package/dist/esm/nodes/cart/cart-remove-item.js.map +1 -1
  65. package/dist/esm/nodes/data/data-create-item.d.ts.map +1 -1
  66. package/dist/esm/nodes/data/data-create-item.js +42 -1
  67. package/dist/esm/nodes/data/data-create-item.js.map +1 -1
  68. package/dist/esm/nodes/data/data-update-item.d.ts.map +1 -1
  69. package/dist/esm/nodes/data/data-update-item.js +38 -3
  70. package/dist/esm/nodes/data/data-update-item.js.map +1 -1
  71. package/dist/esm/nodes/index.d.ts.map +1 -1
  72. package/dist/esm/nodes/index.js +4 -1
  73. package/dist/esm/nodes/index.js.map +1 -1
  74. package/dist/esm/nodes/payment/payment-refund.d.ts +3 -0
  75. package/dist/esm/nodes/payment/payment-refund.d.ts.map +1 -0
  76. package/dist/esm/nodes/payment/payment-refund.js +443 -0
  77. package/dist/esm/nodes/payment/payment-refund.js.map +1 -0
  78. package/dist/esm/secret-collector.d.ts.map +1 -1
  79. package/dist/esm/secret-collector.js +3 -0
  80. package/dist/esm/secret-collector.js.map +1 -1
  81. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +4 -4
  83. package/src/api-route-generator.ts +1 -0
  84. package/src/nodes/analytics/commerce-tracking.ts +93 -0
  85. package/src/nodes/browser/browser-speech-recognition.ts +474 -0
  86. package/src/nodes/cache/page-revalidate.ts +140 -0
  87. package/src/nodes/cart/cart-add-item.ts +35 -1
  88. package/src/nodes/cart/cart-remove-item.ts +36 -1
  89. package/src/nodes/data/data-create-item.ts +44 -1
  90. package/src/nodes/data/data-update-item.ts +39 -3
  91. package/src/nodes/index.ts +6 -0
  92. package/src/nodes/payment/payment-refund.ts +512 -0
  93. package/src/secret-collector.ts +3 -0
@@ -0,0 +1,143 @@
1
+ // A custom-event workflow bound to a component must be emitted INTO that
2
+ // component, not into the global workflow module.
3
+ //
4
+ // This is what makes the AI chat's dictation work: the transcript arrives as a
5
+ // custom event, and the workflow that consumes it writes `chatInputValue` — a
6
+ // component-local state. A global workflow is emitted into a module that owns no
7
+ // state setters, so that write would warn and no-op, and the field would never
8
+ // fill in. The routing below is the guarantee that cannot happen.
9
+
10
+ import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
11
+
12
+ const customEventWorkflow = (config: Record<string, unknown>, scope: string) => ({
13
+ id: 'wf-dictation-result',
14
+ name: 'Insert Dictated Chat Text',
15
+ trigger: {
16
+ type: 'event-custom-triggered',
17
+ nodeId: 'trigger-custom-event',
18
+ scope,
19
+ config,
20
+ },
21
+ nodes: [
22
+ {
23
+ id: 'update-1',
24
+ type: 'state-update-local-state',
25
+ config: {
26
+ property: 'chatInputValue',
27
+ value: {
28
+ type: 'workflowContext',
29
+ nodeId: 'trigger-custom-event',
30
+ path: ['trigger-custom-event', 'eventData', 'text'],
31
+ },
32
+ },
33
+ stepNumber: 1,
34
+ label: 'Store Dictated Text',
35
+ },
36
+ ],
37
+ edges: [{ id: 'e1', source: 'trigger-custom-event', target: 'update-1' }],
38
+ })
39
+
40
+ // `any` because the chunk is a hand-written Babel AST fragment, not a value
41
+ // produced by @babel/types' builders.
42
+ const jsxComponentChunk = (): any => ({
43
+ type: 'chunk-type-ast',
44
+ name: 'jsx-component',
45
+ content: {
46
+ type: 'VariableDeclaration',
47
+ declarations: [
48
+ {
49
+ type: 'VariableDeclarator',
50
+ init: {
51
+ type: 'ArrowFunctionExpression',
52
+ body: { type: 'BlockStatement', body: [{ type: 'ReturnStatement', argument: null }] },
53
+ },
54
+ },
55
+ ],
56
+ },
57
+ })
58
+
59
+ const getWorkflowModule = async (
60
+ uidl: Record<string, unknown>,
61
+ workflow: any,
62
+ isPage: boolean
63
+ ): Promise<string | null> => {
64
+ const plugin = createNextWorkflowPlugin({ isPage })
65
+ const structure: any = {
66
+ uidl: {
67
+ node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
68
+ stateDefinitions: { chatInputValue: { type: 'string', defaultValue: '' } },
69
+ ...uidl,
70
+ },
71
+ chunks: [jsxComponentChunk()],
72
+ options: {
73
+ workflows: { workflows: { [workflow.id]: workflow }, customNodes: {} },
74
+ },
75
+ dependencies: {},
76
+ }
77
+ await plugin(structure)
78
+ const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module')
79
+ return moduleChunk ? String(moduleChunk.content) : null
80
+ }
81
+
82
+ describe('component-scoped custom-event workflows', () => {
83
+ it('registers the listener inside the component it is bound to', async () => {
84
+ const code = await getWorkflowModule(
85
+ { name: 'ai-assistant-chat' },
86
+ customEventWorkflow(
87
+ {
88
+ eventName: 'ai-chat-dictation-result',
89
+ componentId: 'TQ_chat',
90
+ componentName: 'ai-assistant-chat',
91
+ },
92
+ 'component'
93
+ ),
94
+ false
95
+ )
96
+ expect(code).not.toBeNull()
97
+ expect(code).toContain('workflow:custom:ai-chat-dictation-result')
98
+ // Registered on mount and torn down on unmount, like every other
99
+ // component-scoped listener.
100
+ expect(code).toContain('removeEventListener')
101
+ })
102
+
103
+ it('does NOT leak into an unrelated component', async () => {
104
+ const code = await getWorkflowModule(
105
+ { name: 'Footer' },
106
+ customEventWorkflow(
107
+ {
108
+ eventName: 'ai-chat-dictation-result',
109
+ componentId: 'TQ_chat',
110
+ componentName: 'ai-assistant-chat',
111
+ },
112
+ 'component'
113
+ ),
114
+ false
115
+ )
116
+ expect(code).toBeNull()
117
+ })
118
+
119
+ it('does NOT leak into pages', async () => {
120
+ const code = await getWorkflowModule(
121
+ { name: 'ai-assistant-chat', outputOptions: { pageId: 'page-1', fileName: 'home' } },
122
+ customEventWorkflow(
123
+ {
124
+ eventName: 'ai-chat-dictation-result',
125
+ componentId: 'TQ_chat',
126
+ componentName: 'ai-assistant-chat',
127
+ },
128
+ 'component'
129
+ ),
130
+ true
131
+ )
132
+ expect(code).toBeNull()
133
+ })
134
+
135
+ it('leaves an unbound (global-scoped) custom-event workflow out of the component', async () => {
136
+ const code = await getWorkflowModule(
137
+ { name: 'ai-assistant-chat' },
138
+ customEventWorkflow({ eventName: 'order-placed' }, 'global'),
139
+ false
140
+ )
141
+ expect(code).toBeNull()
142
+ })
143
+ })
@@ -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
+ })
@@ -1 +1 @@
1
- {"version":3,"file":"api-route-generator.d.ts","sourceRoot":"","sources":["../../src/api-route-generator.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EAIZ,sBAAsB,EACtB,sBAAsB,EACvB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAqBzC,eAAO,MAAM,6BAA6B,YAC/B,eAAe,iBACT,MAAM,eACR,sBAAsB,KAClC,MAsXF,CAAA;AAED,eAAO,MAAM,kBAAkB,YAAa,eAAe,KAAG,OAE7D,CAAA;AAED,eAAO,MAAM,sCAAsC,YACxC,eAAe,iBACT,MAAM,eACR,sBAAsB,KAClC,MA+bF,CAAA;AA0HD,eAAO,MAAM,oBAAoB,aACrB,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,KACnD,MAuFF,CAAA;AAED,eAAO,MAAM,mBAAmB,eAClB,MAAM,aACP,MAAM,iBACF,MAAM,KACpB,MAuBF,CAAA;AAED,eAAO,MAAM,oBAAoB,aAAc,YAAY,KAAG,MAa7D,CAAA;AAoRD,eAAO,MAAM,+BAA+B,aAChC,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,KACnD,MA0MF,CAAA;AAED,eAAO,MAAM,uBAAuB,aAAc,YAAY,KAAG,MAiBhE,CAAA;AAED,eAAO,MAAM,mBAAmB,aAAc,YAAY,KAAG,MAAM,EAqBlE,CAAA"}
1
+ {"version":3,"file":"api-route-generator.d.ts","sourceRoot":"","sources":["../../src/api-route-generator.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EAIZ,sBAAsB,EACtB,sBAAsB,EACvB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAqBzC,eAAO,MAAM,6BAA6B,YAC/B,eAAe,iBACT,MAAM,eACR,sBAAsB,KAClC,MAsXF,CAAA;AAED,eAAO,MAAM,kBAAkB,YAAa,eAAe,KAAG,OAE7D,CAAA;AAED,eAAO,MAAM,sCAAsC,YACxC,eAAe,iBACT,MAAM,eACR,sBAAsB,KAClC,MA+bF,CAAA;AA2HD,eAAO,MAAM,oBAAoB,aACrB,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,KACnD,MAuFF,CAAA;AAED,eAAO,MAAM,mBAAmB,eAClB,MAAM,aACP,MAAM,iBACF,MAAM,KACpB,MAuBF,CAAA;AAED,eAAO,MAAM,oBAAoB,aAAc,YAAY,KAAG,MAa7D,CAAA;AAoRD,eAAO,MAAM,+BAA+B,aAChC,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,KACnD,MA0MF,CAAA;AAED,eAAO,MAAM,uBAAuB,aAAc,YAAY,KAAG,MAiBhE,CAAA;AAED,eAAO,MAAM,mBAAmB,aAAc,YAAY,KAAG,MAAM,EAqBlE,CAAA"}
@@ -936,6 +936,7 @@ const CLIENT_ONLY_NODES = new Set([
936
936
  'browser-text-to-speech',
937
937
  'browser-get-media-devices',
938
938
  'browser-speech-to-text',
939
+ 'browser-speech-recognition',
939
940
  'general-trigger-download',
940
941
  'general-extract-form-data',
941
942
  'general-emit-custom-event',