Files
hyperframes/packages/studio-server/src/routes/render.test.ts
T
Vance IngallsandClaude Opus 5 6f0df2640b fix(cli,studio,core): close five R5 telemetry and canary findings
- A long-lived preview cached its telemetry posture in two places
  (readConfig and shouldTrack). Running `telemetry disable` in another
  terminal left it resolving canaries and injecting the CLI id for hours.
  Both caches are now dropped together at a request boundary.
- Studio minted and shipped a telemetry id for every render regardless of
  the browser profile's opt-out, and the server emitted the outcome under
  CLI policy, which cannot see localStorage or DNT. The browser now sends
  an explicit telemetryOptOut, distinct from an old client's omission.
- Any non-empty HYPERFRAMES_PREVIEW_HOST disabled the DNS-rebinding guard,
  so even a loopback bind accepted a hostile Host. The guard now holds for
  loopback binds and, on a LAN bind, admits only names this machine
  answers on.
- sunsetAfter had no reader of the current date. A scheduled workflow runs
  scripts/check-canary-sunset.ts weekly, so a failure lands on the
  rollout's owner rather than on an unrelated PR author.
- The install-state seed memo outlived `rm -rf ~/.hyperframes`,
  resurrecting a cleared cohort. Removed; it only saved a read on a
  readConfig cache miss.

Docs updated for the Host rule and the 100% exclusion carve-out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 17:21:41 -07:00

731 lines
26 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
import { registerRenderRoutes } from "./render";
import type { StudioApiAdapter } from "../types";
function createAdapter(
startRenderSpy: ReturnType<typeof vi.fn>,
rendersDir = mkdtempSync(join(tmpdir(), "hf-render-test-")),
): { adapter: StudioApiAdapter; rendersDir: string } {
const adapter: StudioApiAdapter = {
listProjects: () => [],
// Use a real, existing dir: isSafePath() canonicalizes the project dir with
// realpath and fails closed if it doesn't exist (real projects always do).
resolveProject: async (id: string) => ({ id, dir: tmpdir() }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => rendersDir,
startRender: (opts) => {
startRenderSpy(opts);
return {
id: opts.jobId,
status: "rendering",
progress: 0,
outputPath: opts.outputPath,
};
},
};
return { adapter, rendersDir };
}
function buildApp(spy: ReturnType<typeof vi.fn>): {
app: Hono;
rendersDir: string;
cleanup: () => void;
} {
const { adapter, rendersDir } = createAdapter(spy);
const app = new Hono();
registerRenderRoutes(app, adapter);
return { app, rendersDir, cleanup: () => rmSync(rendersDir, { recursive: true, force: true }) };
}
describe("GET /projects/:id/renders — stale sidecar status", () => {
it("does not mark an existing output failed from stale metadata", async () => {
const spy = vi.fn();
const { app, rendersDir, cleanup } = buildApp(spy);
try {
writeFileSync(join(rendersDir, "retry.mp4"), "valid-output");
writeFileSync(
join(rendersDir, "retry.meta.json"),
JSON.stringify({ status: "failed", error: "first attempt" }),
);
const res = await app.request("http://localhost/projects/demo/renders");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.renders).toEqual([expect.objectContaining({ id: "retry", status: "complete" })]);
} finally {
cleanup();
}
});
});
describe("POST /projects/:id/render — outputResolution forwarding", () => {
it("forwards a valid resolution preset to the adapter", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "high",
format: "mp4",
resolution: "landscape-4k",
}),
});
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
const opts = spy.mock.calls[0][0];
expect(opts.outputResolution).toBe("landscape-4k");
} finally {
cleanup();
}
});
it("omits outputResolution when the request does not specify one", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
});
expect(res.status).toBe(200);
const opts = spy.mock.calls[0][0];
expect(opts.outputResolution).toBeUndefined();
} finally {
cleanup();
}
});
it("drops an invalid resolution string (defense-in-depth, not a 400)", async () => {
// The route is intentionally lenient on unknown enum values — the producer
// is the source of truth for validation and emits a clear error message.
// We just want to make sure garbage doesn't propagate as if it were valid.
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", resolution: "8k" }),
});
expect(res.status).toBe(200);
const opts = spy.mock.calls[0][0];
expect(opts.outputResolution).toBeUndefined();
} finally {
cleanup();
}
});
// Contract pin per PR #2529 R2 review: the HTTP route accepts canonical
// presets only, not the CLI-side tier aliases (`1080p` / `hd` / `4k` /
// `uhd`, `landscape-4k` is already canonical). Miga asked that Studio
// Server's contract stay explicit here rather than silently extending it.
// Aliases fall through to the same `undefined` sink as any unknown value,
// which means the render falls back to composition dimensions — a safe,
// deliberate no-op rather than a portrait-1080p failure mode.
it.each(["1080p", "4k", "hd", "uhd"])(
"does NOT accept the CLI-side tier alias %s (canonical-only contract)",
async (alias) => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
resolution: alias,
}),
});
expect(res.status).toBe(200);
// Alias falls through to undefined → render uses composition dims.
// If Studio Server ever extends its contract to accept aliases,
// it must also plumb `outputResolutionAspectAgnostic` alongside so
// the compile stage can remap orientation; today the surface is
// deliberately narrow.
expect(spy.mock.calls[0][0].outputResolution).toBeUndefined();
} finally {
cleanup();
}
},
);
it("accepts each canonical preset value", async () => {
for (const preset of VALID_CANVAS_RESOLUTIONS) {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", resolution: preset }),
});
expect(spy.mock.calls[0][0].outputResolution).toBe(preset);
} finally {
cleanup();
}
}
});
});
describe("POST /projects/:id/render — composition forwarding", () => {
it("forwards a valid composition path to the adapter", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
composition: "compositions/intro.html",
}),
});
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
expect(spy.mock.calls[0][0].composition).toBe("compositions/intro.html");
} finally {
cleanup();
}
});
it("omits composition when not specified", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].composition).toBeUndefined();
} finally {
cleanup();
}
});
it("omits composition when empty string", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", composition: "" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].composition).toBeUndefined();
} finally {
cleanup();
}
});
it("rejects path-traversal attempts with 400", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
composition: "../../../etc/passwd",
}),
});
expect(res.status).toBe(400);
expect(spy).not.toHaveBeenCalled();
} finally {
cleanup();
}
});
});
describe("POST /projects/:id/render — fps wire format", () => {
// The fps fraction-syntax feature accepts JSON `number` (integer fps) and
// JSON `string` (ffmpeg-style rational) on the wire, normalizing both to
// the structured Fps form before invoking the adapter.
it("forwards integer fps as { num, den: 1 }", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 60, quality: "standard", format: "mp4" }),
});
expect(spy.mock.calls[0][0].fps).toEqual({ num: 60, den: 1 });
} finally {
cleanup();
}
});
it("parses '30000/1001' string body as exact NTSC", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: "30000/1001", quality: "standard", format: "mp4" }),
});
expect(spy.mock.calls[0][0].fps).toEqual({ num: 30000, den: 1001 });
} finally {
cleanup();
}
});
it("falls back to 30/1 for malformed fps values", async () => {
// Matches the lenient handling of `quality` and `resolution` in the same
// route — the producer surfaces a clearer downstream error if the value
// is genuinely unusable.
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: "abc", quality: "standard", format: "mp4" }),
});
expect(spy.mock.calls[0][0].fps).toEqual({ num: 30, den: 1 });
} finally {
cleanup();
}
});
it("falls back to 30/1 when fps is omitted", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ quality: "standard", format: "mp4" }),
});
expect(spy.mock.calls[0][0].fps).toEqual({ num: 30, den: 1 });
} finally {
cleanup();
}
});
});
describe("POST /projects/:id/render — composition path safety", () => {
const tmpDirs: string[] = [];
function buildAppWithProjectDir(spy: ReturnType<typeof vi.fn>): {
app: Hono;
projectDir: string;
} {
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-proj-"));
const rendersDir = mkdtempSync(join(tmpdir(), "hf-render-out-"));
tmpDirs.push(projectDir, rendersDir);
const adapter: StudioApiAdapter = {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: projectDir }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => rendersDir,
startRender: (opts) => {
spy(opts);
return { id: opts.jobId, status: "rendering", progress: 0, outputPath: opts.outputPath };
},
};
const app = new Hono();
registerRenderRoutes(app, adapter);
return { app, projectDir };
}
afterEach(() => {
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
tmpDirs.length = 0;
});
async function postComposition(app: Hono, composition: string): Promise<Response> {
return app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", composition }),
});
}
// Mirror the repo convention (preview.test.ts): skip symlink cases on
// non-symlink-privileged Windows runners rather than crash the suite.
function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
try {
symlinkSync(target, path, type);
return true;
} catch {
return false;
}
}
it("accepts a composition path inside the project directory", async () => {
const spy = vi.fn();
const { app } = buildAppWithProjectDir(spy);
const res = await postComposition(app, "scenes/intro.html");
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
});
it("rejects a `..` traversal in the composition path", async () => {
const spy = vi.fn();
const { app } = buildAppWithProjectDir(spy);
const res = await postComposition(app, "../../etc/passwd");
expect(res.status).toBe(400);
expect(spy).not.toHaveBeenCalled();
});
it("rejects a composition reached through an in-project symlink pointing outside the project", async () => {
const spy = vi.fn();
const { app, projectDir } = buildAppWithProjectDir(spy);
const external = mkdtempSync(join(tmpdir(), "hf-render-external-"));
tmpDirs.push(external);
writeFileSync(join(external, "secret.html"), "<html></html>");
if (!tryCreateSymlink(external, join(projectDir, "link"), "dir")) return;
const res = await postComposition(app, "link/secret.html");
expect(res.status).toBe(400);
expect(spy).not.toHaveBeenCalled();
});
it("allows a composition reached through an in-project symlink that stays inside the project", async () => {
const spy = vi.fn();
const { app, projectDir } = buildAppWithProjectDir(spy);
mkdirSync(join(projectDir, "real"));
writeFileSync(join(projectDir, "real", "scene.html"), "<html></html>");
if (!tryCreateSymlink(join(projectDir, "real"), join(projectDir, "alias"), "dir")) return;
const res = await postComposition(app, "alias/scene.html");
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
});
});
describe("GET /projects/:id/renders/file/* — path safety", () => {
const tmpDirs: string[] = [];
function buildApp(): { app: Hono; rendersDir: string } {
const rendersDir = mkdtempSync(join(tmpdir(), "hf-renders-out-"));
tmpDirs.push(rendersDir);
const adapter: StudioApiAdapter = {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: tmpdir() }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => rendersDir,
startRender: (opts) => ({
id: opts.jobId,
status: "rendering",
progress: 0,
outputPath: opts.outputPath,
}),
};
const app = new Hono();
registerRenderRoutes(app, adapter);
return { app, rendersDir };
}
// Mirror the repo convention (preview.test.ts / composition tests above):
// skip symlink cases on non-symlink-privileged Windows runners.
function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
try {
symlinkSync(target, path, type);
return true;
} catch {
return false;
}
}
afterEach(() => {
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
tmpDirs.length = 0;
});
it("serves a render file that lives inside rendersDir", async () => {
const { app, rendersDir } = buildApp();
writeFileSync(join(rendersDir, "demo.mp4"), "render-bytes");
const res = await app.request("http://localhost/projects/demo/renders/file/demo.mp4");
expect(res.status).toBe(200);
expect(await res.text()).toBe("render-bytes");
});
it("rejects a file reached through a symlink inside rendersDir pointing outside it", async () => {
const { app, rendersDir } = buildApp();
// A bare join()+readFileSync followed the symlink and leaked the target;
// the resolveWithinProject chokepoint canonicalizes with realpath first.
const external = mkdtempSync(join(tmpdir(), "hf-renders-external-"));
tmpDirs.push(external);
writeFileSync(join(external, "secret.txt"), "TOP-SECRET");
if (!tryCreateSymlink(join(external, "secret.txt"), join(rendersDir, "leak.txt"), "file"))
return;
const res = await app.request("http://localhost/projects/demo/renders/file/leak.txt");
expect(res.status).toBe(403);
expect(await res.text()).not.toContain("TOP-SECRET");
});
it("serves a render file reached through a symlink that stays inside rendersDir", async () => {
const { app, rendersDir } = buildApp();
mkdirSync(join(rendersDir, "nested"));
writeFileSync(join(rendersDir, "nested", "clip.mp4"), "nested-bytes");
if (!tryCreateSymlink(join(rendersDir, "nested"), join(rendersDir, "alias"), "dir")) return;
const res = await app.request("http://localhost/projects/demo/renders/file/alias/clip.mp4");
expect(res.status).toBe(200);
expect(await res.text()).toBe("nested-bytes");
});
});
describe("POST /render/:jobId/cancel", () => {
async function startJob(app: Hono): Promise<string> {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
});
expect(res.status).toBe(200);
return ((await res.json()) as { jobId: string }).jobId;
}
it("marks a rendering job cancelled and invokes the adapter abort hook", async () => {
const spy = vi.fn();
let aborted = false;
const { adapter, rendersDir } = createAdapter(spy);
const baseStartRender = adapter.startRender.bind(adapter);
adapter.startRender = (opts) => {
const state = baseStartRender(opts);
state.cancel = () => {
aborted = true;
};
return state;
};
const app = new Hono();
registerRenderRoutes(app, adapter);
try {
const jobId = await startJob(app);
const res = await app.request(`http://localhost/render/${jobId}/cancel`, { method: "POST" });
expect(res.status).toBe(200);
expect(((await res.json()) as { status: string }).status).toBe("cancelled");
expect(aborted).toBe(true);
// SSE progress for a cancelled job must terminate (status is terminal).
const progress = await app.request(`http://localhost/render/${jobId}/progress`);
expect(progress.status).toBe(200);
} finally {
rmSync(rendersDir, { recursive: true, force: true });
}
});
it("does not cancel a job that already completed", async () => {
const spy = vi.fn();
const states: Array<{ status: string }> = [];
const { adapter, rendersDir } = createAdapter(spy);
const baseStartRender = adapter.startRender.bind(adapter);
adapter.startRender = (opts) => {
const state = baseStartRender(opts);
states.push(state);
return state;
};
const app = new Hono();
registerRenderRoutes(app, adapter);
try {
const jobId = await startJob(app);
const [state] = states;
if (state) state.status = "complete";
const res = await app.request(`http://localhost/render/${jobId}/cancel`, { method: "POST" });
expect(res.status).toBe(200);
expect(((await res.json()) as { status: string }).status).toBe("complete");
} finally {
rmSync(rendersDir, { recursive: true, force: true });
}
});
it("404s for unknown jobs", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/render/nope/cancel", { method: "POST" });
expect(res.status).toBe(404);
} finally {
cleanup();
}
});
});
describe("POST /projects/:id/render — telemetryDistinctId forwarding", () => {
it("forwards the browser telemetryDistinctId to the adapter as distinctId", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
telemetryDistinctId: "browser-user-123",
}),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].distinctId).toBe("browser-user-123");
} finally {
cleanup();
}
});
it("passes undefined when no telemetryDistinctId is sent (older clients)", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].distinctId).toBeUndefined();
} finally {
cleanup();
}
});
// Explicit suppression, forwarded so the CLI can honour a browser opt-out
// it has no other way to observe.
it("forwards an explicit telemetryOptOut", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
telemetryOptOut: true,
}),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].telemetryOptOut).toBe(true);
} finally {
cleanup();
}
});
// An old client omits the flag, and a non-boolean is not a signal either.
// Defaulting those to "opted out" would silently drop every pre-upgrade
// render outcome.
it.each([undefined, false, "true"])(
"treats telemetryOptOut %s as not opted out",
async (flag) => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
telemetryOptOut: flag,
}),
});
expect(spy.mock.calls[0][0].telemetryOptOut).toBe(false);
} finally {
cleanup();
}
},
);
it("ignores a non-string telemetryDistinctId", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
telemetryDistinctId: 42,
}),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].distinctId).toBeUndefined();
} finally {
cleanup();
}
});
});
describe("POST /projects/:id/render — variables forwarding", () => {
it("forwards a variables object to the adapter", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
format: "mp4",
variables: { title: "Custom", count: 5, dark: true },
}),
});
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
expect(spy.mock.calls[0][0].variables).toEqual({ title: "Custom", count: 5, dark: true });
} finally {
cleanup();
}
});
it("omits variables from adapter opts when not provided", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ format: "mp4" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].variables).toBeUndefined();
} finally {
cleanup();
}
});
it("rejects non-object variables payloads with 400 (no silent drop)", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
for (const variables of [["a"], "str", 42, null]) {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ format: "mp4", variables }),
});
expect(res.status).toBe(400);
}
expect(spy).not.toHaveBeenCalled();
} finally {
cleanup();
}
});
});