From 878f19d4c35afcd81f44ce5dc678a4eea242d79b Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 19 Oct 2026 08:08:52 +0200 Subject: [PATCH 1/2] test(transcription): answer OpenAI through requestUrl The transcription tests now answer OpenAI through Obsidian's requestUrl and reject fetch the way Chromium does when OpenAI's response has no Access-Control-Allow-Origin header for app://obsidian.md. A plain transcription that fails on every chunk must show the last API error, and requestWithTimeout must accept the Set-Cookie array that the desktop requestUrl returns. The e2e journeys stub OpenAI on the request-url IPC channel instead of fetch. These tests fail until the next commit. --- .../TranscriptionService.published.test.ts | 16 ++-- src/services/TranscriptionService.test.ts | 73 +++++++++---------- .../diarization/openaiProvider.test.ts | 48 ++++++------ src/services/openaiTranscription.test.ts | 67 +++++++---------- src/utility/networkRequest.test.ts | 19 +++++ tests/e2e/journey/fixtures.ts | 38 +++++----- tests/mocks/openaiRequestUrl.ts | 31 ++++++++ tests/mocks/transcriptionService.ts | 28 ++++--- 8 files changed, 181 insertions(+), 139 deletions(-) create mode 100644 tests/mocks/openaiRequestUrl.ts diff --git a/src/services/TranscriptionService.published.test.ts b/src/services/TranscriptionService.published.test.ts index abf3ca07..13c44696 100644 --- a/src/services/TranscriptionService.published.test.ts +++ b/src/services/TranscriptionService.published.test.ts @@ -9,17 +9,17 @@ import { deferred } from "../../tests/mocks/feeds"; import { createMockPlugin, diarizeWithDeepgramMock, - fetchMock, getEpisodeAudioBufferMock, installTranscriptionMocks, mockEpisode, noticeMessages, } from "../../tests/mocks/transcriptionService"; +import { openAIMock } from "../../tests/mocks/openaiRequestUrl"; import { TranscriptionService } from "./TranscriptionService"; vi.mock("obsidian", async (importOriginal) => { - const { recordingNotices } = await import("../../tests/mocks/transcriptionService"); - return recordingNotices(await importOriginal()); + const { mockedObsidian } = await import("../../tests/mocks/transcriptionService"); + return mockedObsidian(await importOriginal()); }); const fetchTextMock = vi.hoisted(() => vi.fn()); @@ -116,7 +116,7 @@ describe("published transcripts", () => { "Preparing to transcribe...", ]); expect(getEpisodeAudioBufferMock).not.toHaveBeenCalled(); - expect(fetchMock).not.toHaveBeenCalled(); + expect(openAIMock).not.toHaveBeenCalled(); expect(diarizeWithDeepgramMock).not.toHaveBeenCalled(); }); @@ -130,7 +130,7 @@ describe("published transcripts", () => { expect(vi.mocked(plugin.app.vault.create).mock.calls[0][1]).toBe(publishedNote); expect(getEpisodeAudioBufferMock).not.toHaveBeenCalled(); - expect(fetchMock).not.toHaveBeenCalled(); + expect(openAIMock).not.toHaveBeenCalled(); expect(diarizeWithDeepgramMock).not.toHaveBeenCalled(); }); @@ -166,7 +166,7 @@ describe("published transcripts", () => { expect(noticeMessages).toEqual(["No published transcript was found for this episode."]); expect(getEpisodeAudioBufferMock).not.toHaveBeenCalled(); - expect(fetchMock).not.toHaveBeenCalled(); + expect(openAIMock).not.toHaveBeenCalled(); }); test("links of unknown types keep today's key notice", async () => { @@ -197,7 +197,7 @@ describe("published transcripts", () => { expect(plugin.app.vault.create).not.toHaveBeenCalled(); expect(getEpisodeAudioBufferMock).not.toHaveBeenCalled(); - expect(fetchMock).not.toHaveBeenCalled(); + expect(openAIMock).not.toHaveBeenCalled(); }); test("the offer names Deepgram when diarization uses it", async () => { @@ -235,7 +235,7 @@ describe("published transcripts", () => { }); test("transcribing from audio never looks for a published transcript", async () => { - fetchMock.mockImplementation(async () => Response.json({ text: "Spoken words." })); + openAIMock.mockImplementation(async () => Response.json({ text: "Spoken words." })); const plugin = createMockPlugin({ transcripts: published }); const service = new TranscriptionService(plugin); diff --git a/src/services/TranscriptionService.test.ts b/src/services/TranscriptionService.test.ts index 1898b28f..cd1431f4 100644 --- a/src/services/TranscriptionService.test.ts +++ b/src/services/TranscriptionService.test.ts @@ -1,24 +1,24 @@ // @vitest-environment jsdom import { describe, expect, test, vi } from "vitest"; -import { Notice } from "obsidian"; +import { Notice, type RequestUrlParam } from "obsidian"; import { deferred } from "../../tests/mocks/feeds"; import { OPENAI_FAILURES, rateLimited } from "../../tests/mocks/openaiFailures"; import { createMockPlugin, diarizeWithDeepgramMock, - fetchMock, getEpisodeAudioBufferMock, installTranscriptionMocks, mockEpisode, noticeMessages, } from "../../tests/mocks/transcriptionService"; +import { openAIMock, sentForm } from "../../tests/mocks/openaiRequestUrl"; import { TranscriptionService } from "./TranscriptionService"; import type PodNotes from "src/main"; import type { Episode } from "src/types/Episode"; vi.mock("obsidian", async (importOriginal) => { - const { recordingNotices } = await import("../../tests/mocks/transcriptionService"); - return recordingNotices(await importOriginal()); + const { mockedObsidian } = await import("../../tests/mocks/transcriptionService"); + return mockedObsidian(await importOriginal()); }); async function settlesAfterMicrotasks(promise: Promise): Promise { @@ -53,9 +53,10 @@ type AudioJob = { episode: Episode; source: { kind: "audio" } }; const audioJob: AudioJob = { episode: mockEpisode, source: { kind: "audio" } }; const whisperReturns = (text: string) => - fetchMock.mockImplementation(async () => Response.json({ text })); + openAIMock.mockImplementation(async () => Response.json({ text })); -const sentFile = (init?: RequestInit) => (init?.body as FormData | undefined)?.get("file") as File; +// Vitest's jsdom Request names every multipart file "blob", so these tests tell chunks apart by size. +const sentFile = async (request: RequestUrlParam) => (await sentForm(request)).get("file") as File; describe("TranscriptionService", () => { describe("transcribeCurrentEpisode validation", () => { @@ -142,7 +143,7 @@ describe("TranscriptionService", () => { const plugin = createMockPlugin(); const service = new TranscriptionService(plugin); let finishRequest!: (response: Response) => void; - fetchMock.mockImplementation( + openAIMock.mockImplementation( () => new Promise((resolve) => { finishRequest = resolve; @@ -155,14 +156,13 @@ describe("TranscriptionService", () => { transcribeEpisode: (job: AudioJob) => Promise; } ).transcribeEpisode(audioJob); - await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(openAIMock).toHaveBeenCalledOnce()); const messagesBeforeDispose = setMessage.mock.calls.length; service.dispose(); finishRequest(Response.json({ text: "This must not be saved." })); await pending; - expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true); expect(plugin.app.vault.create).not.toHaveBeenCalled(); expect(setMessage).toHaveBeenCalledTimes(messagesBeforeDispose); } finally { @@ -382,12 +382,12 @@ describe("TranscriptionService", () => { }); test("throws (no file) when the single chunk fails every retry", async () => { - fetchMock.mockImplementation(async () => new Response("boom", { status: 500 })); + openAIMock.mockImplementation(async () => new Response("boom", { status: 500 })); vi.useFakeTimers(); try { const promise = buildBodyDirect(createMockPlugin(), mp3Audio(1024)); const assertion = expect(promise).rejects.toThrow( - "Transcription failed: all 1 audio chunk(s) failed or returned no text.", + "all 1 audio chunk(s) failed or returned no text. Last error: 500 boom", ); await vi.runAllTimersAsync(); await assertion; @@ -400,8 +400,8 @@ describe("TranscriptionService", () => { // >20 MB mp3 → two chunks. chunk 0 fails every retry; chunk 1 "succeeds" // but returns empty text. The body is then only an error marker, which // must NOT be saved as a completed transcript. - fetchMock.mockImplementation(async (_url, init) => - sentFile(init).name.includes("part0") + openAIMock.mockImplementation(async (request) => + (await sentFile(request)).size > 1024 ? new Response("boom", { status: 500 }) : Response.json({ text: " " }), ); @@ -413,7 +413,7 @@ describe("TranscriptionService", () => { mp3Audio(20 * 1024 * 1024 + 1024), ); const assertion = expect(promise).rejects.toThrow( - "Transcription failed: all 2 audio chunk(s) failed or returned no text.", + "all 2 audio chunk(s) failed or returned no text. Last error: 500 boom", ); await vi.runAllTimersAsync(); await assertion; @@ -423,7 +423,7 @@ describe("TranscriptionService", () => { }); test("does not write a file when transcription fails completely", async () => { - fetchMock.mockImplementation(async () => new Response("boom", { status: 500 })); + openAIMock.mockImplementation(async () => new Response("boom", { status: 500 })); const plugin = createMockPlugin(); const service = new TranscriptionService(plugin); @@ -446,8 +446,8 @@ describe("TranscriptionService", () => { test("keeps an otherwise-good transcript but warns when only some chunks fail", async () => { // A >20 MB mp3 byte-splits into two chunks; fail the second one. - fetchMock.mockImplementation(async (_url, init) => - sentFile(init).name.includes("part1") + openAIMock.mockImplementation(async (request) => + (await sentFile(request)).size === 1024 ? new Response("boom", { status: 500 }) : Response.json({ text: "Good chunk." }), ); @@ -501,42 +501,41 @@ describe("TranscriptionService", () => { await transcribe(createMockPlugin()); - expect(fetchMock).toHaveBeenCalledOnce(); - const form = fetchMock.mock.calls[0][1]?.body as FormData; + expect(openAIMock).toHaveBeenCalledOnce(); + const form = await sentForm(openAIMock.mock.calls[0][0]); expect([...form.keys()]).toEqual(["model", "file"]); expect(form.get("model")).toBe("whisper-1"); - const file = sentFile(fetchMock.mock.calls[0][1]); - expect(file.name).toBe("episode.mp3"); + const file = form.get("file") as File; expect(file.type).toBe("audio/mp3"); expect(file.size).toBe(1024); }); test.each(OPENAI_FAILURES)( - "Whisper %s fails the run after three sends", - async (_name, respond) => { - fetchMock.mockImplementation(respond); + "Whisper %s shows the API error after three sends", + async (_name, respond, message) => { + openAIMock.mockImplementation(respond); await expect(finalNotice(createMockPlugin())).resolves.toBe( - "Transcription failed: Transcription failed: all 1 audio chunk(s) failed or returned no text.", + `Transcription failed: all 1 audio chunk(s) failed or returned no text. Last error: ${message}`, ); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(openAIMock).toHaveBeenCalledTimes(3); }, ); test("Whisper answering without text fails the run after three sends", async () => { - fetchMock.mockImplementation(async () => Response.json({})); + openAIMock.mockImplementation(async () => Response.json({})); await expect(finalNotice(createMockPlugin())).resolves.toBe( - "Transcription failed: Transcription failed: all 1 audio chunk(s) failed or returned no text.", + "Transcription failed: all 1 audio chunk(s) failed or returned no text. Last error: OpenAI returned a transcription without text.", ); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(openAIMock).toHaveBeenCalledTimes(3); }); test("Whisper waits out the server's Retry-After and recovers", async () => { vi.useFakeTimers(); try { const sentAt: number[] = []; - fetchMock.mockImplementation(async () => { + openAIMock.mockImplementation(async () => { sentAt.push(Date.now()); return Date.now() - sentAt[0] < 4000 ? rateLimited({ "retry-after": "4" }) @@ -560,7 +559,7 @@ describe("TranscriptionService", () => { vi.useFakeTimers(); const setMessage = vi.spyOn(Notice.prototype, "setMessage"); try { - fetchMock.mockImplementation(async () => rateLimited({ "retry-after": "30" })); + openAIMock.mockImplementation(async () => rateLimited({ "retry-after": "30" })); const plugin = createMockPlugin(); const service = new TranscriptionService(plugin); let settled = false; @@ -574,7 +573,7 @@ describe("TranscriptionService", () => { settled = true; }); await vi.advanceTimersByTimeAsync(29_000); - expect(fetchMock).toHaveBeenCalledOnce(); + expect(openAIMock).toHaveBeenCalledOnce(); const messagesBeforeDispose = setMessage.mock.calls.length; service.dispose(); @@ -582,7 +581,7 @@ describe("TranscriptionService", () => { expect(settled).toBe(true); await pending; - expect(fetchMock).toHaveBeenCalledOnce(); + expect(openAIMock).toHaveBeenCalledOnce(); expect(plugin.app.vault.create).not.toHaveBeenCalled(); expect(setMessage).toHaveBeenCalledTimes(messagesBeforeDispose); } finally { @@ -605,8 +604,8 @@ describe("TranscriptionService", () => { await service.transcribeEpisode(audioJob); const authorization = (call: number) => - new Headers(fetchMock.mock.calls[call][1]?.headers).get("authorization"); - expect(fetchMock).toHaveBeenCalledTimes(2); + openAIMock.mock.calls[call][0].headers?.Authorization; + expect(openAIMock).toHaveBeenCalledTimes(2); expect(authorization(0)).toBe("Bearer first-key"); expect(authorization(1)).toBe("Bearer second-key"); }); @@ -614,7 +613,7 @@ describe("TranscriptionService", () => { test.each(OPENAI_FAILURES)( "OpenAI diarization %s shows the API error after three sends", async (_name, respond, message) => { - fetchMock.mockImplementation(respond); + openAIMock.mockImplementation(respond); const plugin = createMockPlugin(); plugin.settings.transcript.diarization = { enabled: true, @@ -625,7 +624,7 @@ describe("TranscriptionService", () => { await expect(finalNotice(plugin)).resolves.toBe( `Transcription failed: OpenAI diarization failed for every chunk: ${message}`, ); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(openAIMock).toHaveBeenCalledTimes(3); }, ); }); diff --git a/src/services/diarization/openaiProvider.test.ts b/src/services/diarization/openaiProvider.test.ts index 43503e2d..58c74ca8 100644 --- a/src/services/diarization/openaiProvider.test.ts +++ b/src/services/diarization/openaiProvider.test.ts @@ -1,8 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { rateLimited } from "../../../tests/mocks/openaiFailures"; +import { corsBlockedFetch, openAIMock, sentForm } from "../../../tests/mocks/openaiRequestUrl"; import { diarizeWithOpenAI } from "./openaiProvider"; -const fetchMock = vi.fn(); +vi.mock("obsidian", async (importOriginal) => ({ + ...(await importOriginal()), + requestUrl: (await import("../../../tests/mocks/openaiRequestUrl")).openAIRequestUrl, +})); function chunk(name: string): File { return new File([new ArrayBuffer(8)], name, { type: "audio/mpeg" }); @@ -13,10 +17,10 @@ const diarized = (speaker: string, text: string) => async () => segments: [{ type: "transcript.text.segment", id: "1", speaker, start: 0, end: 1, text }], }); -const sentForm = (call: number) => fetchMock.mock.calls[call][1]?.body as FormData; +const sentFormOf = (call: number) => sentForm(openAIMock.mock.calls[call][0]); beforeEach(() => { - vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("fetch", corsBlockedFetch); // In Obsidian's renderer `window` is the global object; the code under test uses its timers. vi.stubGlobal("window", globalThis); }); @@ -24,12 +28,12 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); - fetchMock.mockReset(); + openAIMock.mockReset(); }); describe("diarizeWithOpenAI (#168)", () => { it("concatenates parsed segments across chunks in order", async () => { - fetchMock + openAIMock .mockImplementationOnce(diarized("A", "First.")) .mockImplementationOnce(diarized("B", "Second.")); const chunks = [chunk("a.mp3"), chunk("b.mp3")]; @@ -46,18 +50,18 @@ describe("diarizeWithOpenAI (#168)", () => { { speaker: "A", text: "First.", start: 0, end: 1 }, { speaker: "B", text: "Second.", start: 0, end: 1 }, ]); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect([...sentForm(0).entries()]).toEqual([ + expect(openAIMock).toHaveBeenCalledTimes(2); + expect([...(await sentFormOf(0)).entries()]).toEqual([ ["model", "gpt-4o-transcribe-diarize"], ["file", expect.objectContaining({ name: "a.mp3" })], ["response_format", "diarized_json"], ["chunking_strategy", "auto"], ]); - expect((sentForm(1).get("file") as File).name).toBe("b.mp3"); + expect(((await sentFormOf(1)).get("file") as File).name).toBe("b.mp3"); }); it("keeps a partial transcript with a marker when only some chunks fail", async () => { - fetchMock + openAIMock .mockImplementationOnce(diarized("A", "Good chunk.")) .mockImplementation(async () => new Response("Bad Gateway", { status: 502 })); @@ -76,7 +80,7 @@ describe("diarizeWithOpenAI (#168)", () => { }); it("throws (writes no transcript) when every chunk fails", async () => { - fetchMock.mockImplementation(async () => + openAIMock.mockImplementation(async () => Response.json( { error: { message: "Incorrect API key provided: sk-test." } }, { status: 401 }, @@ -101,7 +105,7 @@ describe("diarizeWithOpenAI (#168)", () => { it("waits out the server's Retry-After and recovers the chunk", async () => { vi.useFakeTimers(); const sentAt: number[] = []; - fetchMock.mockImplementation(async () => { + openAIMock.mockImplementation(async () => { sentAt.push(Date.now()); return Date.now() - sentAt[0] < 4000 ? rateLimited({ "retry-after": "4" }) @@ -125,7 +129,7 @@ describe("diarizeWithOpenAI (#168)", () => { vi.useFakeTimers(); const controller = new AbortController(); const abortReason = new Error("plugin unloaded"); - fetchMock.mockImplementation(async () => rateLimited({ "retry-after": "30" })); + openAIMock.mockImplementation(async () => rateLimited({ "retry-after": "30" })); let outcome: unknown = "pending"; void diarizeWithOpenAI({ @@ -143,26 +147,19 @@ describe("diarizeWithOpenAI (#168)", () => { }, ); await vi.advanceTimersByTimeAsync(29_000); - expect(fetchMock).toHaveBeenCalledOnce(); + expect(openAIMock).toHaveBeenCalledOnce(); controller.abort(abortReason); await vi.advanceTimersByTimeAsync(0); expect(outcome).toBe(abortReason); - expect(fetchMock).toHaveBeenCalledOnce(); + expect(openAIMock).toHaveBeenCalledOnce(); }); - it("aborts an in-flight request without retrying or logging a failure", async () => { + it("stops waiting for an in-flight request on abort without retrying or logging a failure", async () => { const controller = new AbortController(); const abortError = new DOMException("plugin unloaded", "AbortError"); - fetchMock.mockImplementation( - (_url, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { - once: true, - }); - }), - ); + openAIMock.mockImplementation(() => new Promise(() => {})); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); try { @@ -173,13 +170,12 @@ describe("diarizeWithOpenAI (#168)", () => { onProgress: () => {}, signal: controller.signal, }); - await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(openAIMock).toHaveBeenCalledOnce()); controller.abort(abortError); await expect(pending).rejects.toBe(abortError); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true); + expect(openAIMock).toHaveBeenCalledTimes(1); expect(consoleError).not.toHaveBeenCalled(); } finally { consoleError.mockRestore(); diff --git a/src/services/openaiTranscription.test.ts b/src/services/openaiTranscription.test.ts index 1f50197b..fb168ed7 100644 --- a/src/services/openaiTranscription.test.ts +++ b/src/services/openaiTranscription.test.ts @@ -1,13 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { OPENAI_FAILURES, rateLimited } from "../../tests/mocks/openaiFailures"; +import { corsBlockedFetch, openAIMock, sentForm } from "../../tests/mocks/openaiRequestUrl"; import { createTranscription, retryDelayMs } from "./openaiTranscription"; -const fetchMock = vi.fn(); +vi.mock("obsidian", async (importOriginal) => ({ + ...(await importOriginal()), + requestUrl: (await import("../../tests/mocks/openaiRequestUrl")).openAIRequestUrl, +})); -const pendingUntilAborted: typeof fetch = (_url, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); - }); +const neverAnswers = () => new Promise(() => {}); const send = (signal = new AbortController().signal) => createTranscription("sk-test", { model: "whisper-1" }, signal); @@ -34,20 +35,10 @@ const EDGE_FAILURES: typeof OPENAI_FAILURES = [ ["500 error is a string", status500('{"error":"busy"}'), '500 "busy"'], ["500 JSON number", status500("42"), "500 status code (no body)"], ["500 JSON null", status500("null"), "500 null"], - [ - "transport timeout message", - () => Promise.reject(new TypeError("The request timed out.")), - "Request timed out.", - ], - [ - "transport WebKit load failed", - () => Promise.reject(new TypeError("Load failed")), - "Connection error.", - ], ]; beforeEach(() => { - vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("fetch", corsBlockedFetch); // In Obsidian's renderer `window` is the global object; the code under test uses its timers. vi.stubGlobal("window", globalThis); }); @@ -55,12 +46,12 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); - fetchMock.mockReset(); + openAIMock.mockReset(); }); describe("createTranscription", () => { it("posts multipart form data that parses back to the same fields and file bytes", async () => { - fetchMock.mockImplementation(async () => Response.json({ text: "Hi." })); + openAIMock.mockImplementation(async () => Response.json({ text: "Hi." })); const bytes = new Uint8Array(70_000).map((_, i) => (i * 31) % 256); const file = new File([bytes], "episode.mp3", { type: "audio/mp3" }); @@ -75,16 +66,13 @@ describe("createTranscription", () => { new AbortController().signal, ); - expect(fetchMock).toHaveBeenCalledOnce(); - const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe("https://api.openai.com/v1/audio/transcriptions"); - expect(init?.method).toBe("POST"); - expect(Object.fromEntries(new Headers(init?.headers))).toEqual({ - authorization: "Bearer sk-test", - }); - const encoded = new Response(init?.body); - expect(encoded.headers.get("content-type")).toMatch(/^multipart\/form-data; boundary=/); - const form = await encoded.formData(); + expect(openAIMock).toHaveBeenCalledOnce(); + const [request] = openAIMock.mock.calls[0]; + expect(request.url).toBe("https://api.openai.com/v1/audio/transcriptions"); + expect(request.method).toBe("POST"); + expect(request.headers).toEqual({ Authorization: "Bearer sk-test" }); + expect(request.contentType).toMatch(/^multipart\/form-data; boundary=/); + const form = await sentForm(request); expect([...form.keys()]).toEqual(["model", "file", "response_format", "chunking_strategy"]); expect(form.get("model")).toBe("gpt-4o-transcribe-diarize"); expect(form.get("response_format")).toBe("diarized_json"); @@ -100,7 +88,7 @@ describe("createTranscription", () => { text: "Hi.", segments: [{ type: "transcript.text.segment", speaker: "A", start: 0, end: 1 }], }; - fetchMock.mockImplementation(async () => Response.json(payload)); + openAIMock.mockImplementation(async () => Response.json(payload)); await expect(send()).resolves.toEqual(payload); }); @@ -108,15 +96,15 @@ describe("createTranscription", () => { it.each([...OPENAI_FAILURES, ...EDGE_FAILURES])( "%s rejects with the openai SDK's message after one send", async (_name, respond, message) => { - fetchMock.mockImplementation(respond); + openAIMock.mockImplementation(respond); await expect(send()).rejects.toThrow(new Error(message)); - expect(fetchMock).toHaveBeenCalledOnce(); + expect(openAIMock).toHaveBeenCalledOnce(); }, ); it("keeps the openai SDK's message for a 429 with retry headers", async () => { - fetchMock.mockImplementation(async () => + openAIMock.mockImplementation(async () => rateLimited({ "retry-after": "4", "retry-after-ms": "4000" }), ); @@ -143,16 +131,16 @@ describe("createTranscription", () => { ["retry-after zero", { "retry-after": "0" }, 0], ])("%s gives the same retry delay as openai-node", async (_name, headers, delayMs) => { vi.useFakeTimers({ now: new Date("2026-01-01T00:00:00Z") }); - fetchMock.mockImplementation(async () => rateLimited(headers)); + openAIMock.mockImplementation(async () => rateLimited(headers)); const error = await send().catch((caught: unknown) => caught); expect(retryDelayMs(error, 1234)).toBe(delayMs); }); - it("times out a stalled request after 10 minutes", async () => { + it("stops waiting for a stalled request after 10 minutes", async () => { vi.useFakeTimers(); - fetchMock.mockImplementation(pendingUntilAborted); + openAIMock.mockImplementation(neverAnswers); let settled = false; const pending = send().finally(() => { settled = true; @@ -164,23 +152,20 @@ describe("createTranscription", () => { await vi.advanceTimersByTimeAsync(1); await assertion; - expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true); }); - it("aborts the request and rejects with the caller's abort reason", async () => { - fetchMock.mockImplementation(pendingUntilAborted); + it("stops waiting on abort and rejects with the caller's abort reason", async () => { + openAIMock.mockImplementation(neverAnswers); const controller = new AbortController(); const reason = new DOMException( "PodNotes was unloaded during transcription.", "AbortError", ); const pending = send(controller.signal); + await vi.waitFor(() => expect(openAIMock).toHaveBeenCalledOnce()); controller.abort(reason); await expect(pending).rejects.toBe(reason); - const requestSignal = fetchMock.mock.calls[0][1]?.signal; - expect(requestSignal?.aborted).toBe(true); - expect(requestSignal?.reason).toBe(reason); }); }); diff --git a/src/utility/networkRequest.test.ts b/src/utility/networkRequest.test.ts index 47858143..50727cc7 100644 --- a/src/utility/networkRequest.test.ts +++ b/src/utility/networkRequest.test.ts @@ -387,6 +387,25 @@ describe("requestWithTimeout response policy", () => { expect(jsonGetter).not.toHaveBeenCalled(); }); + it("keeps a response whose Set-Cookie header Obsidian returns as an array", async () => { + const response = { + ...makeResponse(), + headers: { + "retry-after": "4", + "set-cookie": ["__cf_bm=a; Path=/", "_cfuvid=b; Path=/"], + }, + } as unknown as RequestUrlResponse; + + const projected = await requestWithTimeout("https://example.com/feed", { + request: implementation(Promise.resolve(response)), + }); + + expect(projected.headers).toEqual({ + "retry-after": "4", + "set-cookie": "__cf_bm=a; Path=/, _cfuvid=b; Path=/", + }); + }); + it("returns the exact status and body snapshots that passed validation", async () => { const smallBody = new ArrayBuffer(2); const largeBody = new ArrayBuffer(20); diff --git a/tests/e2e/journey/fixtures.ts b/tests/e2e/journey/fixtures.ts index 1161d7e3..0173106a 100644 --- a/tests/e2e/journey/fixtures.ts +++ b/tests/e2e/journey/fixtures.ts @@ -443,32 +443,36 @@ const OPENAI_TRANSCRIPTIONS_URL = "https://api.openai.com/v1/audio/transcription export async function installOpenAIStub(obsidian: ObsidianClient, text: string): Promise { await obsidian.dev.evalJsonAsync(`(async () => { if (window.__podnotesJourneyOpenAI) throw new Error("The OpenAI stub is already installed."); - const originalFetch = window.fetch; + const ipc = window.electron.ipcRenderer; + const originalSend = ipc.send; const uploads = []; - window.fetch = async function (input, init) { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url !== ${jsLiteral(OPENAI_TRANSCRIPTIONS_URL)}) return originalFetch.call(this, input, init); - const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); - if (method !== "POST") throw new TypeError("The transcription upload must use POST."); - const body = init?.body; - const file = body instanceof FormData ? body.get("file") : null; + const answer = async (replyChannel, request) => { + const form = await new Response(request.body, { + headers: { "content-type": request.contentType ?? "" }, + }).formData(); + const file = form.get("file"); const bytes = file instanceof Blob ? new Uint8Array(await file.arrayBuffer()) : new Uint8Array(); const ascii = (from, to) => String.fromCharCode(...bytes.slice(from, to)); uploads.push({ - method, - authorization: new Headers(init?.headers).get("authorization"), - model: body instanceof FormData ? body.get("model") : null, + method: (request.method ?? "GET").toUpperCase(), + authorization: new Headers(request.headers).get("authorization"), + model: form.get("model"), fileName: file instanceof File ? file.name : null, fileBytes: bytes.byteLength, riffWave: ascii(0, 4) === "RIFF" && ascii(8, 12) === "WAVE", sha256: require("node:crypto").createHash("sha256").update(bytes).digest("hex"), }); - return new Response(JSON.stringify({ text: ${jsLiteral(text)} }), { - status: 200, - headers: { "content-type": "application/json" }, - }); + const body = new TextEncoder().encode(JSON.stringify({ text: ${jsLiteral(text)} })).buffer; + ipc.emit(replyChannel, {}, { status: 200, headers: { "content-type": "application/json" }, body }); }; - window.__podnotesJourneyOpenAI = { originalFetch, uploads }; + ipc.send = function (channel, replyChannel, request, ...rest) { + if (channel === "request-url" && request?.url === ${jsLiteral(OPENAI_TRANSCRIPTIONS_URL)}) { + void answer(replyChannel, request); + return; + } + return originalSend.call(ipc, channel, replyChannel, request, ...rest); + }; + window.__podnotesJourneyOpenAI = { ipc, originalSend, uploads }; return true; })()`); } @@ -484,7 +488,7 @@ export async function transcriptionUploads( export async function removeOpenAIStub(obsidian: ObsidianClient): Promise { await obsidian.dev.evalJson(`(() => { const stub = window.__podnotesJourneyOpenAI; - if (stub) window.fetch = stub.originalFetch; + if (stub) stub.ipc.send = stub.originalSend; delete window.__podnotesJourneyOpenAI; return true; })()`); diff --git a/tests/mocks/openaiRequestUrl.ts b/tests/mocks/openaiRequestUrl.ts new file mode 100644 index 00000000..ea5373cc --- /dev/null +++ b/tests/mocks/openaiRequestUrl.ts @@ -0,0 +1,31 @@ +import type { RequestUrlParam, RequestUrlResponse } from "obsidian"; +import { vi } from "vitest"; + +/** OpenAI's side of Obsidian's requestUrl. Each test answers with a fetch Response. */ +export const openAIMock = vi.fn<(request: RequestUrlParam) => Promise>(); + +/** Stands in for Obsidian's requestUrl and answers with `openAIMock`. */ +export async function openAIRequestUrl(request: RequestUrlParam): Promise { + const response = await openAIMock(request); + const arrayBuffer = await response.arrayBuffer(); + const text = new TextDecoder().decode(arrayBuffer); + return { + status: response.status, + headers: Object.fromEntries(response.headers), + arrayBuffer, + text, + get json() { + return JSON.parse(text); + }, + }; +} + +/** The renderer's fetch as Obsidian runs it, where OpenAI's responses fail the CORS check. */ +export const corsBlockedFetch = () => Promise.reject(new TypeError("Failed to fetch")); + +/** The multipart form a request sent, parsed back by the platform. */ +export function sentForm(request: RequestUrlParam): Promise { + return new Response(request.body, { + headers: { "content-type": request.contentType ?? "" }, + }).formData(); +} diff --git a/tests/mocks/transcriptionService.ts b/tests/mocks/transcriptionService.ts index 974c5e14..a2f36235 100644 --- a/tests/mocks/transcriptionService.ts +++ b/tests/mocks/transcriptionService.ts @@ -3,26 +3,27 @@ import { afterEach, beforeEach, vi } from "vitest"; import type PodNotes from "src/main"; import type { TranscriptLink } from "src/parser/feedDocumentParser"; import type { Episode } from "src/types/Episode"; +import { corsBlockedFetch, openAIMock, openAIRequestUrl } from "./openaiRequestUrl"; /** What each Notice opened with, oldest first. */ export const noticeMessages: string[] = []; -/** Stand-ins for the audio download, OpenAI (through `fetch`) and Deepgram. */ +/** Stand-ins for the audio download and Deepgram. OpenAI answers through `openAIMock`. */ export const getEpisodeAudioBufferMock = vi.fn(); -export const fetchMock = vi.fn(); export const diarizeWithDeepgramMock = vi.fn(); /** - * Resets the mocks above and `noticeMessages` before each test of the calling file, - * and stubs `fetch` with `fetchMock`. The file still mocks `../downloadEpisode` and - * `./diarization` with these handles, because `vi.mock` applies only to its own file. + * Resets the mocks above, `openAIMock` and `noticeMessages` before each test of the + * calling file, and blocks `fetch` as Obsidian's CORS check does for OpenAI. The file + * still mocks `obsidian`, `../downloadEpisode` and `./diarization` with these handles, + * because `vi.mock` applies only to its own file. */ export function installTranscriptionMocks(): void { beforeEach(() => { vi.clearAllMocks(); noticeMessages.length = 0; - fetchMock.mockReset(); - vi.stubGlobal("fetch", fetchMock); + openAIMock.mockReset(); + vi.stubGlobal("fetch", corsBlockedFetch); diarizeWithDeepgramMock.mockReset(); getEpisodeAudioBufferMock.mockResolvedValue({ buffer: new ArrayBuffer(1024), @@ -36,15 +37,22 @@ export function installTranscriptionMocks(): void { }); } -/** The `obsidian` module whose Notice records its opening message in `noticeMessages`. */ -export function recordingNotices(obsidian: typeof import("obsidian")): typeof import("obsidian") { +/** + * The `obsidian` module whose Notice records its opening message in `noticeMessages` + * and whose requestUrl answers with `openAIMock`. + */ +export function mockedObsidian(obsidian: typeof import("obsidian")): typeof import("obsidian") { class RecordingNotice extends obsidian.Notice { constructor(message: string | DocumentFragment, duration?: number) { super(message, duration); noticeMessages.push(String(message)); } } - return { ...obsidian, Notice: RecordingNotice }; + return { + ...obsidian, + Notice: RecordingNotice, + requestUrl: openAIRequestUrl as typeof obsidian.requestUrl, + }; } export const mockEpisode: Episode = { From e4b6bc91157c243d67aab1419e99752ad4328fb0 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 19 Oct 2026 08:08:53 +0200 Subject: [PATCH 2/2] fix(transcription): send OpenAI uploads through requestUrl OpenAI answers errors without an Access-Control-Allow-Origin header for app://obsidian.md. Chromium blocked those responses from the renderer's fetch, so every failed upload, plain or diarized, showed "Connection error." and hid the real cause, such as a bad key or no quota. createTranscription now posts the same multipart bytes through requestWithTimeout and accepts every status, so OpenAI's message and Retry-After reach the caller. A timeout or abort stops waiting and drops the late response, because requestUrl cannot cancel an upload. requestWithTimeout rejected every response that set a cookie, because the desktop requestUrl returns Set-Cookie as an array. It now joins string arrays with ", ". A plain transcription that fails on every chunk now ends its notice with the last chunk's error and no longer repeats "Transcription failed:". The verify skill's fixture provider now answers OpenAI on requestUrl. --- .claude/skills/verify/net.mjs | 20 ++++---- src/services/TranscriptionService.ts | 14 ++--- src/services/openaiTranscription.ts | 76 +++++++++++++--------------- src/utility/networkRequest.ts | 9 +++- 4 files changed, 61 insertions(+), 58 deletions(-) diff --git a/.claude/skills/verify/net.mjs b/.claude/skills/verify/net.mjs index 6e10e8d7..dc67370d 100644 --- a/.claude/skills/verify/net.mjs +++ b/.claude/skills/verify/net.mjs @@ -7,9 +7,9 @@ // for the fixture server, OpenAI and Deepgram // node .claude/skills/verify/net.mjs unwatch restore fetch and requestUrl // -// PodNotes sends OpenAI uploads through window.fetch and everything else -// (feeds, audio, transcripts, Deepgram) through requestUrl, which Obsidian -// forwards to its main process on the `request-url` IPC channel. +// PodNotes sends its requests (feeds, audio, transcripts, OpenAI, Deepgram) +// through requestUrl, which Obsidian forwards to its main process on the +// `request-url` IPC channel. The fetch log catches anything that bypasses it. import { fileURLToPath } from "node:url"; import { cliSocketExists, @@ -78,11 +78,6 @@ const WATCH = `(() => { window.fetch = async function (input, init) { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); - if (url.startsWith("https://api.openai.com/")) { - const file = init?.body instanceof FormData ? init.body.get("file") : null; - entries.push({ via: "fetch", method, url: logged(url), answeredBy: "fixture provider", uploadBytes: file instanceof Blob ? file.size : 0 }); - return new Response(JSON.stringify({ text, segments: [{ speaker: "A", text, start: 0, end: 1 }] }), { status: 200, headers: { "content-type": "application/json" } }); - } entries.push({ via: "fetch", method, url: logged(url) }); return originalFetch.call(this, input, init); }; @@ -91,9 +86,14 @@ const WATCH = `(() => { ipc.send = function (channel, replyChannel, request, ...rest) { if (channel === "request-url" && typeof request?.url === "string") { const method = (request.method ?? "GET").toUpperCase(); - if (request.url.startsWith("https://api.deepgram.com/")) { + const answer = request.url.startsWith("https://api.openai.com/") + ? { text, segments: [{ speaker: "A", text, start: 0, end: 1 }] } + : request.url.startsWith("https://api.deepgram.com/") + ? { results: { utterances: [{ speaker: 0, transcript: text, start: 0, end: 1 }] } } + : null; + if (answer) { entries.push({ via: "requestUrl", method, url: logged(request.url), answeredBy: "fixture provider", uploadBytes: request.body?.byteLength ?? 0 }); - const body = new TextEncoder().encode(JSON.stringify({ results: { utterances: [{ speaker: 0, transcript: text, start: 0, end: 1 }] } })).buffer; + const body = new TextEncoder().encode(JSON.stringify(answer)).buffer; queueMicrotask(() => ipc.emit(replyChannel, {}, { status: 200, headers: { "content-type": "application/json" }, body })); return; } diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index 6ab634a9..74beae91 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -328,7 +328,7 @@ export class TranscriptionService { const files = await createChunkFiles(audio); this.assertActive(); updateNotice("Starting transcription..."); - const { text, failedChunks } = await this.transcribeChunks(files, updateNotice); + const { text, failedChunks, lastError } = await this.transcribeChunks(files, updateNotice); this.assertActive(); // Strip the error placeholders (and trim) to see whether ANY real speech was @@ -337,10 +337,10 @@ export class TranscriptionService { // saving a body of pure error markers (empty chunks join to " ", not ""). const realText = text.replace(CHUNK_ERROR_PLACEHOLDER_PATTERN, " ").trim(); if (realText.length === 0) { + if (failedChunks === 0) throw new Error("Transcription returned no text."); + const detail = lastError instanceof Error ? lastError.message : String(lastError); throw new Error( - failedChunks > 0 - ? `Transcription failed: all ${files.length} audio chunk(s) failed or returned no text.` - : "Transcription returned no text.", + `all ${files.length} audio chunk(s) failed or returned no text. Last error: ${detail}`, ); } @@ -402,11 +402,12 @@ export class TranscriptionService { private async transcribeChunks( files: File[], updateNotice: (message: string) => void, - ): Promise<{ text: string; failedChunks: number }> { + ): Promise<{ text: string; failedChunks: number; lastError: unknown }> { const apiKey = this.getApiKey(); const transcriptions: string[] = Array.from({ length: files.length }); let completedChunks = 0; let failedChunks = 0; + let lastError: unknown; let nextIndex = 0; const updateProgress = () => { @@ -453,6 +454,7 @@ export class TranscriptionService { ); transcriptions[index] = chunkErrorPlaceholder(index); failedChunks++; + lastError = error; completedChunks++; updateProgress(); } else { @@ -469,7 +471,7 @@ export class TranscriptionService { await Promise.all(workers); this.assertActive(); - return { text: transcriptions.join(" "), failedChunks }; + return { text: transcriptions.join(" "), failedChunks, lastError }; } private waitForRetry(delayMs: number): Promise { diff --git a/src/services/openaiTranscription.ts b/src/services/openaiTranscription.ts index 67dec6c0..ced3b90a 100644 --- a/src/services/openaiTranscription.ts +++ b/src/services/openaiTranscription.ts @@ -1,56 +1,52 @@ +import { + TimeoutError, + requestWithTimeout, + type NetworkBinaryResponse, +} from "../utility/networkRequest"; + const TRANSCRIPTIONS_URL = "https://api.openai.com/v1/audio/transcriptions"; const REQUEST_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_REQUEST_BYTES = 25 * 1024 * 1024; const MAX_SERVER_RETRY_DELAY_MS = 60 * 1000; +const EVERY_HTTP_STATUS = Array.from({ length: 500 }, (_, index) => 100 + index); +// OpenAI's responses do not allow Obsidian's origin, so the renderer's fetch +// cannot read them. requestUrl has no CORS check but cannot cancel an upload, +// so a timeout or abort only stops waiting and drops the late response. export async function createTranscription( apiKey: string, fields: Record, signal: AbortSignal, ): Promise { - const body = new FormData(); - for (const [name, value] of Object.entries(fields)) body.append(name, value); - - const controller = new AbortController(); - const forwardAbort = () => controller.abort(signal.reason); - signal.addEventListener("abort", forwardAbort, { once: true }); - if (signal.aborted) forwardAbort(); - let timedOut = false; - const timeout = window.setTimeout(() => { - timedOut = true; - controller.abort(); - }, REQUEST_TIMEOUT_MS); + const form = new FormData(); + for (const [name, value] of Object.entries(fields)) form.append(name, value); + const multipart = new Request(TRANSCRIPTIONS_URL, { method: "POST", body: form }); + let response: NetworkBinaryResponse; try { - let response: Response; - let text: string; - try { - // oxlint-disable-next-line no-restricted-globals -- requestUrl cannot cancel an in-flight upload. - response = await fetch(TRANSCRIPTIONS_URL, { - method: "POST", - headers: { Authorization: `Bearer ${apiKey}` }, - body, - signal: controller.signal, - }); - text = await response.text(); - } catch (error) { - if (signal.aborted) throw signal.reason; - const mentionsTimeout = /timed? ?out/i.test(String(error)); - throw new Error( - timedOut || mentionsTimeout ? "Request timed out." : "Connection error.", - ); - } + response = await requestWithTimeout(TRANSCRIPTIONS_URL, { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}` }, + contentType: multipart.headers.get("content-type") ?? undefined, + body: await multipart.arrayBuffer(), + timeoutMs: REQUEST_TIMEOUT_MS, + maxRequestBodyBytes: MAX_REQUEST_BYTES, + acceptedStatuses: EVERY_HTTP_STATUS, + signal, + }); + } catch (error) { + if (signal.aborted) throw signal.reason; + throw new Error(error instanceof TimeoutError ? "Request timed out." : "Connection error."); + } - if (!response.ok) { - throw new OpenAIStatusError( - `${response.status} ${errorDetail(text) || "status code (no body)"}`, - serverRetryDelayMs(response.headers), - ); - } - return JSON.parse(text); - } finally { - window.clearTimeout(timeout); - signal.removeEventListener("abort", forwardAbort); + const text = new TextDecoder().decode(response.arrayBuffer); + if (response.status < 200 || response.status >= 300) { + throw new OpenAIStatusError( + `${response.status} ${errorDetail(text) || "status code (no body)"}`, + serverRetryDelayMs(new Headers(response.headers)), + ); } + return JSON.parse(text); } export function retryDelayMs(error: unknown, fallbackMs: number): number { diff --git a/src/utility/networkRequest.ts b/src/utility/networkRequest.ts index aa9ee662..9956a457 100644 --- a/src/utility/networkRequest.ts +++ b/src/utility/networkRequest.ts @@ -503,10 +503,15 @@ function copyResponseHeaders(value: unknown): Record { try { const headers = ownRecord(); for (const [name, headerValue] of Object.entries(value)) { - if (typeof headerValue !== "string") { + // Obsidian's desktop requestUrl returns Set-Cookie as an array of strings. + const combined: unknown = + Array.isArray(headerValue) && headerValue.every((item) => typeof item === "string") + ? headerValue.join(", ") + : headerValue; + if (typeof combined !== "string") { throw new NetworkError("invalid-response"); } - headers[name] = headerValue; + headers[name] = combined; } return Object.freeze(headers); } catch {