diff --git a/lib/ads/agent.ts b/lib/ads/agent.ts new file mode 100644 index 00000000..d6dcbc07 --- /dev/null +++ b/lib/ads/agent.ts @@ -0,0 +1,91 @@ +// ad-generator-agent: the c0mpute plugin that makes every ad type on +// /dashboard/ads/new (c0mpute plugins/ad-generator-agent, DIP 0029). +// +// On by default. While it is on, each generated asset comes from c0mpute or +// from no AI at all, never from a paid API the operator did not opt into: +// +// copy (every format, summaries) Infernet inference on c0mpute, through +// lib/lx/backendAi (the fleet-wide router; +// its OpenAI/Anthropic fallback is the +// standing 2026-11-02 rule, not ours to add) +// narration (video + audio ads) c0mpute tts.synthesize via the speech +// gateway (`c0mpute speech serve`) +// hero image (image ads) the advertiser's og:image; c0mpute has no +// live diffusion workers yet, so otherwise +// none. gpt-image only with +// AD_GENERATOR_PAID_IMAGES=1 +// static / gif / video / audio rendered by our own worker (Chromium +// assembly frames + ffmpeg), no model involved +// +// AD_GENERATOR_AGENT=off (or 0/false/no) restores the previous providers +// (gpt-image heroes, ElevenLabs narration). + +export const AD_GENERATOR_AGENT = "ad-generator-agent"; + +type Env = Record; + +// Read through a variable so nothing here can be inlined at build time. +function runtimeEnv(): Env { + const e: Env = process.env; + return e; +} + +const OFF = /^(0|off|false|no|disabled?)$/i; +const ON = /^(1|on|true|yes|enabled?)$/i; + +export function adGeneratorAgentEnabled(env: Env = runtimeEnv()): boolean { + return !OFF.test((env.AD_GENERATOR_AGENT ?? "").trim()); +} + +/** gpt-image heroes: always when the agent is off, only by opt-in when it is on. */ +export function paidHeroImagesAllowed(env: Env = runtimeEnv()): boolean { + if (!adGeneratorAgentEnabled(env)) return true; + return ON.test((env.AD_GENERATOR_PAID_IMAGES ?? "").trim()); +} + +export type NarrationRoute = "c0mpute" | "elevenlabs"; + +export function narrationRoute(env: Env = runtimeEnv()): NarrationRoute { + return adGeneratorAgentEnabled(env) ? "c0mpute" : "elevenlabs"; +} + +export type C0mputeSpeechTarget = { + /** Full URL of the synthesis endpoint. */ + url: string; + /** Bearer for a private gateway; the public Infernet route needs none. */ + token: string | null; +}; + +const DEFAULT_INFERNET_URL = "https://infernetprotocol.com"; + +/** + * Where narration is synthesised. + * + * A private `c0mpute speech serve` gateway (C0MPUTE_SPEECH_URL + _TOKEN) when + * one is configured: it has no rate limit and is the buyer this agent is meant + * to run on. Otherwise Infernet's public voice route, which forwards to the + * same kind of gateway, so a fresh deployment still narrates on c0mpute. + */ +export function c0mputeSpeechTarget(env: Env = runtimeEnv()): C0mputeSpeechTarget { + const gateway = (env.C0MPUTE_SPEECH_URL ?? "").trim().replace(/\/+$/, ""); + const token = (env.C0MPUTE_SPEECH_TOKEN ?? "").trim(); + if (gateway && token) return { url: `${gateway}/v1/tts`, token }; + const base = (env.C0MPUTE_URL ?? DEFAULT_INFERNET_URL).trim().replace(/\/+$/, ""); + return { url: `${base}/api/chat/voice/tts`, token: null }; +} + +export function c0mputeTtsVoice(env: Env = runtimeEnv()): { engine: string; voice: string } { + return { + engine: (env.AD_GENERATOR_TTS_ENGINE ?? "").trim() || "kokoro", + // Sarah, the same voice the ElevenLabs default used. + voice: (env.AD_GENERATOR_TTS_VOICE ?? "").trim() || "af_sarah", + }; +} + +/** + * The provider label shown on the form and stored with a generation, so it is + * visible which agent made the ad: `ad-generator-agent/c0mpute`. + */ +export function agentProviderLabel(provider: string, env: Env = runtimeEnv()): string { + return adGeneratorAgentEnabled(env) ? `${AD_GENERATOR_AGENT}/${provider}` : provider; +} diff --git a/lib/ads/creative.ts b/lib/ads/creative.ts index 4af85ee9..d306a34c 100644 --- a/lib/ads/creative.ts +++ b/lib/ads/creative.ts @@ -7,6 +7,7 @@ import { env } from "@/lib/env"; import { generateStructuredOutput } from "@/lib/lx/backendAi"; import { extractSiteBrand, type SiteBrand } from "./brand"; import { resolveAdHeroImage } from "./heroImage"; +import { agentProviderLabel, paidHeroImagesAllowed } from "./agent"; import { renderCreativeText, renderTerminalHtml } from "./terminal"; import { renderFeedHtml } from "./feeditem"; import { NO_MEDIA, type AdMediaKind, type MediaAssets } from "./media"; @@ -523,12 +524,17 @@ export async function generateAdCreatives( // Hero image: the advertiser's og:image when it exists, else a gpt-image-2.5 // fallback (only when we have a Supabase client to host the upload). Best- // effort — a failure just leaves the accent-tint background. + // + // The ad-generator-agent (./agent, on by default) keeps this off paid image + // generation: c0mpute has no live diffusion workers yet, so without an + // og:image the ad ships on its accent tint unless the operator opted into + // gpt-image with AD_GENERATOR_PAID_IMAGES=1. let heroUrl: string | null = null; if (opts.supabase) { const hero = await resolveAdHeroImage({ brand, copy: { headline: output.headline, body: output.body, bgColor: output.bgColor, accentColor: output.accentColor }, - openai, + openai: paidHeroImagesAllowed() ? openai : null, supabase: opts.supabase, }).catch(() => null); heroUrl = hero?.url ?? null; @@ -543,7 +549,12 @@ export async function generateAdCreatives( domain: summaryDomain(brand.url || rawUrl), }; - return { brand, creatives: copyToCreatives(brand, output, heroUrl), provider, summary }; + return { + brand, + creatives: copyToCreatives(brand, output, heroUrl), + provider: agentProviderLabel(provider), + summary, + }; } // Append the campaign ref for click attribution, preserving any existing query. diff --git a/lib/ads/video/narration.ts b/lib/ads/video/narration.ts index d97470ac..add9f97f 100644 --- a/lib/ads/video/narration.ts +++ b/lib/ads/video/narration.ts @@ -16,6 +16,13 @@ import { NARRATION_CHARS_PER_SEC, } from "./profiles"; import type { VideoDesignSnapshot } from "./snapshot"; +import { + c0mputeSpeechTarget, + c0mputeTtsVoice, + narrationRoute, + type C0mputeSpeechTarget, + type NarrationRoute, +} from "../agent"; import { spokenDomain, spokenText } from "./spoken"; /** @@ -119,11 +126,16 @@ export type NarrationResult = { export async function synthesiseNarration(input: { snapshot: VideoDesignSnapshot; workDir: string; + /** Default: c0mpute while the ad-generator-agent is on (../agent). */ + route?: NarrationRoute; + /** c0mpute speech endpoint; default from C0MPUTE_SPEECH_URL/_TOKEN. */ + speech?: C0mputeSpeechTarget; apiKey?: string | null; voiceId?: string; fetchImpl?: typeof fetch; }): Promise { if (input.snapshot.audioMode === "silent") return null; + if ((input.route ?? narrationRoute()) === "c0mpute") return synthesiseOnC0mpute(input); // Undefined uses deployment configuration; explicit null suppresses key lookup. const apiKey = input.apiKey === undefined ? (process.env.ELEVENLABS_API_KEY ?? null) : input.apiKey; @@ -173,3 +185,72 @@ export async function synthesiseNarration(input: { throw new NarrationError("narration_unavailable", true); } } + +// ── c0mpute (ad-generator-agent) ───────────────────────────────────────────── + +/** CPU synthesis of a one-line read is seconds; a cold worker or a queue is not. */ +const C0MPUTE_TTS_TIMEOUT_MS = 180_000; + +/** A WAV container: what tts.synthesize returns (the gateway validates it too). */ +function isWav(buf: Buffer): boolean { + return ( + buf.byteLength > 44 && + buf.toString("ascii", 0, 4) === "RIFF" && + buf.toString("ascii", 8, 12) === "WAVE" + ); +} + +/** + * Narration on c0mpute: one `tts.synthesize` job through a speech gateway. + * + * Never falls back to a paid voice. A gateway with no worker answering (503), + * a rate limit or a network failure is retryable, so the render job retries + * rather than publishing a silent ad; a rejected request or bad credentials + * is not, because the same request will be rejected again. + */ +async function synthesiseOnC0mpute(input: { + snapshot: VideoDesignSnapshot; + workDir: string; + speech?: C0mputeSpeechTarget; + fetchImpl?: typeof fetch; +}): Promise { + const script = input.snapshot.narration?.trim() || narrationScript(input.snapshot); + if (!script.trim()) throw new NarrationError("narration_script_missing", false); + + const target = input.speech ?? c0mputeSpeechTarget(); + const { engine, voice } = c0mputeTtsVoice(); + const doFetch = input.fetchImpl ?? fetch; + + try { + const res = await doFetch(target.url, { + method: "POST", + signal: AbortSignal.timeout(C0MPUTE_TTS_TIMEOUT_MS), + headers: { + "content-type": "application/json", + accept: "audio/wav", + ...(target.token ? { authorization: `Bearer ${target.token}` } : {}), + }, + body: JSON.stringify({ text: script, engine, voice, locale: "en-US", speed: 1 }), + }); + if (!res.ok) { + const code = + res.status === 401 || res.status === 403 ? "narration_access_denied" + : res.status === 400 || res.status === 413 ? "narration_rejected" + : res.status === 429 ? "narration_rate_limited" + : res.status === 503 ? "narration_no_c0mpute_worker" + : "narration_provider_failed"; + throw new NarrationError(code, res.status === 429 || res.status >= 500 || res.status === 408); + } + + const buf = Buffer.from(await res.arrayBuffer()); + if (buf.byteLength === 0) throw new NarrationError("narration_empty_audio", true); + if (!isWav(buf)) throw new NarrationError("narration_bad_audio", true); + + const filePath = path.join(input.workDir, "narration.wav"); + await writeFile(filePath, buf); + return { filePath, script, byteSize: buf.byteLength }; + } catch (error) { + if (error instanceof NarrationError) throw error; + throw new NarrationError("narration_unavailable", true); + } +} diff --git a/tests/ads-generator-agent.test.ts b/tests/ads-generator-agent.test.ts new file mode 100644 index 00000000..aa09af7f --- /dev/null +++ b/tests/ads-generator-agent.test.ts @@ -0,0 +1,224 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// The ad-generator-agent (lib/ads/agent.ts, c0mpute plugins/ad-generator-agent): +// every generated ad asset comes from c0mpute or from no model at all. + +// Infernet is stubbed before backendAi builds its router, which captures fetch. +const calls: { url: string; body: Record | null }[] = []; +const copy = { + headline: "Audit every crawler", + shortHeadline: "Audit crawlers", + body: "See which bots read your site and what they cost you.", + ctaText: "Try it free", + bgColor: "#0b0d10", + fgColor: "#e7e9ee", + accentColor: "#6ee7b7", + lightBgColor: "#f7f9fc", + lightFgColor: "#0b0d10", + lightAccentColor: "#0f766e", + summaryShort: "CrawlProof shows site owners which crawlers read their pages.", + summaryLong: "CrawlProof records crawler traffic per site.\n\nIt separates people from bots.", +}; +vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => { + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : null; + calls.push({ url: String(url), body }); + if (String(url).endsWith("/api/chat")) { + expect(body?.p2p_only).toBe(true); + return Response.json({ jobId: "adgen1", source: "p2p", model: body?.modelName }); + } + if (String(url).includes("/api/chat/stream/adgen1")) { + const text = JSON.stringify(copy).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const sse = + 'event: meta\ndata: {"model":"qwen-test"}\n\n' + + `event: token\ndata: {"text":"${text}"}\n\n` + + "event: done\ndata: {}\n\n"; + return new Response(sse, { headers: { "content-type": "text/event-stream" } }); + } + throw new Error(`unexpected fetch ${url}`); +}); + +const hero = vi.hoisted(() => ({ resolve: vi.fn() })); +vi.mock("@/lib/ads/heroImage", () => ({ resolveAdHeroImage: hero.resolve })); +vi.mock("@/lib/ads/brand", async (original) => ({ + ...(await original()), + extractSiteBrand: vi.fn(async () => ({ + url: "https://crawlproof.com/", + domain: "crawlproof.com", + title: "CrawlProof", + description: "Crawler analytics", + text: "CrawlProof shows which crawlers read your site. ".repeat(10), + logoUrl: null, + ogImage: null, + themeColor: null, + palette: ["#0b0d10"], + })), +})); + +// lib/env reads OPENAI_API_KEY once at import, so the paid client exists for +// the whole file: the tests prove the agent withholds it, not that it is absent. +process.env.OPENAI_API_KEY = "sk-test-not-used"; +const agent = await import("@/lib/ads/agent"); +const { generateAdCreatives } = await import("@/lib/ads/creative"); +const { synthesiseNarration, NarrationError } = await import("@/lib/ads/video/narration"); + +const ENV_KEYS = [ + "AD_GENERATOR_AGENT", + "AD_GENERATOR_PAID_IMAGES", + "ELEVENLABS_API_KEY", + "C0MPUTE_SPEECH_URL", + "C0MPUTE_SPEECH_TOKEN", +]; +const saved: Record = {}; +beforeEach(() => { + for (const k of ENV_KEYS) saved[k] = process.env[k]; + delete process.env.AD_GENERATOR_AGENT; + delete process.env.AD_GENERATOR_PAID_IMAGES; + calls.length = 0; + hero.resolve.mockReset().mockResolvedValue(null); +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +describe("agent switches", () => { + it("is on by default and off only when the operator says so", () => { + expect(agent.adGeneratorAgentEnabled({})).toBe(true); + for (const v of ["off", "0", "false", "no", "disabled"]) { + expect(agent.adGeneratorAgentEnabled({ AD_GENERATOR_AGENT: v })).toBe(false); + } + expect(agent.adGeneratorAgentEnabled({ AD_GENERATOR_AGENT: "on" })).toBe(true); + }); + + it("keeps paid hero images off unless opted in", () => { + expect(agent.paidHeroImagesAllowed({})).toBe(false); + expect(agent.paidHeroImagesAllowed({ AD_GENERATOR_PAID_IMAGES: "1" })).toBe(true); + expect(agent.paidHeroImagesAllowed({ AD_GENERATOR_AGENT: "off" })).toBe(true); + }); + + it("narrates on c0mpute while on, ElevenLabs only when off", () => { + expect(agent.narrationRoute({})).toBe("c0mpute"); + expect(agent.narrationRoute({ AD_GENERATOR_AGENT: "off" })).toBe("elevenlabs"); + }); + + it("prefers a private speech gateway and falls back to Infernet's voice route", () => { + expect( + agent.c0mputeSpeechTarget({ C0MPUTE_SPEECH_URL: "http://gw:8787/", C0MPUTE_SPEECH_TOKEN: "t" }), + ).toEqual({ url: "http://gw:8787/v1/tts", token: "t" }); + expect(agent.c0mputeSpeechTarget({})).toEqual({ + url: "https://infernetprotocol.com/api/chat/voice/tts", + token: null, + }); + }); +}); + +describe("copy and artwork", () => { + it("writes the copy on c0mpute and never asks gpt-image for a hero", async () => { + process.env.OPENAI_API_KEY = "sk-test-not-used"; + const out = await generateAdCreatives("https://crawlproof.com", { supabase: {} as never }); + + expect(out.provider).toBe("ad-generator-agent/c0mpute"); + expect(out.creatives.length).toBeGreaterThan(0); + expect(out.creatives[0].headline).toBe("Audit every crawler"); + // The Infernet call is the only network call, and it is p2p_only. + expect(calls.map((c) => new URL(c.url).pathname)).toEqual([ + "/api/chat", + "/api/chat/stream/adgen1", + ]); + expect(hero.resolve).toHaveBeenCalledTimes(1); + expect(hero.resolve.mock.calls[0][0].openai).toBeNull(); + }); + + it("hands the hero step the paid client only with the opt-in", async () => { + process.env.OPENAI_API_KEY = "sk-test-not-used"; + process.env.AD_GENERATOR_PAID_IMAGES = "1"; + await generateAdCreatives("https://crawlproof.com", { supabase: {} as never }); + expect(hero.resolve.mock.calls[0][0].openai).not.toBeNull(); + }); +}); + +describe("narration on c0mpute", () => { + const snapshot = { + audioMode: "narrated", + headline: "Audit every crawler", + ctaText: "Try it free", + domain: "crawlproof.com", + } as never; + const wav = Buffer.concat([ + Buffer.from("RIFF"), + Buffer.alloc(4), + Buffer.from("WAVEfmt "), + Buffer.alloc(64, 1), + ]); + + it("buys one tts.synthesize job and writes the WAV it returns", async () => { + process.env.ELEVENLABS_API_KEY = "paid-key-must-not-be-used"; + const dir = await mkdtemp(path.join(tmpdir(), "adgen-narr-")); + const seen: { url: string; init: RequestInit }[] = []; + try { + const res = await synthesiseNarration({ + snapshot, + workDir: dir, + speech: { url: "http://gw:8787/v1/tts", token: "gw-token" }, + fetchImpl: (async (url: string, init: RequestInit) => { + seen.push({ url, init }); + return new Response(wav, { headers: { "content-type": "audio/wav" } }); + }) as typeof fetch, + }); + expect(seen).toHaveLength(1); + expect(seen[0].url).toBe("http://gw:8787/v1/tts"); + expect((seen[0].init.headers as Record).authorization).toBe("Bearer gw-token"); + const body = JSON.parse(String(seen[0].init.body)); + expect(body).toMatchObject({ engine: "kokoro", voice: "af_sarah", locale: "en-US", speed: 1 }); + expect(body.text).toBe("Audit every crawler. Try it free at crawl proof dot com."); + expect(res!.filePath.endsWith("narration.wav")).toBe(true); + expect((await readFile(res!.filePath)).equals(wav)).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("retries when no c0mpute worker took the job, instead of paying for a voice", async () => { + process.env.ELEVENLABS_API_KEY = "paid-key-must-not-be-used"; + const urls: string[] = []; + const run = synthesiseNarration({ + snapshot, + workDir: "/unused", + speech: { url: "http://gw/v1/tts", token: null }, + fetchImpl: (async (url: string) => { + urls.push(url); + return Response.json({ error: "no c0mpute worker completed the speech job" }, { status: 503 }); + }) as typeof fetch, + }); + await expect(run).rejects.toMatchObject({ code: "narration_no_c0mpute_worker", retryable: true }); + expect(urls).toEqual(["http://gw/v1/tts"]); + }); + + it("does not retry a rejected request or bad credentials", async () => { + for (const status of [400, 401]) { + const run = synthesiseNarration({ + snapshot, + workDir: "/unused", + speech: { url: "http://gw/v1/tts", token: "t" }, + fetchImpl: (async () => new Response("no", { status })) as typeof fetch, + }); + await expect(run).rejects.toBeInstanceOf(NarrationError); + await expect(run).rejects.toMatchObject({ retryable: false }); + } + }); + + it("refuses a body that is not audio", async () => { + const run = synthesiseNarration({ + snapshot, + workDir: "/unused", + speech: { url: "http://gw/v1/tts", token: null }, + fetchImpl: (async () => new Response("oops".repeat(10))) as typeof fetch, + }); + await expect(run).rejects.toMatchObject({ code: "narration_bad_audio", retryable: true }); + }); +}); diff --git a/tests/ads-video-narration.test.ts b/tests/ads-video-narration.test.ts index ade31ea5..bd694dd0 100644 --- a/tests/ads-video-narration.test.ts +++ b/tests/ads-video-narration.test.ts @@ -108,7 +108,7 @@ describe("synthesis enforces the narrated audio contract", () => { it("reports missing configuration for narrated designs", async () => { const dir = await mkdtemp(path.join(tmpdir(), "narr-")); try { - await expect(synthesiseNarration({ snapshot: snapshot(), workDir: dir, apiKey: null })).rejects.toMatchObject({ code: "narration_not_configured", retryable: false }); + await expect(synthesiseNarration({ route: "elevenlabs", snapshot: snapshot(), workDir: dir, apiKey: null })).rejects.toMatchObject({ code: "narration_not_configured", retryable: false }); } finally { await rm(dir, { recursive: true, force: true }); } @@ -117,7 +117,7 @@ describe("synthesis enforces the narrated audio contract", () => { it("classifies a rate limit as retryable", async () => { const dir = await mkdtemp(path.join(tmpdir(), "narr-")); try { - const result = synthesiseNarration({ + const result = synthesiseNarration({ route: "elevenlabs", snapshot: snapshot(), workDir: dir, apiKey: "k", @@ -134,7 +134,7 @@ describe("synthesis enforces the narrated audio contract", () => { // muxing zero bytes fails much later with a worse message. const dir = await mkdtemp(path.join(tmpdir(), "narr-")); try { - const result = synthesiseNarration({ + const result = synthesiseNarration({ route: "elevenlabs", snapshot: snapshot(), workDir: dir, apiKey: "k", @@ -149,7 +149,7 @@ describe("synthesis enforces the narrated audio contract", () => { it("retries a network failure", async () => { const dir = await mkdtemp(path.join(tmpdir(), "narr-")); try { - const result = synthesiseNarration({ + const result = synthesiseNarration({ route: "elevenlabs", snapshot: snapshot(), workDir: dir, apiKey: "k", @@ -165,19 +165,19 @@ describe("synthesis enforces the narrated audio contract", () => { it("skips silent designs without calling the provider", async () => { let called = false; - expect(await synthesiseNarration({ snapshot: snapshot({ audioMode: "silent" }), workDir: "/unused", apiKey: "fixture", fetchImpl: (async () => { called = true; throw Error(); }) as typeof fetch })).toBeNull(); + expect(await synthesiseNarration({ route: "elevenlabs", snapshot: snapshot({ audioMode: "silent" }), workDir: "/unused", apiKey: "fixture", fetchImpl: (async () => { called = true; throw Error(); }) as typeof fetch })).toBeNull(); expect(called).toBe(false); }); it("reports payment failures without leaking the provider body", async () => { - await expect(synthesiseNarration({ snapshot: snapshot(), workDir: "/unused", apiKey: "fixture", fetchImpl: (async () => new Response("sensitive provider body", { status: 402 })) as typeof fetch })).rejects.toMatchObject({ code: "narration_payment_required", message: "narration_payment_required", retryable: false }); + await expect(synthesiseNarration({ route: "elevenlabs", snapshot: snapshot(), workDir: "/unused", apiKey: "fixture", fetchImpl: (async () => new Response("sensitive provider body", { status: 402 })) as typeof fetch })).rejects.toMatchObject({ code: "narration_payment_required", message: "narration_payment_required", retryable: false }); }); it("writes the audio and reports the script it spoke", async () => { const dir = await mkdtemp(path.join(tmpdir(), "narr-")); try { const bytes = new Uint8Array([0x49, 0x44, 0x33, 1, 2, 3, 4, 5]); - const res = await synthesiseNarration({ + const res = await synthesiseNarration({ route: "elevenlabs", snapshot: snapshot(), workDir: dir, apiKey: "k", @@ -195,7 +195,7 @@ describe("synthesis enforces the narrated audio contract", () => { it("speaks the immutable approved narration instead of regenerating the script", async () => { const dir = await mkdtemp(path.join(tmpdir(), "narr-approved-")); try { - await synthesiseNarration({ snapshot: snapshot({ narration: "Approved voiceover." }), workDir: dir, apiKey: "fixture", fetchImpl: (async (_url: string, init: RequestInit) => { + await synthesiseNarration({ route: "elevenlabs", snapshot: snapshot({ narration: "Approved voiceover." }), workDir: dir, apiKey: "fixture", fetchImpl: (async (_url: string, init: RequestInit) => { expect(JSON.parse(String(init.body)).text).toBe("Approved voiceover."); expect(init.signal).toBeDefined(); return new Response(new Uint8Array([1,2,3]));