@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.
- package/__tests__/browser-speech-recognition.test.ts +625 -0
- package/__tests__/custom-event-component-scope.test.ts +143 -0
- package/__tests__/data-update-item-runtime.test.ts +39 -0
- package/__tests__/page-revalidate-node.test.ts +276 -0
- package/__tests__/payment-refund.test.ts +135 -0
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +1 -0
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/nodes/analytics/commerce-tracking.d.ts +46 -0
- package/dist/cjs/nodes/analytics/commerce-tracking.d.ts.map +1 -0
- package/dist/cjs/nodes/analytics/commerce-tracking.js +94 -0
- package/dist/cjs/nodes/analytics/commerce-tracking.js.map +1 -0
- package/dist/cjs/nodes/browser/browser-speech-recognition.d.ts +3 -0
- package/dist/cjs/nodes/browser/browser-speech-recognition.d.ts.map +1 -0
- package/dist/cjs/nodes/browser/browser-speech-recognition.js +448 -0
- package/dist/cjs/nodes/browser/browser-speech-recognition.js.map +1 -0
- package/dist/cjs/nodes/cache/page-revalidate.d.ts +3 -0
- package/dist/cjs/nodes/cache/page-revalidate.d.ts.map +1 -0
- package/dist/cjs/nodes/cache/page-revalidate.js +125 -0
- package/dist/cjs/nodes/cache/page-revalidate.js.map +1 -0
- package/dist/cjs/nodes/cart/cart-add-item.d.ts.map +1 -1
- package/dist/cjs/nodes/cart/cart-add-item.js +22 -1
- package/dist/cjs/nodes/cart/cart-add-item.js.map +1 -1
- package/dist/cjs/nodes/cart/cart-remove-item.d.ts.map +1 -1
- package/dist/cjs/nodes/cart/cart-remove-item.js +23 -1
- package/dist/cjs/nodes/cart/cart-remove-item.js.map +1 -1
- package/dist/cjs/nodes/data/data-create-item.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-create-item.js +42 -1
- package/dist/cjs/nodes/data/data-create-item.js.map +1 -1
- package/dist/cjs/nodes/data/data-update-item.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-update-item.js +38 -3
- package/dist/cjs/nodes/data/data-update-item.js.map +1 -1
- package/dist/cjs/nodes/index.d.ts.map +1 -1
- package/dist/cjs/nodes/index.js +4 -1
- package/dist/cjs/nodes/index.js.map +1 -1
- package/dist/cjs/nodes/payment/payment-refund.d.ts +3 -0
- package/dist/cjs/nodes/payment/payment-refund.d.ts.map +1 -0
- package/dist/cjs/nodes/payment/payment-refund.js +446 -0
- package/dist/cjs/nodes/payment/payment-refund.js.map +1 -0
- package/dist/cjs/secret-collector.d.ts.map +1 -1
- package/dist/cjs/secret-collector.js +3 -0
- package/dist/cjs/secret-collector.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +1 -0
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/nodes/analytics/commerce-tracking.d.ts +46 -0
- package/dist/esm/nodes/analytics/commerce-tracking.d.ts.map +1 -0
- package/dist/esm/nodes/analytics/commerce-tracking.js +90 -0
- package/dist/esm/nodes/analytics/commerce-tracking.js.map +1 -0
- package/dist/esm/nodes/browser/browser-speech-recognition.d.ts +3 -0
- package/dist/esm/nodes/browser/browser-speech-recognition.d.ts.map +1 -0
- package/dist/esm/nodes/browser/browser-speech-recognition.js +445 -0
- package/dist/esm/nodes/browser/browser-speech-recognition.js.map +1 -0
- package/dist/esm/nodes/cache/page-revalidate.d.ts +3 -0
- package/dist/esm/nodes/cache/page-revalidate.d.ts.map +1 -0
- package/dist/esm/nodes/cache/page-revalidate.js +122 -0
- package/dist/esm/nodes/cache/page-revalidate.js.map +1 -0
- package/dist/esm/nodes/cart/cart-add-item.d.ts.map +1 -1
- package/dist/esm/nodes/cart/cart-add-item.js +22 -1
- package/dist/esm/nodes/cart/cart-add-item.js.map +1 -1
- package/dist/esm/nodes/cart/cart-remove-item.d.ts.map +1 -1
- package/dist/esm/nodes/cart/cart-remove-item.js +23 -1
- package/dist/esm/nodes/cart/cart-remove-item.js.map +1 -1
- package/dist/esm/nodes/data/data-create-item.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-create-item.js +42 -1
- package/dist/esm/nodes/data/data-create-item.js.map +1 -1
- package/dist/esm/nodes/data/data-update-item.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-update-item.js +38 -3
- package/dist/esm/nodes/data/data-update-item.js.map +1 -1
- package/dist/esm/nodes/index.d.ts.map +1 -1
- package/dist/esm/nodes/index.js +4 -1
- package/dist/esm/nodes/index.js.map +1 -1
- package/dist/esm/nodes/payment/payment-refund.d.ts +3 -0
- package/dist/esm/nodes/payment/payment-refund.d.ts.map +1 -0
- package/dist/esm/nodes/payment/payment-refund.js +443 -0
- package/dist/esm/nodes/payment/payment-refund.js.map +1 -0
- package/dist/esm/secret-collector.d.ts.map +1 -1
- package/dist/esm/secret-collector.js +3 -0
- package/dist/esm/secret-collector.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/api-route-generator.ts +1 -0
- package/src/nodes/analytics/commerce-tracking.ts +93 -0
- package/src/nodes/browser/browser-speech-recognition.ts +474 -0
- package/src/nodes/cache/page-revalidate.ts +140 -0
- package/src/nodes/cart/cart-add-item.ts +35 -1
- package/src/nodes/cart/cart-remove-item.ts +36 -1
- package/src/nodes/data/data-create-item.ts +44 -1
- package/src/nodes/data/data-update-item.ts +39 -3
- package/src/nodes/index.ts +6 -0
- package/src/nodes/payment/payment-refund.ts +512 -0
- package/src/secret-collector.ts +3 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
// The dictation node is the only node whose observable behaviour arrives AFTER
|
|
2
|
+
// it returns, so its contract cannot be checked by reading the emitted string —
|
|
3
|
+
// these tests execute the real handler against a fake Web Speech API and assert
|
|
4
|
+
// what it dispatches.
|
|
5
|
+
|
|
6
|
+
import { nodeRegistry } from '../src/nodes'
|
|
7
|
+
|
|
8
|
+
type Handler = (config: any, context: Record<string, unknown>) => Promise<any>
|
|
9
|
+
|
|
10
|
+
const loadHandler = (): Handler => {
|
|
11
|
+
const source = nodeRegistry['browser-speech-recognition'].generateHandler()
|
|
12
|
+
// Same shape the generated `node-handlers-client.js` uses.
|
|
13
|
+
// eslint-disable-next-line no-new-func
|
|
14
|
+
return new Function(`${source}\nreturn browser_speech_recognition;`)() as Handler
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface FakeRecognition {
|
|
18
|
+
lang: string
|
|
19
|
+
continuous: boolean
|
|
20
|
+
interimResults: boolean
|
|
21
|
+
maxAlternatives?: number
|
|
22
|
+
start: jest.Mock
|
|
23
|
+
stop: jest.Mock
|
|
24
|
+
onresult: ((event: any) => void) | null
|
|
25
|
+
onerror: ((event: any) => void) | null
|
|
26
|
+
onend: (() => void) | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const instances: FakeRecognition[] = []
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Set to make the NEXT recogniser reject `start()`, the way a real one raises
|
|
33
|
+
* `InvalidStateError` while a previous session is still releasing the device.
|
|
34
|
+
* A flag rather than a mock on an existing instance: each start constructs a
|
|
35
|
+
* fresh recogniser, so the throwing one does not exist yet at arrange time.
|
|
36
|
+
*/
|
|
37
|
+
let nextStartThrows = false
|
|
38
|
+
|
|
39
|
+
class FakeSpeechRecognition implements FakeRecognition {
|
|
40
|
+
lang = ''
|
|
41
|
+
continuous = false
|
|
42
|
+
interimResults = false
|
|
43
|
+
maxAlternatives?: number
|
|
44
|
+
onresult: ((event: any) => void) | null = null
|
|
45
|
+
onerror: ((event: any) => void) | null = null
|
|
46
|
+
onend: (() => void) | null = null
|
|
47
|
+
start = jest.fn(() => {
|
|
48
|
+
if (nextStartThrows) {
|
|
49
|
+
nextStartThrows = false
|
|
50
|
+
throw new Error('InvalidStateError')
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
stop = jest.fn(() => {
|
|
54
|
+
if (this.onend) {
|
|
55
|
+
this.onend()
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
constructor() {
|
|
60
|
+
instances.push(this)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const dispatched: Array<{ type: string; detail: any }> = []
|
|
65
|
+
|
|
66
|
+
const result = (transcript: string, isFinal: boolean, resultIndex = 0) => ({
|
|
67
|
+
resultIndex,
|
|
68
|
+
results: [{ 0: { transcript }, isFinal }],
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/** A result list holding several phrases at once, as a continuous session builds. */
|
|
72
|
+
const resultList = (phrases: Array<[string, boolean]>, resultIndex = 0) => ({
|
|
73
|
+
resultIndex,
|
|
74
|
+
results: phrases.map(([transcript, isFinal]) => ({ 0: { transcript }, isFinal })),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const setupWindow = (options: { supported?: boolean; hidden?: boolean } = {}) => {
|
|
78
|
+
instances.length = 0
|
|
79
|
+
dispatched.length = 0
|
|
80
|
+
nextStartThrows = false
|
|
81
|
+
const win: any = {
|
|
82
|
+
document: { documentElement: { lang: 'en-GB' }, hidden: options.hidden === true },
|
|
83
|
+
dispatchEvent: (event: any) => {
|
|
84
|
+
dispatched.push({ type: event.type, detail: event.detail })
|
|
85
|
+
return true
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
if (options.supported !== false) {
|
|
89
|
+
win.webkitSpeechRecognition = FakeSpeechRecognition
|
|
90
|
+
}
|
|
91
|
+
;(globalThis as any).window = win
|
|
92
|
+
;(globalThis as any).CustomEvent = class {
|
|
93
|
+
type: string
|
|
94
|
+
detail: any
|
|
95
|
+
constructor(type: string, init?: { detail?: any }) {
|
|
96
|
+
this.type = type
|
|
97
|
+
this.detail = init ? init.detail : undefined
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return win
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const baseConfig = {
|
|
104
|
+
sessionId: 'chat',
|
|
105
|
+
resultEventName: 'dictation-result',
|
|
106
|
+
endEventName: 'dictation-ended',
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const results = () => dispatched.filter((e) => e.type === 'workflow:custom:dictation-result')
|
|
110
|
+
const ends = () => dispatched.filter((e) => e.type === 'workflow:custom:dictation-ended')
|
|
111
|
+
|
|
112
|
+
describe('browser-speech-recognition', () => {
|
|
113
|
+
let handler: Handler
|
|
114
|
+
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
jest.useFakeTimers()
|
|
117
|
+
handler = loadHandler()
|
|
118
|
+
setupWindow()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
afterEach(() => {
|
|
122
|
+
jest.useRealTimers()
|
|
123
|
+
delete (globalThis as any).window
|
|
124
|
+
delete (globalThis as any).CustomEvent
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('starts a session and reports that it is listening', async () => {
|
|
128
|
+
const out = await handler({ ...baseConfig, action: 'start' }, {})
|
|
129
|
+
expect(out).toMatchObject({ listening: true, supported: true, action: 'started' })
|
|
130
|
+
expect(instances).toHaveLength(1)
|
|
131
|
+
expect(instances[0].start).toHaveBeenCalled()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('falls back to the document language when none is configured', async () => {
|
|
135
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
136
|
+
expect(instances[0].lang).toBe('en-GB')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('prefers an explicit language over the document one', async () => {
|
|
140
|
+
await handler({ ...baseConfig, action: 'start', language: 'fr-FR' }, {})
|
|
141
|
+
expect(instances[0].lang).toBe('fr-FR')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('toggles off a live session instead of starting a second recogniser', async () => {
|
|
145
|
+
await handler({ ...baseConfig, action: 'toggle' }, {})
|
|
146
|
+
const out = await handler({ ...baseConfig, action: 'toggle' }, {})
|
|
147
|
+
expect(out).toMatchObject({ listening: false, action: 'stopped' })
|
|
148
|
+
expect(instances).toHaveLength(1)
|
|
149
|
+
expect(instances[0].stop).toHaveBeenCalled()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('appends the transcript to the text already in the field', async () => {
|
|
153
|
+
await handler({ ...baseConfig, action: 'start', initialText: 'Hello' }, {})
|
|
154
|
+
instances[0].onresult!(result('there', true))
|
|
155
|
+
expect(results()[0].detail.text).toBe('Hello there')
|
|
156
|
+
expect(results()[0].detail.transcript).toBe('there')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('emits the transcript alone when the field was empty', async () => {
|
|
160
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
161
|
+
instances[0].onresult!(result('just this', true))
|
|
162
|
+
expect(results()[0].detail.text).toBe('just this')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('marks interim results as not final and finals as final', async () => {
|
|
166
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
167
|
+
instances[0].onresult!(result('partial', false))
|
|
168
|
+
instances[0].onresult!(result('complete', true))
|
|
169
|
+
expect(results()[0].detail.isFinal).toBe(false)
|
|
170
|
+
expect(results()[1].detail.isFinal).toBe(true)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('does not re-emit an unchanged interim result', async () => {
|
|
174
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
175
|
+
instances[0].onresult!(result('same', false))
|
|
176
|
+
instances[0].onresult!(result('same', false))
|
|
177
|
+
instances[0].onresult!(result('same', false))
|
|
178
|
+
expect(results()).toHaveLength(1)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('accumulates finals across a restart, which reports its own results from 0', async () => {
|
|
182
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
183
|
+
instances[0].onresult!(result('first phrase', true))
|
|
184
|
+
// The browser ended the session on silence; auto-restart reuses the same
|
|
185
|
+
// recogniser, whose next result set starts at index 0 again.
|
|
186
|
+
instances[0].onend!()
|
|
187
|
+
instances[0].onresult!(result('second phrase', true))
|
|
188
|
+
expect(results().pop()!.detail.text).toBe('first phrase second phrase')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('does not repeat a phrase the engine re-reports as already final', async () => {
|
|
192
|
+
// Chrome re-sends a settled result when a later phrase revises the list.
|
|
193
|
+
// Appending from `event.resultIndex` would say the first phrase twice.
|
|
194
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
195
|
+
instances[0].onresult!(resultList([['one', true]]))
|
|
196
|
+
instances[0].onresult!(
|
|
197
|
+
resultList([
|
|
198
|
+
['one', true],
|
|
199
|
+
['two', true],
|
|
200
|
+
])
|
|
201
|
+
)
|
|
202
|
+
expect(results().pop()!.detail.text).toBe('one two')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('replaces an interim phrase with its final form rather than keeping both', async () => {
|
|
206
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
207
|
+
instances[0].onresult!(resultList([['hello wor', false]]))
|
|
208
|
+
instances[0].onresult!(resultList([['hello world', true]]))
|
|
209
|
+
expect(results().pop()!.detail.text).toBe('hello world')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('shows finals and the live interim together', async () => {
|
|
213
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
214
|
+
instances[0].onresult!(
|
|
215
|
+
resultList([
|
|
216
|
+
['first', true],
|
|
217
|
+
['sec', false],
|
|
218
|
+
])
|
|
219
|
+
)
|
|
220
|
+
expect(results().pop()!.detail.text).toBe('first sec')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('carries the running text on the end event too', async () => {
|
|
224
|
+
await handler({ ...baseConfig, action: 'start', initialText: 'Note:' }, {})
|
|
225
|
+
instances[0].onresult!(result('buy milk', true))
|
|
226
|
+
await handler({ ...baseConfig, action: 'stop' }, {})
|
|
227
|
+
expect(ends()[0].detail.text).toBe('Note: buy milk')
|
|
228
|
+
expect(ends()[0].detail.transcript).toBe('buy milk')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('does not blame a mid-session "no-speech" for the eventual end', async () => {
|
|
232
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
233
|
+
instances[0].onerror!({ error: 'no-speech' })
|
|
234
|
+
instances[0].onend!() // auto-restart clears the transient error
|
|
235
|
+
await handler({ ...baseConfig, action: 'stop' }, {})
|
|
236
|
+
expect(ends()[0].detail.error).toBe('')
|
|
237
|
+
expect(ends()[0].detail.reason).toBe('stopped')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('reports an unsupported browser and still emits the end event', async () => {
|
|
241
|
+
setupWindow({ supported: false })
|
|
242
|
+
const out = await handler({ ...baseConfig, action: 'start' }, {})
|
|
243
|
+
expect(out).toMatchObject({ listening: false, supported: false, reason: 'unsupported' })
|
|
244
|
+
expect(ends()).toHaveLength(1)
|
|
245
|
+
expect(ends()[0].detail.reason).toBe('unsupported')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('emits exactly one end event with reason "stopped" when the visitor stops it', async () => {
|
|
249
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
250
|
+
await handler({ ...baseConfig, action: 'stop' }, {})
|
|
251
|
+
expect(ends()).toHaveLength(1)
|
|
252
|
+
expect(ends()[0].detail.reason).toBe('stopped')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('surfaces a denied microphone as the end reason', async () => {
|
|
256
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
257
|
+
instances[0].onerror!({ error: 'not-allowed' })
|
|
258
|
+
instances[0].onend!()
|
|
259
|
+
expect(ends()).toHaveLength(1)
|
|
260
|
+
expect(ends()[0].detail.reason).toBe('not-allowed')
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('does not restart after a fatal error', async () => {
|
|
264
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
265
|
+
instances[0].start.mockClear()
|
|
266
|
+
instances[0].onerror!({ error: 'not-allowed' })
|
|
267
|
+
instances[0].onend!()
|
|
268
|
+
expect(instances[0].start).not.toHaveBeenCalled()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('restarts after an ordinary silence timeout so long dictation is not cut short', async () => {
|
|
272
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
273
|
+
instances[0].start.mockClear()
|
|
274
|
+
instances[0].onend!()
|
|
275
|
+
expect(instances[0].start).toHaveBeenCalledTimes(1)
|
|
276
|
+
expect(ends()).toHaveLength(0)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('treats "no-speech" as ordinary and keeps listening', async () => {
|
|
280
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
281
|
+
instances[0].start.mockClear()
|
|
282
|
+
instances[0].onerror!({ error: 'no-speech' })
|
|
283
|
+
instances[0].onend!()
|
|
284
|
+
expect(instances[0].start).toHaveBeenCalledTimes(1)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('does not restart while the tab is in the background', async () => {
|
|
288
|
+
const win = setupWindow()
|
|
289
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
290
|
+
instances[0].start.mockClear()
|
|
291
|
+
win.document.hidden = true
|
|
292
|
+
instances[0].onend!()
|
|
293
|
+
expect(instances[0].start).not.toHaveBeenCalled()
|
|
294
|
+
expect(ends()).toHaveLength(1)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('does not restart when auto-restart is off', async () => {
|
|
298
|
+
await handler({ ...baseConfig, action: 'start', autoRestart: false }, {})
|
|
299
|
+
instances[0].start.mockClear()
|
|
300
|
+
instances[0].onend!()
|
|
301
|
+
expect(instances[0].start).not.toHaveBeenCalled()
|
|
302
|
+
expect(ends()[0].detail.reason).toBe('ended')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('stops on its own after the maximum duration', async () => {
|
|
306
|
+
await handler({ ...baseConfig, action: 'start', maxDurationMs: 1000 }, {})
|
|
307
|
+
jest.advanceTimersByTime(1001)
|
|
308
|
+
expect(instances[0].stop).toHaveBeenCalled()
|
|
309
|
+
expect(ends()).toHaveLength(1)
|
|
310
|
+
expect(ends()[0].detail.reason).toBe('timeout')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('leaves a live session alone when told to start again', async () => {
|
|
314
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
315
|
+
const out = await handler({ ...baseConfig, action: 'start' }, {})
|
|
316
|
+
expect(out).toMatchObject({ listening: true, action: 'unchanged' })
|
|
317
|
+
expect(instances).toHaveLength(1)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('is a no-op when told to stop a session that is not running', async () => {
|
|
321
|
+
const out = await handler({ ...baseConfig, action: 'stop' }, {})
|
|
322
|
+
expect(out).toMatchObject({ listening: false, action: 'unchanged' })
|
|
323
|
+
expect(ends()).toHaveLength(0)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('finalizes rather than stranding the caller when start() throws', async () => {
|
|
327
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
328
|
+
await handler({ ...baseConfig, action: 'stop' }, {})
|
|
329
|
+
dispatched.length = 0
|
|
330
|
+
nextStartThrows = true
|
|
331
|
+
const out = await handler({ ...baseConfig, action: 'start' }, {})
|
|
332
|
+
expect(out).toMatchObject({ listening: false, reason: 'start-failed' })
|
|
333
|
+
expect(ends()).toHaveLength(1)
|
|
334
|
+
expect(ends()[0].detail.reason).toBe('start-failed')
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('gives up rather than restarting a recogniser that fails instantly', async () => {
|
|
338
|
+
// No microphone, or the speech service unreachable: every run ends at once.
|
|
339
|
+
// Restarting those as fast as the browser allows is a tight loop.
|
|
340
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
341
|
+
for (let i = 0; i < 20; i += 1) {
|
|
342
|
+
instances[0].onend!()
|
|
343
|
+
}
|
|
344
|
+
expect(instances[0].start.mock.calls.length).toBeLessThanOrEqual(6)
|
|
345
|
+
expect(ends()).toHaveLength(1)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('keeps restarting when each run actually listened for a while', async () => {
|
|
349
|
+
const realNow = Date.now
|
|
350
|
+
let clock = 0
|
|
351
|
+
Date.now = () => clock
|
|
352
|
+
try {
|
|
353
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
354
|
+
instances[0].start.mockClear()
|
|
355
|
+
for (let i = 0; i < 8; i += 1) {
|
|
356
|
+
clock += 5000 // five seconds of listening before each end
|
|
357
|
+
instances[0].onend!()
|
|
358
|
+
}
|
|
359
|
+
expect(instances[0].start).toHaveBeenCalledTimes(8)
|
|
360
|
+
expect(ends()).toHaveLength(0)
|
|
361
|
+
} finally {
|
|
362
|
+
Date.now = realNow
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('does not let a replaced session turn the UI off under the new one', async () => {
|
|
367
|
+
// `onend` arrives a task after `.stop()`, so a click landing in that gap
|
|
368
|
+
// starts a fresh session under the same name. The old one must die quietly.
|
|
369
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
370
|
+
const first = instances[0]
|
|
371
|
+
first.stop.mockImplementation(() => {}) // onend deferred, as in a real browser
|
|
372
|
+
await handler({ ...baseConfig, action: 'stop' }, {})
|
|
373
|
+
dispatched.length = 0
|
|
374
|
+
|
|
375
|
+
const restarted = await handler({ ...baseConfig, action: 'start' }, {})
|
|
376
|
+
expect(restarted).toMatchObject({ listening: true })
|
|
377
|
+
first.onend!() // the OLD recogniser finally reports back
|
|
378
|
+
|
|
379
|
+
expect(ends()).toHaveLength(0)
|
|
380
|
+
expect(Object.keys((globalThis as any).window.__teleportSpeechSessions)).toEqual(['chat'])
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('keeps separate sessions independent', async () => {
|
|
384
|
+
await handler({ ...baseConfig, sessionId: 'a', action: 'start' }, {})
|
|
385
|
+
await handler({ ...baseConfig, sessionId: 'b', action: 'start' }, {})
|
|
386
|
+
const out = await handler({ ...baseConfig, sessionId: 'a', action: 'stop' }, {})
|
|
387
|
+
expect(out.sessionId).toBe('a')
|
|
388
|
+
expect(instances).toHaveLength(2)
|
|
389
|
+
expect(instances[0].stop).toHaveBeenCalled()
|
|
390
|
+
expect(instances[1].stop).not.toHaveBeenCalled()
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('emits nothing when no event names are configured', async () => {
|
|
394
|
+
await handler({ action: 'start' }, {})
|
|
395
|
+
instances[0].onresult!(result('quiet', true))
|
|
396
|
+
await handler({ action: 'stop' }, {})
|
|
397
|
+
expect(dispatched).toHaveLength(0)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('is registered as a client-only node', () => {
|
|
401
|
+
expect(nodeRegistry['browser-speech-recognition'].executionEnv).toBe('client')
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* ⛔ `isFatalNodeResult` in the generated runtime treats `success === false`
|
|
406
|
+
* OR any non-empty `error` string as a FAILED node and abandons the rest of
|
|
407
|
+
* the workflow. For this node that would skip the state write that puts the
|
|
408
|
+
* button back to idle — leaving a control stuck showing "recording" precisely
|
|
409
|
+
* in the cases where nothing is being recorded.
|
|
410
|
+
*
|
|
411
|
+
* Not being able to listen is a normal outcome of asking, so no exit path may
|
|
412
|
+
* report one.
|
|
413
|
+
*/
|
|
414
|
+
describe('never reports a workflow failure', () => {
|
|
415
|
+
const isFatalNodeResult = (out: any) =>
|
|
416
|
+
!!out &&
|
|
417
|
+
(out.success === false || (typeof out.error === 'string' && out.error) || out.error === true)
|
|
418
|
+
|
|
419
|
+
it('on an unsupported browser', async () => {
|
|
420
|
+
setupWindow({ supported: false })
|
|
421
|
+
expect(isFatalNodeResult(await handler({ ...baseConfig, action: 'start' }, {}))).toBe(false)
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('when start() throws', async () => {
|
|
425
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
426
|
+
await handler({ ...baseConfig, action: 'stop' }, {})
|
|
427
|
+
nextStartThrows = true
|
|
428
|
+
expect(isFatalNodeResult(await handler({ ...baseConfig, action: 'start' }, {}))).toBe(false)
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('on every ordinary path', async () => {
|
|
432
|
+
const outs = [
|
|
433
|
+
await handler({ ...baseConfig, action: 'stop' }, {}), // nothing running
|
|
434
|
+
await handler({ ...baseConfig, action: 'start' }, {}),
|
|
435
|
+
await handler({ ...baseConfig, action: 'start' }, {}), // already running
|
|
436
|
+
await handler({ ...baseConfig, action: 'stop' }, {}),
|
|
437
|
+
]
|
|
438
|
+
outs.forEach((out) => expect(isFatalNodeResult(out)).toBe(false))
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('outside a browser', async () => {
|
|
442
|
+
delete (globalThis as any).window
|
|
443
|
+
expect(isFatalNodeResult(await handler({ ...baseConfig, action: 'start' }, {}))).toBe(false)
|
|
444
|
+
})
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
// A consumer that only acts on SETTLED speech — a spoken command, a submit —
|
|
448
|
+
// has nothing to act on unless the final result actually reaches it.
|
|
449
|
+
describe('the final result is never swallowed by the interim before it', () => {
|
|
450
|
+
it('emits the final even when its text is identical to the last interim', async () => {
|
|
451
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
452
|
+
instances[0].onresult!(result('send it', false))
|
|
453
|
+
instances[0].onresult!(result('send it', true))
|
|
454
|
+
|
|
455
|
+
expect(results().map((e) => [e.detail.text, e.detail.isFinal])).toEqual([
|
|
456
|
+
['send it', false],
|
|
457
|
+
['send it', true],
|
|
458
|
+
])
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('still collapses an unchanged interim, which the API repeats several times a second', async () => {
|
|
462
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
463
|
+
instances[0].onresult!(result('hello', false))
|
|
464
|
+
instances[0].onresult!(result('hello', false))
|
|
465
|
+
instances[0].onresult!(result('hello', false))
|
|
466
|
+
|
|
467
|
+
expect(results()).toHaveLength(1)
|
|
468
|
+
})
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
describe('stopReason', () => {
|
|
472
|
+
it('reports the caller’s reason on the end event', async () => {
|
|
473
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
474
|
+
const out = await handler({ ...baseConfig, action: 'stop', stopReason: 'voice-submit' }, {})
|
|
475
|
+
|
|
476
|
+
expect(out).toMatchObject({ listening: false, action: 'stopped' })
|
|
477
|
+
expect(ends()).toHaveLength(1)
|
|
478
|
+
expect(ends()[0].detail.reason).toBe('voice-submit')
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('falls back to "stopped" when none is given', async () => {
|
|
482
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
483
|
+
await handler({ ...baseConfig, action: 'stop' }, {})
|
|
484
|
+
|
|
485
|
+
expect(ends()[0].detail.reason).toBe('stopped')
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('reports it even when stop() throws and the handler has to finalize itself', async () => {
|
|
489
|
+
await handler({ ...baseConfig, action: 'start' }, {})
|
|
490
|
+
instances[0].stop.mockImplementationOnce(() => {
|
|
491
|
+
throw new Error('InvalidStateError')
|
|
492
|
+
})
|
|
493
|
+
await handler({ ...baseConfig, action: 'stop', stopReason: 'voice-stop' }, {})
|
|
494
|
+
|
|
495
|
+
expect(ends()).toHaveLength(1)
|
|
496
|
+
expect(ends()[0].detail.reason).toBe('voice-stop')
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('leaves a reason the session ended on its own alone', async () => {
|
|
500
|
+
await handler(
|
|
501
|
+
{ ...baseConfig, action: 'start', autoRestart: false, stopReason: 'ignored' },
|
|
502
|
+
{}
|
|
503
|
+
)
|
|
504
|
+
instances[0].onend!()
|
|
505
|
+
|
|
506
|
+
expect(ends()[0].detail.reason).toBe('ended')
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
// Hands-free commands. The visitor dictating has no free hand for a button,
|
|
511
|
+
// so the last thing they say can be the instruction — and the words they said
|
|
512
|
+
// to command must never end up in the field they were dictating into.
|
|
513
|
+
describe('spoken commands', () => {
|
|
514
|
+
// ⛔ Half of a cross-repo contract: the builder declares these same two
|
|
515
|
+
// strings as SPEECH_STOP_PHRASE_REASON / SPEECH_SUBMIT_PHRASE_REASON and
|
|
516
|
+
// gates its "now send the message" branch on them. Neither repo can import
|
|
517
|
+
// the other, so each pins the literal and a one-sided rename fails here.
|
|
518
|
+
it('reports the reason strings the builder gates its branches on', async () => {
|
|
519
|
+
await handler(
|
|
520
|
+
{ ...baseConfig, action: 'start', autoRestart: false, stopPhrases: ['stop'] },
|
|
521
|
+
{}
|
|
522
|
+
)
|
|
523
|
+
instances[0].onresult!(result('that is all stop', true))
|
|
524
|
+
expect(ends()[0].detail.reason).toBe('stop-phrase')
|
|
525
|
+
|
|
526
|
+
setupWindow()
|
|
527
|
+
await handler(
|
|
528
|
+
{ ...baseConfig, action: 'start', autoRestart: false, submitPhrases: ['send it'] },
|
|
529
|
+
{}
|
|
530
|
+
)
|
|
531
|
+
instances[0].onresult!(result('a question send it', true))
|
|
532
|
+
expect(ends()[0].detail.reason).toBe('submit-phrase')
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
const phraseConfig = {
|
|
536
|
+
...baseConfig,
|
|
537
|
+
action: 'start',
|
|
538
|
+
autoRestart: false,
|
|
539
|
+
stopPhrases: ['stop listening', 'stop'],
|
|
540
|
+
submitPhrases: ['send the message', 'send it', 'send'],
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
it('stops the session when a stop phrase ends a settled result', async () => {
|
|
544
|
+
await handler(phraseConfig, {})
|
|
545
|
+
instances[0].onresult!(result('remind me to call the vet stop listening', true))
|
|
546
|
+
|
|
547
|
+
expect(instances[0].stop).toHaveBeenCalled()
|
|
548
|
+
expect(ends()).toHaveLength(1)
|
|
549
|
+
expect(ends()[0].detail.reason).toBe('stop-phrase')
|
|
550
|
+
expect(ends()[0].detail.matchedPhrase).toBe('stop listening')
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('reports the submit reason for a send phrase, so a workflow can act', async () => {
|
|
554
|
+
await handler(phraseConfig, {})
|
|
555
|
+
instances[0].onresult!(result('what are your opening hours send it', true))
|
|
556
|
+
|
|
557
|
+
expect(ends()[0].detail.reason).toBe('submit-phrase')
|
|
558
|
+
expect(ends()[0].detail.matchedPhrase).toBe('send it')
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('cuts the command out of every emitted text, end event included', async () => {
|
|
562
|
+
await handler(phraseConfig, {})
|
|
563
|
+
instances[0].onresult!(result('what are your opening hours send it', true))
|
|
564
|
+
|
|
565
|
+
expect(results()[results().length - 1].detail.text).toBe('what are your opening hours')
|
|
566
|
+
expect(ends()[0].detail.text).toBe('what are your opening hours')
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
it('hides the command word on an interim WITHOUT acting on it', async () => {
|
|
570
|
+
await handler(phraseConfig, {})
|
|
571
|
+
instances[0].onresult!(result('what are your opening hours send it', false))
|
|
572
|
+
|
|
573
|
+
expect(results()[0].detail.text).toBe('what are your opening hours')
|
|
574
|
+
expect(instances[0].stop).not.toHaveBeenCalled()
|
|
575
|
+
expect(ends()).toHaveLength(0)
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
it('prefers the longest phrase, so a shorter one cannot strand words', async () => {
|
|
579
|
+
await handler(phraseConfig, {})
|
|
580
|
+
instances[0].onresult!(result('book me a table stop listening', true))
|
|
581
|
+
|
|
582
|
+
expect(ends()[0].detail.text).toBe('book me a table')
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
it('only matches at a word boundary and only at the end', async () => {
|
|
586
|
+
await handler(phraseConfig, {})
|
|
587
|
+
instances[0].onresult!(result('please tell me what to enclose', true))
|
|
588
|
+
instances[0].onresult!(result('how do I stop my subscription', true))
|
|
589
|
+
|
|
590
|
+
expect(instances[0].stop).not.toHaveBeenCalled()
|
|
591
|
+
expect(ends()).toHaveLength(0)
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
it('treats a submit that leaves nothing behind as a plain stop', async () => {
|
|
595
|
+
await handler(phraseConfig, {})
|
|
596
|
+
instances[0].onresult!(result('send it', true))
|
|
597
|
+
|
|
598
|
+
expect(ends()[0].detail.reason).toBe('stop-phrase')
|
|
599
|
+
expect(ends()[0].detail.text).toBe('')
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it('keeps the message’s own punctuation, dropping only the separator', async () => {
|
|
603
|
+
await handler(phraseConfig, {})
|
|
604
|
+
instances[0].onresult!(result('what time do you open? send it.', true))
|
|
605
|
+
|
|
606
|
+
expect(ends()[0].detail.text).toBe('what time do you open?')
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
it('does nothing at all when no phrases are configured', async () => {
|
|
610
|
+
await handler({ ...baseConfig, action: 'start', autoRestart: false }, {})
|
|
611
|
+
instances[0].onresult!(result('please send it', true))
|
|
612
|
+
|
|
613
|
+
expect(instances[0].stop).not.toHaveBeenCalled()
|
|
614
|
+
expect(results()[0].detail.text).toBe('please send it')
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
it('appends to text already in the field without matching against it', async () => {
|
|
618
|
+
await handler({ ...phraseConfig, initialText: 'draft: send' }, {})
|
|
619
|
+
instances[0].onresult!(result('and the rest send it', true))
|
|
620
|
+
|
|
621
|
+
// The typed "send" is not a command — only what was SPOKEN is matched.
|
|
622
|
+
expect(ends()[0].detail.text).toBe('draft: send and the rest')
|
|
623
|
+
})
|
|
624
|
+
})
|
|
625
|
+
})
|