@openparachute/vault 0.7.9-rc.6 → 0.7.9-rc.8

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.
@@ -1,388 +1,140 @@
1
- /**
2
- * Auto-transcribe gating decisions (vault#353).
3
- *
4
- * Three independent guards: mime-type prefix, enabled toggle, scribe URL
5
- * present. Pure function — exercise the truth table.
6
- */
7
-
8
1
  import { describe, test, expect } from "bun:test";
9
- import {
10
- _resetNoProviderWarnForTest,
11
- classifyAutoTranscribe,
12
- NO_PROVIDER_ERROR,
13
- noProviderErrorFor,
14
- shouldAutoTranscribe,
15
- warnNoTranscriptionProvider,
16
- } from "./auto-transcribe.ts";
17
-
18
- function readGlobalConfig(enabled: boolean | undefined) {
19
- return () => ({
20
- port: 1940,
21
- ...(enabled !== undefined ? { auto_transcribe: { enabled } } : {}),
22
- }) as any;
23
- }
24
-
25
- describe("shouldAutoTranscribe", () => {
26
- const scribePresent = () => "http://127.0.0.1:1943";
27
- const scribeAbsent = () => undefined;
28
-
29
- test("triggers on audio/* mime-type when enabled + scribe reachable", () => {
30
- expect(shouldAutoTranscribe("audio/wav", {
31
- readGlobalConfigImpl: readGlobalConfig(true),
32
- getCachedScribeUrlImpl: scribePresent,
33
- })).toBe(true);
34
- });
35
-
36
- test("triggers on audio/mp4 (m4a)", () => {
37
- expect(shouldAutoTranscribe("audio/mp4", {
38
- readGlobalConfigImpl: readGlobalConfig(true),
39
- getCachedScribeUrlImpl: scribePresent,
40
- })).toBe(true);
41
- });
42
-
43
- test("triggers on audio/webm", () => {
44
- expect(shouldAutoTranscribe("audio/webm", {
45
- readGlobalConfigImpl: readGlobalConfig(true),
46
- getCachedScribeUrlImpl: scribePresent,
47
- })).toBe(true);
48
- });
49
-
50
- test("triggers case-insensitively (AUDIO/WAV)", () => {
51
- expect(shouldAutoTranscribe("AUDIO/WAV", {
52
- readGlobalConfigImpl: readGlobalConfig(true),
53
- getCachedScribeUrlImpl: scribePresent,
54
- })).toBe(true);
55
- });
56
-
57
- test("skips non-audio mime-types (image/png, application/pdf, video/mp4)", () => {
58
- expect(shouldAutoTranscribe("image/png", {
59
- readGlobalConfigImpl: readGlobalConfig(true),
60
- getCachedScribeUrlImpl: scribePresent,
61
- })).toBe(false);
62
- expect(shouldAutoTranscribe("application/pdf", {
63
- readGlobalConfigImpl: readGlobalConfig(true),
64
- getCachedScribeUrlImpl: scribePresent,
65
- })).toBe(false);
66
- expect(shouldAutoTranscribe("video/mp4", {
67
- readGlobalConfigImpl: readGlobalConfig(true),
68
- getCachedScribeUrlImpl: scribePresent,
69
- })).toBe(false);
70
- });
71
-
72
- test("skips when enabled is false (default off)", () => {
73
- expect(shouldAutoTranscribe("audio/wav", {
74
- readGlobalConfigImpl: readGlobalConfig(false),
75
- getCachedScribeUrlImpl: scribePresent,
76
- })).toBe(false);
77
- });
78
-
79
- test("fires when enabled is unset — unset config means ON", () => {
80
- // Default behavior (no `auto_transcribe` block in config) is opt-out:
81
- // once an operator has scribe reachable, audio attachments transcribe
82
- // automatically. Operators wanting it OFF set
83
- // `auto_transcribe.enabled: false` explicitly. Previously default-off;
84
- // flipped to default-on so installing scribe is the only opt-in signal.
85
- expect(shouldAutoTranscribe("audio/wav", {
86
- readGlobalConfigImpl: readGlobalConfig(undefined),
87
- getCachedScribeUrlImpl: scribePresent,
88
- })).toBe(true);
89
- });
90
-
91
- test("skips when scribe URL is undefined (no services.json entry, no env)", () => {
92
- expect(shouldAutoTranscribe("audio/wav", {
93
- readGlobalConfigImpl: readGlobalConfig(true),
94
- getCachedScribeUrlImpl: scribeAbsent,
95
- })).toBe(false);
96
- });
97
-
98
- test("skips when scribe URL is empty string", () => {
99
- expect(shouldAutoTranscribe("audio/wav", {
100
- readGlobalConfigImpl: readGlobalConfig(true),
101
- getCachedScribeUrlImpl: () => "",
102
- })).toBe(false);
103
- });
104
-
105
- test("skips on garbage mime-type input", () => {
106
- expect(shouldAutoTranscribe("", {
107
- readGlobalConfigImpl: readGlobalConfig(true),
108
- getCachedScribeUrlImpl: scribePresent,
109
- })).toBe(false);
110
- expect(shouldAutoTranscribe("not-a-mime", {
111
- readGlobalConfigImpl: readGlobalConfig(true),
112
- getCachedScribeUrlImpl: scribePresent,
113
- })).toBe(false);
114
- });
115
-
116
- test("respects enabledOverride when present", () => {
117
- expect(shouldAutoTranscribe("audio/wav", {
118
- readGlobalConfigImpl: readGlobalConfig(false),
119
- getCachedScribeUrlImpl: scribePresent,
120
- enabledOverride: true,
121
- })).toBe(true);
122
- expect(shouldAutoTranscribe("audio/wav", {
123
- readGlobalConfigImpl: readGlobalConfig(true),
124
- getCachedScribeUrlImpl: scribePresent,
125
- enabledOverride: false,
126
- })).toBe(false);
127
- });
128
-
129
- describe("per-vault precedence (per-vault → global → true)", () => {
130
- test("per-vault true wins even when global is false", () => {
131
- expect(shouldAutoTranscribe("audio/wav", {
132
- readGlobalConfigImpl: readGlobalConfig(false),
133
- getCachedScribeUrlImpl: scribePresent,
134
- perVaultEnabled: true,
135
- })).toBe(true);
136
- });
137
-
138
- test("per-vault false wins even when global is true", () => {
139
- // The whole point: linking scribe to vault X (perVault true) elsewhere
140
- // must not force-on a vault that set its own false.
141
- expect(shouldAutoTranscribe("audio/wav", {
142
- readGlobalConfigImpl: readGlobalConfig(true),
143
- getCachedScribeUrlImpl: scribePresent,
144
- perVaultEnabled: false,
145
- })).toBe(false);
146
- });
147
-
148
- test("per-vault unset falls back to global", () => {
149
- expect(shouldAutoTranscribe("audio/wav", {
150
- readGlobalConfigImpl: readGlobalConfig(true),
151
- getCachedScribeUrlImpl: scribePresent,
152
- perVaultEnabled: undefined,
153
- })).toBe(true);
154
- expect(shouldAutoTranscribe("audio/wav", {
155
- readGlobalConfigImpl: readGlobalConfig(false),
156
- getCachedScribeUrlImpl: scribePresent,
157
- perVaultEnabled: undefined,
158
- })).toBe(false);
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { findModel } from "./transcription/models.ts";
6
+ import { classifyAutoTranscribe, shouldAutoTranscribe, noProviderErrorFor,
7
+ NO_PROVIDER_ERROR, warnNoTranscriptionProvider, _resetNoProviderWarnForTest } from "./auto-transcribe.ts";
8
+ import { getTranscriptionWorker, getTranscriptionWorkerProvider, setTranscriptionWorker } from "./transcription-registry.ts";
9
+
10
+ const config = (enabled?: boolean) => () => ({ port: 1940, auto_transcribe: { enabled } }) as any;
11
+ const ready = () => ({ ready: true, localProvider: "whisper-cpp" });
12
+ const absent = () => ({ ready: false, localProvider: null });
13
+
14
+ describe("automatic transcription eligibility", () => {
15
+ for (const mime of ["audio/wav", "audio/mp4", "audio/webm", "AUDIO/WAV"]) {
16
+ test(`ready worker accepts ${mime}`, () => {
17
+ expect(shouldAutoTranscribe(mime, { readGlobalConfigImpl: config(), providerStateImpl: ready })).toBe(true);
159
18
  });
160
-
161
- test("both per-vault and global unset falls back to true (no regression)", () => {
162
- expect(shouldAutoTranscribe("audio/wav", {
163
- readGlobalConfigImpl: readGlobalConfig(undefined),
164
- getCachedScribeUrlImpl: scribePresent,
165
- perVaultEnabled: undefined,
166
- })).toBe(true);
19
+ }
20
+ for (const mime of ["image/png", "video/mp4", "application/pdf", "", null as any]) {
21
+ test(`non-audio ${mime} never probes readiness`, () => {
22
+ expect(classifyAutoTranscribe(mime, { providerStateImpl: () => { throw Error("unexpected probe"); } }).kind).toBe("not-audio");
167
23
  });
168
-
169
- test("enabledOverride still hard-overrides the per-vault value", () => {
170
- // The explicit caller-opt-in path beats everything.
171
- expect(shouldAutoTranscribe("audio/wav", {
172
- readGlobalConfigImpl: readGlobalConfig(true),
173
- getCachedScribeUrlImpl: scribePresent,
174
- perVaultEnabled: false,
175
- enabledOverride: true,
176
- })).toBe(true);
24
+ }
25
+ for (const global of [undefined, true, false]) {
26
+ for (const vault of [undefined, true, false]) {
27
+ for (const override of [undefined, true, false]) {
28
+ test(`precedence global=${global} vault=${vault} override=${override}`, () => {
29
+ const enabled = override ?? vault ?? global ?? true;
30
+ const d = classifyAutoTranscribe("audio/wav", { readGlobalConfigImpl: config(global),
31
+ perVaultEnabled: vault, enabledOverride: override, providerStateImpl: ready });
32
+ expect(d.kind).toBe(enabled ? "transcribe" : "disabled");
33
+ });
34
+ }
35
+ }
36
+ }
37
+ test("disabled bypasses readiness entirely", () => {
38
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: false,
39
+ providerStateImpl: () => { throw Error("unexpected probe"); } }).kind).toBe("disabled");
40
+ });
41
+ for (const local of [null, "whisper-cpp"]) {
42
+ test(`not ready remains unavailable with installed=${local}`, () => {
43
+ expect(classifyAutoTranscribe("audio/wav", { readGlobalConfigImpl: config(),
44
+ providerStateImpl: () => ({ ready: false, localProvider: local }) })).toEqual({ kind: "unavailable", localProvider: local });
177
45
  });
46
+ }
47
+ test("unavailable is false, not an enqueue", () => {
48
+ expect(shouldAutoTranscribe("audio/wav", { readGlobalConfigImpl: config(), providerStateImpl: absent })).toBe(false);
178
49
  });
179
50
  });
180
51
 
181
- // ---------------------------------------------------------------------------
182
- // vault#643 — "disabled" and "unavailable" are not the same answer.
183
- //
184
- // `shouldAutoTranscribe` collapsed both into `false`, so a box with
185
- // auto-transcribe ON and no reachable provider accepted audio, transcribed
186
- // nothing, wrote no marker, and logged nothing. The attachment was
187
- // indistinguishable from a plain upload.
188
- //
189
- // That is the DEFAULT state of a fresh install: the provider resolves to
190
- // `scribe-http`, nothing sets SCRIBE_URL, and voice memos silently never
191
- // transcribe. The hosted door already marks a terminal state rather than
192
- // skipping quietly (workers/vault/src/vault-do.ts — "voice not enabled for
193
- // this plan" / "monthly voice limit reached"); this is the self-host twin.
194
- // ---------------------------------------------------------------------------
195
- describe("classifyAutoTranscribe (vault#643)", () => {
196
- const audio = "audio/webm";
197
- const withProvider = () => "http://127.0.0.1:1943";
198
- const noProvider = () => undefined;
199
-
200
- test("non-audio → not-audio, whatever else is true", () => {
201
- expect(
202
- classifyAutoTranscribe("image/png", { getCachedScribeUrlImpl: withProvider }).kind,
203
- ).toBe("not-audio");
204
- });
205
-
206
- test("operator turned it off → disabled (silence is CORRECT here)", () => {
207
- expect(
208
- classifyAutoTranscribe(audio, {
209
- perVaultEnabled: false,
210
- getCachedScribeUrlImpl: withProvider,
211
- }).kind,
212
- ).toBe("disabled");
213
- });
214
-
215
- test("enabled + no provider → unavailable, NOT disabled — the whole point", () => {
216
- expect(
217
- classifyAutoTranscribe(audio, {
218
- perVaultEnabled: true,
219
- getCachedScribeUrlImpl: noProvider,
220
- }).kind,
221
- ).toBe("unavailable");
222
- });
223
-
224
- test("a blank provider URL is unavailable, not a provider", () => {
225
- expect(
226
- classifyAutoTranscribe(audio, {
227
- perVaultEnabled: true,
228
- getCachedScribeUrlImpl: () => " ",
229
- }).kind,
230
- ).toBe("unavailable");
231
- });
232
-
233
- test("the default (no toggle set) with no provider is unavailable — the fresh-install case", () => {
234
- expect(
235
- classifyAutoTranscribe(audio, {
236
- readGlobalConfigImpl: (() => ({})) as never,
237
- getCachedScribeUrlImpl: noProvider,
238
- }).kind,
239
- ).toBe("unavailable");
240
- });
241
-
242
- test("enabled + provider → transcribe", () => {
243
- expect(
244
- classifyAutoTranscribe(audio, {
245
- perVaultEnabled: true,
246
- getCachedScribeUrlImpl: withProvider,
247
- }).kind,
248
- ).toBe("transcribe");
249
- });
250
-
251
- test("shouldAutoTranscribe stays a faithful boolean view of the classifier", () => {
252
- for (const opts of [
253
- { perVaultEnabled: false, getCachedScribeUrlImpl: withProvider },
254
- { perVaultEnabled: true, getCachedScribeUrlImpl: noProvider },
255
- { perVaultEnabled: true, getCachedScribeUrlImpl: withProvider },
256
- ]) {
257
- expect(shouldAutoTranscribe(audio, opts)).toBe(
258
- classifyAutoTranscribe(audio, opts).kind === "transcribe",
259
- );
260
- }
261
- });
52
+ test("real gate requires a matching registered worker; stale URL cannot enable a missing local worker", () => {
53
+ const previous = { provider: process.env.TRANSCRIPTION_PROVIDER, url: process.env.SCRIBE_URL };
54
+ const worker = getTranscriptionWorker();
55
+ const provider = getTranscriptionWorkerProvider();
56
+ const fake = { stop: async () => {}, tick: async () => 0, kick: async () => {} };
57
+ try {
58
+ process.env.TRANSCRIPTION_PROVIDER = "transcribe-cpp";
59
+ process.env.SCRIBE_URL = "http://stale.invalid";
60
+ setTranscriptionWorker(null);
61
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("unavailable");
62
+ setTranscriptionWorker(fake, "scribe-http");
63
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("unavailable");
64
+ setTranscriptionWorker(fake, "transcribe-cpp");
65
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("transcribe");
66
+ process.env.TRANSCRIPTION_PROVIDER = "scribe-http";
67
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("unavailable");
68
+ setTranscriptionWorker(fake, "scribe-http");
69
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("transcribe");
70
+ } finally {
71
+ setTranscriptionWorker(worker, provider);
72
+ if (previous.provider === undefined) delete process.env.TRANSCRIPTION_PROVIDER;
73
+ else process.env.TRANSCRIPTION_PROVIDER = previous.provider;
74
+ if (previous.url === undefined) delete process.env.SCRIBE_URL;
75
+ else process.env.SCRIBE_URL = previous.url;
76
+ }
262
77
  });
263
78
 
264
- describe("warnNoTranscriptionProvider throttle (vault#643)", () => {
265
- test("warns once per window — a bulk import gets one line, not a hundred", () => {
266
- _resetNoProviderWarnForTest();
267
- const seen: string[] = [];
268
- const orig = console.warn;
269
- console.warn = (...args: unknown[]) => seen.push(String(args[0]));
270
- try {
271
- let now = 1_000_000;
272
- const clock = () => now;
273
- for (let i = 0; i < 50; i++) warnNoTranscriptionProvider(clock);
274
- expect(seen.length).toBe(1);
275
- expect(seen[0]).toMatch(/no transcription provider configured/i);
276
- // Past the window → one more.
277
- now += 61_000;
278
- warnNoTranscriptionProvider(clock);
279
- expect(seen.length).toBe(2);
280
- } finally {
281
- console.warn = orig;
282
- _resetNoProviderWarnForTest();
283
- }
284
- });
79
+ test("diagnostics distinguish installed from ready without reviving Scribe", () => {
80
+ expect(noProviderErrorFor(null)).toBe(NO_PROVIDER_ERROR);
81
+ expect(NO_PROVIDER_ERROR).toContain("transcription status");
82
+ expect(NO_PROVIDER_ERROR).toContain("standalone Scribe service is retired");
83
+ const message = noProviderErrorFor("whisper-cpp");
84
+ expect(message).toContain("binary is installed");
85
+ expect(message).toContain("not active and ready");
86
+ expect(message).toContain("transcription status");
87
+ expect(message).not.toContain("point SCRIBE_URL");
88
+ expect(message).not.toContain("resolves only a scribe URL");
285
89
  });
286
90
 
287
- /**
288
- * The `unavailable` error must be TRUE, not merely actionable.
289
- *
290
- * Found live: a box with `TRANSCRIPTION_PROVIDER=whisper-cpp`, `parakeet-cli`
291
- * on PATH, the model downloaded, and `[transcribe] worker started →
292
- * whisper-cpp` in the same boot log was writing "no transcription provider
293
- * configured — set TRANSCRIPTION_PROVIDER to a local provider" onto its
294
- * attachments. Every clause of that is false there, and the fix it prescribes
295
- * is a 400 MB re-download that changes nothing.
296
- *
297
- * The decision is deliberately unchanged — `unavailable` stays `unavailable`,
298
- * because this path resolves a scribe URL and nothing else. Only the sentence
299
- * moves.
300
- */
301
- describe("the unavailable message tells the truth about the local install", () => {
302
- const audio = "audio/webm";
303
- const noScribe = () => undefined;
304
-
305
- test("nothing runnable locally → the original message, unchanged", () => {
306
- // The fresh-install case the old string was written for, and it is still
307
- // exactly right there. Byte-identical so a fresh install sees no churn.
308
- const d = classifyAutoTranscribe(audio, {
309
- perVaultEnabled: true,
310
- getCachedScribeUrlImpl: noScribe,
311
- localProviderImpl: () => null,
312
- });
313
- expect(d.kind).toBe("unavailable");
314
- expect(noProviderErrorFor(d.kind === "unavailable" ? d.localProvider : null)).toBe(
315
- NO_PROVIDER_ERROR,
316
- );
317
- });
318
-
319
- test("a runnable local provider → a message that does not contradict the box", () => {
320
- const d = classifyAutoTranscribe(audio, {
321
- perVaultEnabled: true,
322
- getCachedScribeUrlImpl: noScribe,
323
- localProviderImpl: () => "whisper-cpp",
324
- });
325
- expect(d.kind).toBe("unavailable");
326
- const msg = noProviderErrorFor(d.kind === "unavailable" ? d.localProvider : null);
327
-
328
- // The three false claims the old string made on such a box.
329
- expect(msg).not.toContain("no transcription provider configured");
330
- expect(msg).not.toContain("set TRANSCRIPTION_PROVIDER to a local provider");
331
- expect(msg).not.toContain("transcription install");
332
- // And what it must say instead: the local install is fine, this path just
333
- // doesn't read it, and don't go reinstalling anything.
334
- expect(msg).toContain("whisper-cpp");
335
- expect(msg).toContain("SCRIBE_URL");
336
- expect(msg).toMatch(/does not consult it/);
337
- expect(msg).toMatch(/Reinstalling whisper-cpp will not change this/);
338
- });
339
-
340
- test("the decision itself is untouched — this diff moves no behaviour", () => {
341
- // Same inputs, both local-provider states: still `unavailable` either way.
342
- // If this ever diverges, the change stopped being diagnostic-only and
343
- // started deciding what gets transcribed.
344
- for (const local of [null, "whisper-cpp"]) {
345
- expect(
346
- classifyAutoTranscribe(audio, {
347
- perVaultEnabled: true,
348
- getCachedScribeUrlImpl: noScribe,
349
- localProviderImpl: () => local,
350
- }).kind,
351
- ).toBe("unavailable");
91
+ test("whisper-cpp real readiness requires model plus matching active worker, without Scribe", () => {
92
+ const keys = ["PARACHUTE_HOME", "WHISPER_CPP_BIN_DIR", "TRANSCRIPTION_PROVIDER", "TRANSCRIPTION_MODEL", "SCRIBE_URL"] as const;
93
+ const original = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
94
+ const home = mkdtempSync(join(tmpdir(), "auto-whisper-"));
95
+ const oldWorker = getTranscriptionWorker();
96
+ const oldProvider = getTranscriptionWorkerProvider();
97
+ try {
98
+ process.env.PARACHUTE_HOME = home;
99
+ process.env.WHISPER_CPP_BIN_DIR = join(home, "bin");
100
+ process.env.TRANSCRIPTION_PROVIDER = "whisper-cpp";
101
+ process.env.TRANSCRIPTION_MODEL = "whisper-base.en";
102
+ delete process.env.SCRIBE_URL;
103
+ mkdirSync(join(home, "bin"));
104
+ // Snapshot checks presence only; these are never executed.
105
+ writeFileSync(join(home, "bin", "whisper-cli"), "fixture");
106
+ writeFileSync(join(home, "bin", "ffmpeg"), "fixture");
107
+ const worker = { stop: async () => {}, tick: async () => 0, kick: async () => {} };
108
+ setTranscriptionWorker(worker, "whisper-cpp");
109
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("unavailable");
110
+ const models = join(home, "transcription", "models");
111
+ mkdirSync(models, { recursive: true });
112
+ writeFileSync(join(models, findModel("whisper-base.en")!.filename), "fixture");
113
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("transcribe");
114
+ setTranscriptionWorker(null);
115
+ expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("unavailable");
116
+ } finally {
117
+ setTranscriptionWorker(oldWorker, oldProvider);
118
+ for (const key of keys) {
119
+ if (original[key] === undefined) delete process.env[key]; else process.env[key] = original[key];
352
120
  }
353
- // And a reachable scribe still wins outright, local install or not.
354
- expect(
355
- classifyAutoTranscribe(audio, {
356
- perVaultEnabled: true,
357
- getCachedScribeUrlImpl: () => "http://127.0.0.1:1943",
358
- localProviderImpl: () => "whisper-cpp",
359
- }).kind,
360
- ).toBe("transcribe");
361
- });
362
-
363
- test("an installed provider with ffmpeg missing STILL gets the honest message", () => {
364
- // The trap that cost a rewrite: `snap.ready` also demands ffmpeg, so it
365
- // varies box to box. A machine with the provider installed and ffmpeg
366
- // missing is still one where "no transcription provider configured" is
367
- // false, so the discriminator is INSTALLED, not READY — it answers the
368
- // question the sentence asks, identically everywhere.
369
- const msg = noProviderErrorFor("whisper-cpp");
370
- expect(msg).not.toBe(NO_PROVIDER_ERROR);
371
- // And it must not over-claim in the other direction: with ffmpeg missing
372
- // the install is present but not necessarily working, so the message says
373
- // requests are ROUTED to it and points at status for the real state.
374
- expect(msg).toMatch(/routed to it/);
375
- expect(msg).toContain("transcription status");
376
- expect(msg).not.toMatch(/is fine/);
377
- });
121
+ rmSync(home, { recursive: true, force: true });
122
+ }
123
+ });
378
124
 
379
- test("the provider NAME is not the discriminator — the fresh-install trap", () => {
380
- // `resolveTranscriptionProviderName` returns "whisper-cpp" on a box where
381
- // nothing is set up at all, because that is the default when no scribe URL
382
- // resolves. Keying off the name would tell a fresh-install operator their
383
- // whisper-cpp install is fine when they have never installed one. The
384
- // discriminator is RUNNABILITY, and null must produce the old message.
385
- expect(noProviderErrorFor(null)).toBe(NO_PROVIDER_ERROR);
386
- expect(noProviderErrorFor("whisper-cpp")).not.toBe(NO_PROVIDER_ERROR);
387
- });
125
+ test("missing-provider warning is throttled and resets", () => {
126
+ const previous = console.warn;
127
+ const messages: unknown[] = [];
128
+ console.warn = (...args) => { messages.push(args); };
129
+ try {
130
+ _resetNoProviderWarnForTest();
131
+ warnNoTranscriptionProvider(() => 60_000);
132
+ warnNoTranscriptionProvider(() => 60_001);
133
+ expect(messages).toHaveLength(1);
134
+ warnNoTranscriptionProvider(() => 120_000);
135
+ expect(messages).toHaveLength(2);
136
+ _resetNoProviderWarnForTest();
137
+ warnNoTranscriptionProvider(() => 120_001);
138
+ expect(messages).toHaveLength(3);
139
+ } finally { console.warn = previous; _resetNoProviderWarnForTest(); }
388
140
  });