|
24 | 24 | import { readFileSync, existsSync } from 'node:fs'; |
25 | 25 | import { fileURLToPath } from 'node:url'; |
26 | 26 | import path from 'node:path'; |
27 | | -import { |
28 | | - startCaptureBridge, makeCsrcSink, makeObservationSink, |
29 | | - type CsrcRecord, type CsrcCapableSink, type ObservationRecord, type ObservationCapableSink, |
| 27 | +import type { |
| 28 | + CsrcRecord, CsrcCapableSink, ObservationRecord, ObservationCapableSink, |
30 | 29 | } from './capture-bridge.js'; |
31 | 30 | import type { Invocation } from './config.js'; |
32 | 31 | import type { BotPipeline } from './pipeline.js'; |
33 | 32 |
|
| 33 | +// HERMETIC: capture-bridge resolves VEXA_CSRC_INACTIVE_MS at module load, so an ambient override |
| 34 | +// in the developer's shell would fail the 800ms assertions below. Scrub BEFORE the module loads — |
| 35 | +// which is why this import is dynamic (a static one hoists above the delete). |
| 36 | +delete process.env.VEXA_CSRC_INACTIVE_MS; |
| 37 | +const { startCaptureBridge, makeCsrcSink, makeObservationSink, resolveCsrcInactiveMs } = |
| 38 | + await import('./capture-bridge.js'); |
| 39 | + |
34 | 40 | let failed = 0; |
35 | 41 | const check = (name: string, cond: boolean, detail?: string) => { |
36 | 42 | console.log(` ${cond ? '✅' : '❌'} ${name}${cond || !detail ? '' : ` — ${detail}`}`); |
@@ -87,10 +93,57 @@ const check = (name: string, cond: boolean, detail?: string) => { |
87 | 93 | warnings.some((w) => w.includes('observation-clock-skew')) && stored[2].t >= t, warnings.join(' | ')); |
88 | 94 | } |
89 | 95 |
|
| 96 | +// ── the window is resolved from env, and garbage never reaches the sensor ─────────────────────── |
| 97 | +// The sensor resolves its option with `??`, which passes NaN through, and a NaN window makes BOTH |
| 98 | +// of its comparisons false: every source is re-deactivated on every poll — the fragmentation this |
| 99 | +// value exists to end, worse and silent. So an unusable override falls back rather than propagating. |
| 100 | +{ |
| 101 | + check('window: no override ⇒ the measured 800ms', resolveCsrcInactiveMs(undefined) === 800, |
| 102 | + String(resolveCsrcInactiveMs(undefined))); |
| 103 | + check('window: a usable override wins', resolveCsrcInactiveMs('1200') === 1200, |
| 104 | + String(resolveCsrcInactiveMs('1200'))); |
| 105 | + check('window: garbage falls back instead of becoming NaN', |
| 106 | + resolveCsrcInactiveMs('abc') === 800, String(resolveCsrcInactiveMs('abc'))); |
| 107 | + check('window: zero, negative and empty fall back too — none of them is a window', |
| 108 | + resolveCsrcInactiveMs('0') === 800 && resolveCsrcInactiveMs('-5') === 800 && resolveCsrcInactiveMs('') === 800, |
| 109 | + `${resolveCsrcInactiveMs('0')}/${resolveCsrcInactiveMs('-5')}/${resolveCsrcInactiveMs('')}`); |
| 110 | + // A window shorter than one poll is stale before the next tick — the NaN failure mode wearing an |
| 111 | + // ordinary number. It is rejected at the floor, not accepted because it happens to be positive. |
| 112 | + check('window: shorter than one 100ms poll is rejected, one poll exactly is kept', |
| 113 | + resolveCsrcInactiveMs('1') === 800 && resolveCsrcInactiveMs('99') === 800 && resolveCsrcInactiveMs('100') === 100, |
| 114 | + `${resolveCsrcInactiveMs('1')}/${resolveCsrcInactiveMs('99')}/${resolveCsrcInactiveMs('100')}`); |
| 115 | + // The ceiling is the floor's mirror: a fat-fingered 600000 would hold every source active for |
| 116 | + // ten minutes and synthesize essentially no deactivations — the NaN failure mode wearing an |
| 117 | + // ordinary number, equally silent. 1600ms already merges turns; 10s is generous headroom. |
| 118 | + check('window: a huge override is rejected at the ceiling, ten seconds exactly is kept', |
| 119 | + resolveCsrcInactiveMs('600000') === 800 && resolveCsrcInactiveMs('10001') === 800 |
| 120 | + && resolveCsrcInactiveMs('10000') === 10000, |
| 121 | + `${resolveCsrcInactiveMs('600000')}/${resolveCsrcInactiveMs('10001')}/${resolveCsrcInactiveMs('10000')}`); |
| 122 | + { |
| 123 | + const warnings: string[] = []; |
| 124 | + const used = resolveCsrcInactiveMs('abc', (m) => warnings.push(m)); |
| 125 | + check('window: a rejected override is SAID OUT LOUD, never silently ignored', |
| 126 | + used === 800 && warnings.length === 1 && warnings[0]!.includes('VEXA_CSRC_INACTIVE_MS="abc"'), |
| 127 | + JSON.stringify(warnings)); |
| 128 | + const quiet: string[] = []; |
| 129 | + resolveCsrcInactiveMs(undefined, (m) => quiet.push(m)); |
| 130 | + resolveCsrcInactiveMs('1200', (m) => quiet.push(m)); |
| 131 | + check('window: no override and a good override are both silent — only a REJECTION warns', |
| 132 | + quiet.length === 0, JSON.stringify(quiet)); |
| 133 | + // An empty override is what a half-rendered deploy template looks like. Treating it as "not |
| 134 | + // set" is how a knob gets ignored in silence — the exact failure the warning exists to prevent. |
| 135 | + const blank: string[] = []; |
| 136 | + check('window: an override SET to empty or blank warns too — it is an intent, not an absence', |
| 137 | + resolveCsrcInactiveMs('', (m) => blank.push(m)) === 800 |
| 138 | + && resolveCsrcInactiveMs(' ', (m) => blank.push(m)) === 800 |
| 139 | + && blank.length === 2, JSON.stringify(blank)); |
| 140 | + } |
| 141 | +} |
| 142 | + |
90 | 143 | // ── The real bundle (built by build-browser-utils.mjs — turbo test depends on build) ───────────── |
91 | 144 | const BUNDLE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'browser-utils.global.js'); |
92 | 145 | if (!existsSync(BUNDLE)) { |
93 | | - console.error(`❌ missing ${BUNDLE} — build the capture bundle first (pnpm --filter @vexa/bot build).`); |
| 146 | + console.error(`❌ missing ${BUNDLE} — build the capture bricks first (pnpm --filter @vexa/mixed-capture-core --filter @vexa/bot build).`); |
94 | 147 | process.exit(1); |
95 | 148 | } |
96 | 149 |
|
@@ -138,6 +191,19 @@ const utils = g.VexaBrowserUtils as Record<string, unknown> | undefined; |
138 | 191 | check('bundle: window.VexaBrowserUtils.createCsrcPoll is exported (RED at base — brick not bundled)', |
139 | 192 | typeof utils?.createCsrcPoll === 'function', `keys: ${Object.keys(utils ?? {}).join(',')}`); |
140 | 193 |
|
| 194 | +// Capture the options the REAL bridge hands the REAL sensor factory. The window the production |
| 195 | +// path runs on is not observable from a transition alone (only from WHEN one is absent), and the |
| 196 | +// number itself is owned by the composition root, not by the sensor — so it is asserted here, on |
| 197 | +// the object that actually crosses. RED when the bridge passes no `inactiveMs` at all. |
| 198 | +type CsrcPollOpts = { inactiveMs?: number; pollMs?: number; now?: () => number; timeOrigin?: () => number; |
| 199 | + receivers?: () => unknown[]; onTransition: (t: { csrc: number; active: boolean; tMs: number }) => void }; |
| 200 | +const realCreateCsrcPoll = utils!.createCsrcPoll as (o: CsrcPollOpts) => unknown; |
| 201 | +let productionCsrcOpts: CsrcPollOpts | undefined; |
| 202 | +(utils as Record<string, unknown>).createCsrcPoll = (o: CsrcPollOpts): unknown => { |
| 203 | + productionCsrcOpts = o; |
| 204 | + return realCreateCsrcPoll(o); |
| 205 | +}; |
| 206 | + |
141 | 207 | // ── Fake Playwright Page + the Node seams ─────────────────────────────────────────────────────── |
142 | 208 | const page = { |
143 | 209 | async exposeFunction(name: string, fn: unknown): Promise<void> { g[name] = fn; }, |
@@ -213,8 +279,41 @@ check('the spine and the stored sidecar agree on WHEN each edge happened', |
213 | 279 | check('isolation: NO transition reached pipeline.recordHint — a csrc is an id, never a name', |
214 | 280 | hints.length === 0, JSON.stringify(hints)); |
215 | 281 |
|
| 282 | +// ── the MEASURED inactivity window reaches the sensor ──────────────────────────────────────────── |
| 283 | +// 400 ms — the sensor's own default — is shorter than the median natural speech pause (measured |
| 284 | +// p50 550 ms), so a turn fragments into 1.67 lane activations. The composition root passes the |
| 285 | +// measured 800 ms instead. Asserted twice: the value that crossed, and what that value DOES. |
| 286 | +check('the bridge passes an explicit inactivity window to the sensor (RED at base: undefined)', |
| 287 | + productionCsrcOpts?.inactiveMs === 800, `inactiveMs=${String(productionCsrcOpts?.inactiveMs)}`); |
| 288 | +{ |
| 289 | + // Re-drive the sensor over the PRODUCTION options object — same `inactiveMs`, with only the |
| 290 | + // clock and the receivers replaced, so the window is proven by behaviour and not by a number. |
| 291 | + let t = 1_900_000_000_000; |
| 292 | + const edges: Array<{ active: boolean }> = []; |
| 293 | + let speaking = true; |
| 294 | + let lastSpoke = t; |
| 295 | + const poll = realCreateCsrcPoll({ |
| 296 | + ...productionCsrcOpts!, |
| 297 | + onTransition: (x) => edges.push({ active: x.active }), |
| 298 | + now: () => t, |
| 299 | + timeOrigin: () => 0, |
| 300 | + receivers: () => [{ track: { kind: 'audio' }, |
| 301 | + getContributingSources: () => [{ source: 5, timestamp: speaking ? t : lastSpoke, audioLevel: 0.4 }] }], |
| 302 | + }) as { poll(): void; destroy(): void }; |
| 303 | + poll.poll(); |
| 304 | + speaking = false; |
| 305 | + t += 500; poll.poll(); |
| 306 | + check('a 500ms pause — past the sensor default, inside the measured window — does NOT close the turn', |
| 307 | + edges.length === 1 && edges[0].active === true, JSON.stringify(edges)); |
| 308 | + t += 400; poll.poll(); |
| 309 | + check('past the measured window the deactivation is synthesized', |
| 310 | + edges.length === 2 && edges[1].active === false, JSON.stringify(edges)); |
| 311 | + poll.destroy(); |
| 312 | +} |
| 313 | + |
216 | 314 | (g as any).setInterval = realSetInterval; |
217 | 315 | (g as any).clearInterval = realClearInterval; |
| 316 | +(utils as Record<string, unknown>).createCsrcPoll = realCreateCsrcPoll; |
218 | 317 | g.document = savedDocument; |
219 | 318 |
|
220 | 319 | if (failed) { console.error(`\n❌ csrc-wiring: ${failed} checks FAILED.`); process.exit(1); } |
|
0 commit comments