switchroom 0.16.29 → 0.16.46
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/dist/agent-scheduler/index.js +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1524 -129
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +22 -5
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the LOCAL TTS sidecar synthesis path (voice PR-C2):
|
|
3
|
+
* - synthesizeViaSidecar (HTTP POST to /tts, mocked fetch)
|
|
4
|
+
*
|
|
5
|
+
* The OUTBOUND mirror of voice-transcribe-sidecar.test.ts: every error
|
|
6
|
+
* reason is pinned so a refactor can't silently change the contract the
|
|
7
|
+
* gateway depends on (any non-ok → null → text-only fallback). The /tts
|
|
8
|
+
* endpoint returns RAW OGG bytes on success (not JSON), so the happy-path
|
|
9
|
+
* asserts on arrayBuffer + the audio/ogg-style meta headers.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect } from 'bun:test'
|
|
13
|
+
import {
|
|
14
|
+
synthesizeViaSidecar,
|
|
15
|
+
chunkTtsText,
|
|
16
|
+
clampTtsSpeed,
|
|
17
|
+
SIDECAR_TTS_MAX_CHARS,
|
|
18
|
+
DEFAULT_SIDECAR_BASE_URL,
|
|
19
|
+
} from '../voice-synthesize-sidecar.js'
|
|
20
|
+
|
|
21
|
+
const OGG_BYTES = new Uint8Array([0x4f, 0x67, 0x67, 0x53]) // OggS magic
|
|
22
|
+
|
|
23
|
+
/** Mock a 200 audio/ogg response carrying `bytes`. */
|
|
24
|
+
function makeOggFetch(
|
|
25
|
+
bytes: Uint8Array,
|
|
26
|
+
headers: Record<string, string> = {},
|
|
27
|
+
): typeof fetch {
|
|
28
|
+
return (async () => ({
|
|
29
|
+
ok: true,
|
|
30
|
+
status: 200,
|
|
31
|
+
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
|
|
32
|
+
text: async () => '',
|
|
33
|
+
headers: new Headers(headers),
|
|
34
|
+
})) as unknown as typeof fetch
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Mock a non-2xx JSON error response. */
|
|
38
|
+
function makeErrFetch(status: number, body: unknown): typeof fetch {
|
|
39
|
+
return (async () => ({
|
|
40
|
+
ok: false,
|
|
41
|
+
status,
|
|
42
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
43
|
+
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
|
44
|
+
headers: new Headers(),
|
|
45
|
+
})) as unknown as typeof fetch
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('synthesizeViaSidecar — pre-flight checks', () => {
|
|
49
|
+
it('returns no-token when token is empty', async () => {
|
|
50
|
+
const r = await synthesizeViaSidecar({
|
|
51
|
+
token: '',
|
|
52
|
+
text: 'hello',
|
|
53
|
+
fetchImpl: makeOggFetch(OGG_BYTES),
|
|
54
|
+
})
|
|
55
|
+
expect(r).toMatchObject({ ok: false, reason: 'no-token' })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns empty-text on whitespace-only text', async () => {
|
|
59
|
+
const r = await synthesizeViaSidecar({ token: 't', text: ' ' })
|
|
60
|
+
expect(r).toMatchObject({ ok: false, reason: 'empty-text' })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns text-too-long above the cap', async () => {
|
|
64
|
+
const r = await synthesizeViaSidecar({
|
|
65
|
+
token: 't',
|
|
66
|
+
text: 'x'.repeat(SIDECAR_TTS_MAX_CHARS + 1),
|
|
67
|
+
})
|
|
68
|
+
expect(r).toMatchObject({ ok: false, reason: 'text-too-long' })
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('synthesizeViaSidecar — happy path', () => {
|
|
73
|
+
it('returns OGG bytes + meta on 200 audio/ogg', async () => {
|
|
74
|
+
const r = await synthesizeViaSidecar({
|
|
75
|
+
token: 't',
|
|
76
|
+
text: 'Hello world',
|
|
77
|
+
fetchImpl: makeOggFetch(OGG_BYTES, {
|
|
78
|
+
'X-Voice-Duration-Ms': '1234',
|
|
79
|
+
'X-Voice-Audio-Seconds': '2.5',
|
|
80
|
+
'X-Voice-Voice': 'af_heart',
|
|
81
|
+
}),
|
|
82
|
+
})
|
|
83
|
+
expect(r.ok).toBe(true)
|
|
84
|
+
if (r.ok) {
|
|
85
|
+
expect(Array.from(r.audio)).toEqual(Array.from(OGG_BYTES))
|
|
86
|
+
expect(r.durationMs).toBe(1234)
|
|
87
|
+
expect(r.audioSeconds).toBe(2.5)
|
|
88
|
+
expect(r.voice).toBe('af_heart')
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('POSTs JSON {text,voice,format:ogg} + X-Voice-Token to <base>/tts', async () => {
|
|
93
|
+
let receivedUrl = ''
|
|
94
|
+
let receivedToken: string | null = null
|
|
95
|
+
let receivedBody: string | null = null
|
|
96
|
+
const fakeFetch: typeof fetch = (async (url: string, init?: RequestInit) => {
|
|
97
|
+
receivedUrl = url
|
|
98
|
+
receivedToken = (init?.headers as Record<string, string>)['X-Voice-Token']
|
|
99
|
+
receivedBody = init?.body as string
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
status: 200,
|
|
103
|
+
arrayBuffer: async () => OGG_BYTES.buffer.slice(0),
|
|
104
|
+
text: async () => '',
|
|
105
|
+
headers: new Headers(),
|
|
106
|
+
}
|
|
107
|
+
}) as unknown as typeof fetch
|
|
108
|
+
|
|
109
|
+
const r = await synthesizeViaSidecar({
|
|
110
|
+
token: 'secret-token',
|
|
111
|
+
text: 'bonjour',
|
|
112
|
+
voice: 'af_sky',
|
|
113
|
+
fetchImpl: fakeFetch,
|
|
114
|
+
})
|
|
115
|
+
expect(r.ok).toBe(true)
|
|
116
|
+
expect(receivedUrl).toBe(`${DEFAULT_SIDECAR_BASE_URL}/tts`)
|
|
117
|
+
expect(receivedToken).toBe('secret-token')
|
|
118
|
+
const parsed = JSON.parse(receivedBody!)
|
|
119
|
+
expect(parsed.text).toBe('bonjour')
|
|
120
|
+
expect(parsed.voice).toBe('af_sky')
|
|
121
|
+
expect(parsed.format).toBe('ogg')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('omits the voice field when none is supplied', async () => {
|
|
125
|
+
let receivedBody: string | null = null
|
|
126
|
+
const fakeFetch: typeof fetch = (async (_url: string, init?: RequestInit) => {
|
|
127
|
+
receivedBody = init?.body as string
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
status: 200,
|
|
131
|
+
arrayBuffer: async () => OGG_BYTES.buffer.slice(0),
|
|
132
|
+
text: async () => '',
|
|
133
|
+
headers: new Headers(),
|
|
134
|
+
}
|
|
135
|
+
}) as unknown as typeof fetch
|
|
136
|
+
await synthesizeViaSidecar({ token: 't', text: 'hi', fetchImpl: fakeFetch })
|
|
137
|
+
const parsed = JSON.parse(receivedBody!)
|
|
138
|
+
expect('voice' in parsed).toBe(false)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('omits the speed field when none is supplied (sidecar defaults to 1.0)', async () => {
|
|
142
|
+
let receivedBody: string | null = null
|
|
143
|
+
const fakeFetch: typeof fetch = (async (_url: string, init?: RequestInit) => {
|
|
144
|
+
receivedBody = init?.body as string
|
|
145
|
+
return {
|
|
146
|
+
ok: true,
|
|
147
|
+
status: 200,
|
|
148
|
+
arrayBuffer: async () => OGG_BYTES.buffer.slice(0),
|
|
149
|
+
text: async () => '',
|
|
150
|
+
headers: new Headers(),
|
|
151
|
+
}
|
|
152
|
+
}) as unknown as typeof fetch
|
|
153
|
+
await synthesizeViaSidecar({ token: 't', text: 'hi', fetchImpl: fakeFetch })
|
|
154
|
+
const parsed = JSON.parse(receivedBody!)
|
|
155
|
+
expect('speed' in parsed).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('includes speed in the /tts body when supplied', async () => {
|
|
159
|
+
let receivedBody: string | null = null
|
|
160
|
+
const fakeFetch: typeof fetch = (async (_url: string, init?: RequestInit) => {
|
|
161
|
+
receivedBody = init?.body as string
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
status: 200,
|
|
165
|
+
arrayBuffer: async () => OGG_BYTES.buffer.slice(0),
|
|
166
|
+
text: async () => '',
|
|
167
|
+
headers: new Headers(),
|
|
168
|
+
}
|
|
169
|
+
}) as unknown as typeof fetch
|
|
170
|
+
await synthesizeViaSidecar({ token: 't', text: 'hi', speed: 1.1, fetchImpl: fakeFetch })
|
|
171
|
+
const parsed = JSON.parse(receivedBody!)
|
|
172
|
+
expect(parsed.speed).toBe(1.1)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('clamps an out-of-range speed before putting it on the wire', async () => {
|
|
176
|
+
const cap = async (speed: number): Promise<number> => {
|
|
177
|
+
let receivedBody: string | null = null
|
|
178
|
+
const fakeFetch: typeof fetch = (async (_url: string, init?: RequestInit) => {
|
|
179
|
+
receivedBody = init?.body as string
|
|
180
|
+
return {
|
|
181
|
+
ok: true,
|
|
182
|
+
status: 200,
|
|
183
|
+
arrayBuffer: async () => OGG_BYTES.buffer.slice(0),
|
|
184
|
+
text: async () => '',
|
|
185
|
+
headers: new Headers(),
|
|
186
|
+
}
|
|
187
|
+
}) as unknown as typeof fetch
|
|
188
|
+
await synthesizeViaSidecar({ token: 't', text: 'hi', speed, fetchImpl: fakeFetch })
|
|
189
|
+
return JSON.parse(receivedBody!).speed
|
|
190
|
+
}
|
|
191
|
+
expect(await cap(5.0)).toBe(2.0) // above max → 2.0
|
|
192
|
+
expect(await cap(0.1)).toBe(0.5) // below min → 0.5
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('clampTtsSpeed', () => {
|
|
197
|
+
it('passes in-range values through', () => {
|
|
198
|
+
for (const v of [0.5, 0.75, 1.0, 1.1, 1.5, 2.0]) {
|
|
199
|
+
expect(clampTtsSpeed(v)).toBe(v)
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('clamps out-of-range numbers to the nearest bound', () => {
|
|
204
|
+
expect(clampTtsSpeed(0.1)).toBe(0.5)
|
|
205
|
+
expect(clampTtsSpeed(-3)).toBe(0.5)
|
|
206
|
+
expect(clampTtsSpeed(2.5)).toBe(2.0)
|
|
207
|
+
expect(clampTtsSpeed(100)).toBe(2.0)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('falls back to the default for non-finite / non-numeric input', () => {
|
|
211
|
+
expect(clampTtsSpeed(undefined)).toBe(1.0)
|
|
212
|
+
expect(clampTtsSpeed(null)).toBe(1.0)
|
|
213
|
+
expect(clampTtsSpeed(NaN)).toBe(1.0)
|
|
214
|
+
expect(clampTtsSpeed('fast')).toBe(1.0)
|
|
215
|
+
expect(clampTtsSpeed(undefined, 1.1)).toBe(1.1) // custom fallback
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('falls back to wall-clock durationMs when headers are absent', async () => {
|
|
219
|
+
const r = await synthesizeViaSidecar({
|
|
220
|
+
token: 't',
|
|
221
|
+
text: 'hi',
|
|
222
|
+
fetchImpl: makeOggFetch(OGG_BYTES),
|
|
223
|
+
})
|
|
224
|
+
expect(r.ok).toBe(true)
|
|
225
|
+
if (r.ok) {
|
|
226
|
+
expect(r.durationMs).toBeGreaterThanOrEqual(0)
|
|
227
|
+
expect(r.audioSeconds).toBeNull()
|
|
228
|
+
expect(r.voice).toBeNull()
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
describe('synthesizeViaSidecar — error paths', () => {
|
|
234
|
+
it('echoes the sidecar reason from a non-2xx JSON body', async () => {
|
|
235
|
+
const r = await synthesizeViaSidecar({
|
|
236
|
+
token: 't',
|
|
237
|
+
text: 'hi',
|
|
238
|
+
fetchImpl: makeErrFetch(413, { ok: false, reason: 'text-too-long', detail: '9999 > 1200' }),
|
|
239
|
+
})
|
|
240
|
+
expect(r).toMatchObject({ ok: false, reason: 'text-too-long', detail: '9999 > 1200' })
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('falls back to http-<n> when the error body is not JSON', async () => {
|
|
244
|
+
const r = await synthesizeViaSidecar({
|
|
245
|
+
token: 't',
|
|
246
|
+
text: 'hi',
|
|
247
|
+
fetchImpl: makeErrFetch(503, 'service unavailable'),
|
|
248
|
+
})
|
|
249
|
+
expect(r).toMatchObject({ ok: false, reason: 'http-503' })
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('returns empty-audio on a 200 with no bytes', async () => {
|
|
253
|
+
const r = await synthesizeViaSidecar({
|
|
254
|
+
token: 't',
|
|
255
|
+
text: 'hi',
|
|
256
|
+
fetchImpl: makeOggFetch(new Uint8Array(0)),
|
|
257
|
+
})
|
|
258
|
+
expect(r).toMatchObject({ ok: false, reason: 'empty-audio' })
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('returns fetch-failed on a network error', async () => {
|
|
262
|
+
const boom: typeof fetch = (async () => {
|
|
263
|
+
throw new Error('ECONNREFUSED')
|
|
264
|
+
}) as unknown as typeof fetch
|
|
265
|
+
const r = await synthesizeViaSidecar({ token: 't', text: 'hi', fetchImpl: boom })
|
|
266
|
+
expect(r).toMatchObject({ ok: false, reason: 'fetch-failed' })
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('returns timeout when the fetch aborts', async () => {
|
|
270
|
+
const aborted: typeof fetch = (async () => {
|
|
271
|
+
throw new Error('The operation was aborted')
|
|
272
|
+
}) as unknown as typeof fetch
|
|
273
|
+
const r = await synthesizeViaSidecar({ token: 't', text: 'hi', fetchImpl: aborted })
|
|
274
|
+
expect(r).toMatchObject({ ok: false, reason: 'timeout' })
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
describe('chunkTtsText — long-reply voice chunking (PR-C2)', () => {
|
|
279
|
+
it('returns a single chunk when text fits the cap', () => {
|
|
280
|
+
expect(chunkTtsText('Short answer.', 600)).toEqual(['Short answer.'])
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('returns no chunks for empty text', () => {
|
|
284
|
+
expect(chunkTtsText('', 600)).toEqual([])
|
|
285
|
+
expect(chunkTtsText(' ', 600)).toEqual([])
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('splits a long reply into multiple chunks, each under the cap', () => {
|
|
289
|
+
const sentence = 'This is a sentence that is reasonably long. '
|
|
290
|
+
const text = sentence.repeat(20).trim() // ~880 chars
|
|
291
|
+
const chunks = chunkTtsText(text, 200)
|
|
292
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
293
|
+
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(200)
|
|
294
|
+
// No text is lost: every sentence's words survive across the chunks.
|
|
295
|
+
const rejoined = chunks.join(' ')
|
|
296
|
+
expect(rejoined).toContain('reasonably long')
|
|
297
|
+
expect(rejoined.split('reasonably long').length - 1).toBe(20)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('prefers sentence boundaries — does not cut mid-sentence', () => {
|
|
301
|
+
const text =
|
|
302
|
+
'First sentence here. Second sentence here. Third sentence here. Fourth one too.'
|
|
303
|
+
const chunks = chunkTtsText(text, 45)
|
|
304
|
+
// Each chunk should end at a sentence terminator (no dangling fragment).
|
|
305
|
+
for (const c of chunks) expect(/[.!?]$/.test(c)).toBe(true)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('hard-slices a single sentence longer than the cap (no boundary fits)', () => {
|
|
309
|
+
const longWord = 'a'.repeat(50)
|
|
310
|
+
const text = `${longWord} ${longWord} ${longWord}` // one boundary-less-ish run
|
|
311
|
+
const chunks = chunkTtsText(text, 60)
|
|
312
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
313
|
+
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(60)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('never emits a chunk over the cap even with a giant tokenless blob', () => {
|
|
317
|
+
const blob = 'x'.repeat(5000)
|
|
318
|
+
const chunks = chunkTtsText(blob, 1200)
|
|
319
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
320
|
+
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(1200)
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
describe('synthesizeViaSidecar — multi-chunk send path (PR-C2)', () => {
|
|
325
|
+
it('synthesizes each chunk independently (one POST per chunk)', async () => {
|
|
326
|
+
const text = 'Sentence one is here. '.repeat(40).trim() // long → many chunks
|
|
327
|
+
const chunks = chunkTtsText(text, 200)
|
|
328
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
329
|
+
|
|
330
|
+
let calls = 0
|
|
331
|
+
const fakeFetch: typeof fetch = (async () => {
|
|
332
|
+
calls++
|
|
333
|
+
return {
|
|
334
|
+
ok: true,
|
|
335
|
+
status: 200,
|
|
336
|
+
arrayBuffer: async () => OGG_BYTES.buffer.slice(0),
|
|
337
|
+
text: async () => '',
|
|
338
|
+
headers: new Headers(),
|
|
339
|
+
}
|
|
340
|
+
}) as unknown as typeof fetch
|
|
341
|
+
|
|
342
|
+
// Mirror the gateway's per-chunk synthesis loop.
|
|
343
|
+
const oggs: Uint8Array[] = []
|
|
344
|
+
for (const c of chunks) {
|
|
345
|
+
const r = await synthesizeViaSidecar({ token: 't', text: c, fetchImpl: fakeFetch })
|
|
346
|
+
expect(r.ok).toBe(true)
|
|
347
|
+
if (r.ok) oggs.push(r.audio)
|
|
348
|
+
}
|
|
349
|
+
expect(calls).toBe(chunks.length)
|
|
350
|
+
expect(oggs.length).toBe(chunks.length)
|
|
351
|
+
})
|
|
352
|
+
})
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the LOCAL STT sidecar transcription path (voice PR-B2):
|
|
3
|
+
* - transcribeViaSidecar (HTTP POST to /stt, mocked fetch)
|
|
4
|
+
* - materializeSidecarToken (vault resolution, mocked broker)
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the cloud twin's test (voice-transcribe.test.ts): every error
|
|
7
|
+
* reason is pinned so a refactor can't silently change the contract the
|
|
8
|
+
* gateway depends on (any non-ok → null → "(voice message)" fallback).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'bun:test'
|
|
12
|
+
import {
|
|
13
|
+
transcribeViaSidecar,
|
|
14
|
+
SIDECAR_MAX_BYTES,
|
|
15
|
+
DEFAULT_SIDECAR_BASE_URL,
|
|
16
|
+
} from '../voice-transcribe-sidecar.js'
|
|
17
|
+
import {
|
|
18
|
+
materializeSidecarToken,
|
|
19
|
+
DEFAULT_VOICE_SIDECAR_TOKEN_REF,
|
|
20
|
+
VOICE_SIDECAR_TOKEN_KEY,
|
|
21
|
+
} from '../../src/telegram/materialize-sidecar-token.js'
|
|
22
|
+
import type { resolveVaultReferencesViaBroker } from '../../src/vault/resolver.js'
|
|
23
|
+
import type { SwitchroomConfig } from '../../src/config/schema.js'
|
|
24
|
+
|
|
25
|
+
const SMALL_AUDIO = new Uint8Array([0x4f, 0x67, 0x67, 0x53]) // OggS magic
|
|
26
|
+
|
|
27
|
+
function makeFetch(status: number, body: unknown): typeof fetch {
|
|
28
|
+
return (async () => ({
|
|
29
|
+
ok: status >= 200 && status < 300,
|
|
30
|
+
status,
|
|
31
|
+
json: async () => body,
|
|
32
|
+
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
|
33
|
+
headers: new Headers(),
|
|
34
|
+
})) as unknown as typeof fetch
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('transcribeViaSidecar — pre-flight checks', () => {
|
|
38
|
+
it('returns no-token when token is empty', async () => {
|
|
39
|
+
const r = await transcribeViaSidecar({
|
|
40
|
+
token: '',
|
|
41
|
+
audio: SMALL_AUDIO,
|
|
42
|
+
filename: 'voice.ogg',
|
|
43
|
+
fetchImpl: makeFetch(200, { ok: true, text: 'x' }),
|
|
44
|
+
})
|
|
45
|
+
expect(r).toMatchObject({ ok: false, reason: 'no-token' })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns audio-too-short on empty audio', async () => {
|
|
49
|
+
const r = await transcribeViaSidecar({
|
|
50
|
+
token: 't',
|
|
51
|
+
audio: new Uint8Array(0),
|
|
52
|
+
filename: 'voice.ogg',
|
|
53
|
+
})
|
|
54
|
+
expect(r).toMatchObject({ ok: false, reason: 'audio-too-short' })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns audio-too-large above the cap', async () => {
|
|
58
|
+
const tooBig = new Uint8Array(SIDECAR_MAX_BYTES + 1)
|
|
59
|
+
const r = await transcribeViaSidecar({
|
|
60
|
+
token: 't',
|
|
61
|
+
audio: tooBig,
|
|
62
|
+
filename: 'voice.ogg',
|
|
63
|
+
})
|
|
64
|
+
expect(r).toMatchObject({ ok: false, reason: 'audio-too-large' })
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('transcribeViaSidecar — happy path', () => {
|
|
69
|
+
it('returns transcript on 200 + valid JSON', async () => {
|
|
70
|
+
const r = await transcribeViaSidecar({
|
|
71
|
+
token: 't',
|
|
72
|
+
audio: SMALL_AUDIO,
|
|
73
|
+
filename: 'voice.ogg',
|
|
74
|
+
fetchImpl: makeFetch(200, {
|
|
75
|
+
ok: true,
|
|
76
|
+
text: 'Hello world',
|
|
77
|
+
language: 'en',
|
|
78
|
+
durationMs: 1234,
|
|
79
|
+
audioSeconds: 3.2,
|
|
80
|
+
}),
|
|
81
|
+
})
|
|
82
|
+
expect(r.ok).toBe(true)
|
|
83
|
+
if (r.ok) {
|
|
84
|
+
expect(r.text).toBe('Hello world')
|
|
85
|
+
expect(r.language).toBe('en')
|
|
86
|
+
expect(r.durationMs).toBe(1234)
|
|
87
|
+
expect(r.audioSeconds).toBe(3.2)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('sends the X-Voice-Token header, audio + language fields, to <base>/stt', async () => {
|
|
92
|
+
let receivedUrl = ''
|
|
93
|
+
let receivedToken: string | null = null
|
|
94
|
+
let receivedForm: FormData | null = null
|
|
95
|
+
const fakeFetch: typeof fetch = (async (url: string, init?: RequestInit) => {
|
|
96
|
+
receivedUrl = url
|
|
97
|
+
receivedToken = (init?.headers as Record<string, string>)['X-Voice-Token']
|
|
98
|
+
receivedForm = init?.body as FormData
|
|
99
|
+
return {
|
|
100
|
+
ok: true,
|
|
101
|
+
status: 200,
|
|
102
|
+
json: async () => ({ ok: true, text: 'bonjour' }),
|
|
103
|
+
text: async () => '',
|
|
104
|
+
headers: new Headers(),
|
|
105
|
+
}
|
|
106
|
+
}) as unknown as typeof fetch
|
|
107
|
+
|
|
108
|
+
const r = await transcribeViaSidecar({
|
|
109
|
+
token: 'secret-token',
|
|
110
|
+
audio: SMALL_AUDIO,
|
|
111
|
+
filename: 'voice.ogg',
|
|
112
|
+
language: 'fr',
|
|
113
|
+
fetchImpl: fakeFetch,
|
|
114
|
+
})
|
|
115
|
+
expect(r.ok).toBe(true)
|
|
116
|
+
expect(receivedUrl).toBe(`${DEFAULT_SIDECAR_BASE_URL}/stt`)
|
|
117
|
+
expect(receivedToken).toBe('secret-token')
|
|
118
|
+
expect(receivedForm).not.toBeNull()
|
|
119
|
+
expect(receivedForm!.get('language')).toBe('fr')
|
|
120
|
+
expect(receivedForm!.get('audio')).not.toBeNull()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('falls back to wall-clock durationMs when the sidecar omits it', async () => {
|
|
124
|
+
const r = await transcribeViaSidecar({
|
|
125
|
+
token: 't',
|
|
126
|
+
audio: SMALL_AUDIO,
|
|
127
|
+
filename: 'voice.ogg',
|
|
128
|
+
fetchImpl: makeFetch(200, { ok: true, text: 'hi' }),
|
|
129
|
+
})
|
|
130
|
+
expect(r.ok).toBe(true)
|
|
131
|
+
if (r.ok) {
|
|
132
|
+
expect(r.durationMs).toBeGreaterThanOrEqual(0)
|
|
133
|
+
expect(r.audioSeconds).toBeNull()
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('transcribeViaSidecar — error paths', () => {
|
|
139
|
+
it('returns http-401 on a wrong/missing token (sidecar rejects)', async () => {
|
|
140
|
+
const r = await transcribeViaSidecar({
|
|
141
|
+
token: 't',
|
|
142
|
+
audio: SMALL_AUDIO,
|
|
143
|
+
filename: 'voice.ogg',
|
|
144
|
+
fetchImpl: makeFetch(401, { ok: false, reason: 'unauthorized' }),
|
|
145
|
+
})
|
|
146
|
+
expect(r).toMatchObject({ ok: false, reason: 'http-401' })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('surfaces the sidecar reason on a 2xx { ok: false } body', async () => {
|
|
150
|
+
const r = await transcribeViaSidecar({
|
|
151
|
+
token: 't',
|
|
152
|
+
audio: SMALL_AUDIO,
|
|
153
|
+
filename: 'voice.ogg',
|
|
154
|
+
fetchImpl: makeFetch(200, { ok: false, reason: 'gpu-timeout', detail: 'busy' }),
|
|
155
|
+
})
|
|
156
|
+
expect(r).toMatchObject({ ok: false, reason: 'gpu-timeout' })
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('returns malformed-response when 200 body has no text field', async () => {
|
|
160
|
+
const r = await transcribeViaSidecar({
|
|
161
|
+
token: 't',
|
|
162
|
+
audio: SMALL_AUDIO,
|
|
163
|
+
filename: 'voice.ogg',
|
|
164
|
+
fetchImpl: makeFetch(200, { ok: true, unexpected: 'shape' }),
|
|
165
|
+
})
|
|
166
|
+
expect(r).toMatchObject({ ok: false, reason: 'malformed-response' })
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('returns fetch-failed on a thrown network error', async () => {
|
|
170
|
+
const fakeFetch: typeof fetch = (async () => {
|
|
171
|
+
throw new Error('ECONNREFUSED')
|
|
172
|
+
}) as unknown as typeof fetch
|
|
173
|
+
const r = await transcribeViaSidecar({
|
|
174
|
+
token: 't',
|
|
175
|
+
audio: SMALL_AUDIO,
|
|
176
|
+
filename: 'voice.ogg',
|
|
177
|
+
fetchImpl: fakeFetch,
|
|
178
|
+
})
|
|
179
|
+
expect(r).toMatchObject({ ok: false, reason: 'fetch-failed' })
|
|
180
|
+
expect(r.detail).toContain('ECONNREFUSED')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('returns timeout when fetch is aborted', async () => {
|
|
184
|
+
const fakeFetch: typeof fetch = (async () => {
|
|
185
|
+
throw new Error('The operation was aborted')
|
|
186
|
+
}) as unknown as typeof fetch
|
|
187
|
+
const r = await transcribeViaSidecar({
|
|
188
|
+
token: 't',
|
|
189
|
+
audio: SMALL_AUDIO,
|
|
190
|
+
filename: 'voice.ogg',
|
|
191
|
+
timeoutMs: 5,
|
|
192
|
+
fetchImpl: fakeFetch,
|
|
193
|
+
})
|
|
194
|
+
expect(r).toMatchObject({ ok: false, reason: 'timeout' })
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// ── materializeSidecarToken — vault resolution (mirrors the key helper) ──
|
|
199
|
+
|
|
200
|
+
const EMPTY_CONFIG = {} as unknown as SwitchroomConfig
|
|
201
|
+
|
|
202
|
+
function makeBrokerResolver(
|
|
203
|
+
resolvedRefs: Record<string, string>,
|
|
204
|
+
): typeof resolveVaultReferencesViaBroker {
|
|
205
|
+
return (async (config: SwitchroomConfig) => {
|
|
206
|
+
const ref = config.telegram?.bot_token
|
|
207
|
+
if (ref && resolvedRefs[ref] !== undefined) {
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
config: {
|
|
211
|
+
...config,
|
|
212
|
+
telegram: { ...config.telegram, bot_token: resolvedRefs[ref] },
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { ok: false, reason: 'not_found' as const }
|
|
217
|
+
}) as unknown as typeof resolveVaultReferencesViaBroker
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
describe('materializeSidecarToken — vault resolution', () => {
|
|
221
|
+
it('defaults to vault:voice/sidecar-token', () => {
|
|
222
|
+
expect(VOICE_SIDECAR_TOKEN_KEY).toBe('voice/sidecar-token')
|
|
223
|
+
expect(DEFAULT_VOICE_SIDECAR_TOKEN_REF).toBe('vault:voice/sidecar-token')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('resolves the configured vault ref via the broker', async () => {
|
|
227
|
+
const tok = await materializeSidecarToken({
|
|
228
|
+
config: EMPTY_CONFIG,
|
|
229
|
+
env: {},
|
|
230
|
+
resolveViaBroker: makeBrokerResolver({
|
|
231
|
+
'vault:voice/sidecar-token': 'deadbeef' + 'cafe',
|
|
232
|
+
}),
|
|
233
|
+
})
|
|
234
|
+
expect(tok).toBe('deadbeef' + 'cafe')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('returns a literal (non-vault) token directly without the broker', async () => {
|
|
238
|
+
let brokerCalled = false
|
|
239
|
+
const tok = await materializeSidecarToken({
|
|
240
|
+
tokenRef: 'raw' + 'token',
|
|
241
|
+
config: EMPTY_CONFIG,
|
|
242
|
+
env: {},
|
|
243
|
+
resolveViaBroker: (async () => {
|
|
244
|
+
brokerCalled = true
|
|
245
|
+
return { ok: false, reason: 'not_found' as const }
|
|
246
|
+
}) as unknown as typeof resolveVaultReferencesViaBroker,
|
|
247
|
+
})
|
|
248
|
+
expect(tok).toBe('raw' + 'token')
|
|
249
|
+
expect(brokerCalled).toBe(false)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('returns null (graceful fallback, no throw) when unresolvable', async () => {
|
|
253
|
+
const tok = await materializeSidecarToken(
|
|
254
|
+
{
|
|
255
|
+
config: EMPTY_CONFIG,
|
|
256
|
+
env: {},
|
|
257
|
+
resolveViaBroker: makeBrokerResolver({}),
|
|
258
|
+
},
|
|
259
|
+
() => {},
|
|
260
|
+
)
|
|
261
|
+
expect(tok).toBeNull()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// ── Regression: single-key isolation (voice token bug) ──
|
|
265
|
+
//
|
|
266
|
+
// resolveVaultReferencesViaBroker fails the WHOLE batch unless EVERY vault:
|
|
267
|
+
// ref resolves. Admin-only refs (litellm/master-key,
|
|
268
|
+
// google/client-secret) are correctly DENIED to a normal agent — so if the
|
|
269
|
+
// materializer passes the full config, those denials sink the resolution
|
|
270
|
+
// even though voice/sidecar-token itself is granted. The fix passes a
|
|
271
|
+
// MINIMAL config carrying only the one ref. These tests fail against the
|
|
272
|
+
// old `...config` spread.
|
|
273
|
+
|
|
274
|
+
function collectRefs(v: unknown, out: Set<string>): void {
|
|
275
|
+
if (typeof v === 'string' && v.startsWith('vault:')) out.add(v.slice('vault:'.length).split('#')[0])
|
|
276
|
+
else if (Array.isArray(v)) for (const i of v) collectRefs(i, out)
|
|
277
|
+
else if (v !== null && typeof v === 'object') for (const val of Object.values(v as Record<string, unknown>)) collectRefs(val, out)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Resolver stub with the real batch semantics: denies unless every collected ref is granted. */
|
|
281
|
+
function makeBatchResolver(
|
|
282
|
+
granted: Record<string, string>,
|
|
283
|
+
onCall?: (refs: Set<string>) => void,
|
|
284
|
+
): typeof resolveVaultReferencesViaBroker {
|
|
285
|
+
return (async (config: SwitchroomConfig) => {
|
|
286
|
+
const refs = new Set<string>()
|
|
287
|
+
collectRefs(config, refs)
|
|
288
|
+
onCall?.(refs)
|
|
289
|
+
for (const r of refs) {
|
|
290
|
+
if (granted[`vault:${r}`] === undefined) {
|
|
291
|
+
return { ok: false, reason: 'denied' as const }
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const resolved = granted[config.telegram?.bot_token ?? '']
|
|
295
|
+
return {
|
|
296
|
+
ok: true,
|
|
297
|
+
config: { ...config, telegram: { ...config.telegram, bot_token: resolved } },
|
|
298
|
+
}
|
|
299
|
+
}) as unknown as typeof resolveVaultReferencesViaBroker
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const CONFIG_WITH_ADMIN_REFS = {
|
|
303
|
+
telegram: { bot_token: 'vault:tg/bot' },
|
|
304
|
+
litellm: { admin_key: 'vault:litellm/master-key' },
|
|
305
|
+
google_workspace: { google_client_secret: 'vault:google/client-secret' },
|
|
306
|
+
agents: { klanker: { some_key: 'vault:per-agent/secret' } },
|
|
307
|
+
vault: { path: '~/.switchroom/vault.enc' },
|
|
308
|
+
} as unknown as SwitchroomConfig
|
|
309
|
+
|
|
310
|
+
it('resolves the sidecar token even when the config carries admin-only vault refs the broker DENIES', async () => {
|
|
311
|
+
const tok = await materializeSidecarToken({
|
|
312
|
+
config: CONFIG_WITH_ADMIN_REFS,
|
|
313
|
+
env: {},
|
|
314
|
+
resolveViaBroker: makeBatchResolver({ 'vault:voice/sidecar-token': 'deadbeefcafe' }),
|
|
315
|
+
})
|
|
316
|
+
expect(tok).toBe('deadbeefcafe')
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('invokes the resolver with a config containing exactly ONE vault ref (the sidecar token)', async () => {
|
|
320
|
+
let seen: Set<string> | null = null
|
|
321
|
+
await materializeSidecarToken({
|
|
322
|
+
config: CONFIG_WITH_ADMIN_REFS,
|
|
323
|
+
env: {},
|
|
324
|
+
resolveViaBroker: makeBatchResolver(
|
|
325
|
+
{ 'vault:voice/sidecar-token': 'deadbeefcafe' },
|
|
326
|
+
(refs) => { seen = refs },
|
|
327
|
+
),
|
|
328
|
+
})
|
|
329
|
+
expect(seen).not.toBeNull()
|
|
330
|
+
expect([...seen!]).toEqual(['voice/sidecar-token'])
|
|
331
|
+
})
|
|
332
|
+
})
|