refactor(runtime): replace Bun dependency with Node fetch

Run Perplexity search and auth requests through pi's Node runtime instead of
shelling out to Bun. Move development and CI commands to npm, add a Node test
build path, and keep OTP auth fail-fast when Set-Cookie headers are not
available.
This commit is contained in:
Ivan Pereira
2026-06-23 15:51:16 +01:00
parent 142640d96c
commit bbb5623f25
29 changed files with 4085 additions and 882 deletions
+12 -3
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import { afterEach, describe, expect, mock, test } from "../test-helpers.js";
import { AuthError, type StoredToken } from "../../src/search/types.js";
@@ -17,8 +17,15 @@ function createOpaqueToken(): string {
return "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0.part2.part3.part4.part5";
}
function csrfHeaders(): [string, string][] {
return [
["content-type", "application/json"],
["set-cookie", "next-auth.csrf-token=csrf-cookie; Path=/; HttpOnly"],
];
}
async function importLoginModule() {
return import(`../../src/auth/login.ts?test=${crypto.randomUUID()}`);
return import(`../../src/auth/login.js?test=${crypto.randomUUID()}`);
}
function restoreEnv(): void {
@@ -178,7 +185,7 @@ describe("auth/login", () => {
if (url.endsWith("/csrf")) {
return new Response(JSON.stringify({ csrfToken: "csrf-token" }), {
status: 200,
headers: { "content-type": "application/json" },
headers: csrfHeaders(),
});
}
@@ -221,6 +228,8 @@ describe("auth/login", () => {
const signinEmailRequest = fetchMock.mock.calls[1]?.[1] as RequestInit;
const signinOtpRequest = fetchMock.mock.calls[2]?.[1] as RequestInit;
expect(new Headers(signinEmailRequest.headers).get("Cookie")).toBe("next-auth.csrf-token=csrf-cookie");
expect(new Headers(signinOtpRequest.headers).get("Cookie")).toBe("next-auth.csrf-token=csrf-cookie");
expect(JSON.parse(String(signinEmailRequest.body))).toEqual({
email: "user@example.com",
csrfToken: "csrf-token",
+51 -9
View File
@@ -2,7 +2,7 @@
* OTP login flow tests derived from real captured request/response data.
* See scripts/debug-login-dump.json for the raw fixture.
*/
import { afterEach, describe, expect, mock, test } from "bun:test";
import { afterEach, describe, expect, mock, test } from "../test-helpers.js";
import { AuthError, type StoredToken } from "../../src/search/types.js";
@@ -21,11 +21,23 @@ const CSRF_TOKEN = "0e4f8cc491e3197788492604ad32577f2022747fe30e0f51ba3ba235f07c
const TEST_EMAIL = "user@test.com";
const TEST_OTP = "9f3e2-knzol";
const CSRF_COOKIES = [
"next-auth.csrf-token=csrf-cookie; Path=/; HttpOnly",
"cf_clearance=clearance-cookie; Path=/; Secure",
];
const CSRF_COOKIE_HEADER = "next-auth.csrf-token=csrf-cookie; cf_clearance=clearance-cookie";
// ---
function jsonHeaders(cookies: string[] = []): [string, string][] {
return [
["content-type", "application/json; charset=utf-8"],
...cookies.map((cookie): [string, string] => ["set-cookie", cookie]),
];
}
async function importLoginModule() {
return import(`../../src/auth/login.ts?test=${crypto.randomUUID()}`);
return import(`../../src/auth/login.js?test=${crypto.randomUUID()}`);
}
function restoreEnv(): void {
@@ -74,21 +86,21 @@ function buildReplayFetchMock(options?: {
if (url.endsWith("/csrf")) {
return new Response(JSON.stringify({ csrfToken: CSRF_TOKEN }), {
status: 200,
headers: { "content-type": "application/json; charset=utf-8" },
headers: jsonHeaders(CSRF_COOKIES),
});
}
if (url.endsWith("/signin-email")) {
return new Response(JSON.stringify({ success: "Email sign in triggered" }), {
status: 200,
headers: { "content-type": "application/json; charset=utf-8" },
headers: jsonHeaders(),
});
}
if (url.endsWith("/signin-otp")) {
return new Response(JSON.stringify(otpBody), {
status: 200,
headers: { "content-type": "application/json; charset=utf-8" },
headers: jsonHeaders(),
});
}
@@ -165,15 +177,17 @@ describe("OTP login flow (from real captured responses)", () => {
expect(calls[0].init?.method ?? "GET").toBe("GET");
expect(calls[0].init?.body).toBeFalsy();
// signin-email: POST with email + csrfToken
// signin-email: POST with email + csrfToken + CSRF cookies
expect(calls[1].init?.method).toBe("POST");
expect(new Headers(calls[1].init?.headers).get("Cookie")).toBe(CSRF_COOKIE_HEADER);
expect(JSON.parse(String(calls[1].init?.body))).toEqual({
email: TEST_EMAIL,
csrfToken: CSRF_TOKEN,
});
// signin-otp: POST with email + otp + csrfToken
// signin-otp: POST with email + otp + csrfToken + CSRF cookies
expect(calls[2].init?.method).toBe("POST");
expect(new Headers(calls[2].init?.headers).get("Cookie")).toBe(CSRF_COOKIE_HEADER);
expect(JSON.parse(String(calls[2].init?.body))).toEqual({
email: TEST_EMAIL,
otp: TEST_OTP,
@@ -246,6 +260,34 @@ describe("OTP login flow (from real captured responses)", () => {
expect((thrown as AuthError).code).toBe("NO_TOKEN");
});
test("throws when CSRF response does not include auth cookies", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
mockStorage();
const fetchMock = mock(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/csrf")) {
return new Response(JSON.stringify({ csrfToken: CSRF_TOKEN }), {
status: 200,
headers: jsonHeaders(),
});
}
return new Response("should not continue", { status: 500 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
await expect(authenticate({ promptForEmail: async () => TEST_EMAIL })).rejects.toMatchObject({
name: "AuthError",
code: "EXTRACTION_FAILED",
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("throws when OTP verification returns non-200", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
@@ -257,14 +299,14 @@ describe("OTP login flow (from real captured responses)", () => {
if (url.endsWith("/csrf")) {
return new Response(JSON.stringify({ csrfToken: CSRF_TOKEN }), {
status: 200,
headers: { "content-type": "application/json" },
headers: jsonHeaders(CSRF_COOKIES),
});
}
if (url.endsWith("/signin-email")) {
return new Response(JSON.stringify({ success: "Email sign in triggered" }), {
status: 200,
headers: { "content-type": "application/json" },
headers: jsonHeaders(),
});
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import { afterEach, describe, expect, mock, test } from "../test-helpers.js";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
async function importStorageModule() {
return import(`../../src/auth/storage.ts?test=${crypto.randomUUID()}`);
return import(`../../src/auth/storage.js?test=${crypto.randomUUID()}`);
}
afterEach(() => {
+2 -2
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "./test-helpers.js";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -15,7 +15,7 @@ beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-perplexity-command-test-"));
configPath = join(tempDir, "config.json");
const mod = await import(`../src/config.ts?t=${Date.now()}`);
const mod = await import(`../src/config.js?t=${Date.now()}`);
loadConfig = mod.loadConfig;
saveConfig = mod.saveConfig;
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "./test-helpers.js";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -17,7 +17,7 @@ beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-perplexity-test-"));
configPath = join(tempDir, "config.json");
const mod = await import(`../src/config.ts?t=${Date.now()}`);
const mod = await import(`../src/config.js?t=${Date.now()}`);
loadConfig = mod.loadConfig;
saveConfig = mod.saveConfig;
resolveSearchDefaults = mod.resolveSearchDefaults;
+3 -3
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "./test-helpers.js";
import { loadToken } from "../src/auth/storage.js";
import { searchPerplexity } from "../src/search/client.js";
@@ -35,10 +35,11 @@ async function delay(ms: number): Promise<void> {
describe("Perplexity model selection e2e", () => {
maybeTest(
"sends requested model slugs and receives matching display_model values",
{ timeout: 180_000 },
async () => {
const token = await loadToken();
if (!token) {
throw new Error("No cached Perplexity token. Run /perplexity-login before PI_PERPLEXITY_E2E=1 bun test.");
throw new Error("No cached Perplexity token. Run /perplexity-login before PI_PERPLEXITY_E2E=1 npm test.");
}
const models = configuredModels();
@@ -63,6 +64,5 @@ describe("Perplexity model selection e2e", () => {
}
}
},
{ timeout: 180_000 },
);
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import { afterEach, describe, expect, mock, test } from "./test-helpers.js";
afterEach(() => {
mock.restore();
@@ -25,7 +25,7 @@ describe("perplexity_search execute", () => {
}));
mock.module("../src/search/client.js", () => ({ searchPerplexity }));
const { default: registerExtension } = await import(`../src/index.ts?test=${crypto.randomUUID()}`);
const { default: registerExtension } = await import(`../src/index.js?test=${crypto.randomUUID()}`);
let execute: ((toolCallId: string, params: any, signal?: AbortSignal, onUpdate?: any, ctx?: any) => Promise<any>) | undefined;
let parameters: unknown;
+2 -2
View File
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "./test-helpers.js";
describe("extension entrypoint", () => {
test("registers the config command", async () => {
const { default: registerExtension } = await import(`../src/index.ts?test=${crypto.randomUUID()}`);
const { default: registerExtension } = await import(`../src/index.js?test=${crypto.randomUUID()}`);
const commands: string[] = [];
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, mock, test } from "./test-helpers.js";
import { perplexityFetchText } from "../src/perplexity-fetch.js";
const originalFetch = globalThis.fetch;
const originalGetSetCookie = (Headers.prototype as Headers & { getSetCookie?: () => string[] }).getSetCookie;
afterEach(() => {
globalThis.fetch = originalFetch;
Object.defineProperty(Headers.prototype, "getSetCookie", {
configurable: true,
writable: true,
value: originalGetSetCookie,
});
});
describe("perplexityFetchText", () => {
test("returns all Set-Cookie headers from Node fetch", async () => {
globalThis.fetch = mock(async () =>
new Response("ok", {
status: 200,
headers: [
["set-cookie", "first=1; Path=/; HttpOnly"],
["set-cookie", "second=2; Path=/; Secure"],
],
}),
) as unknown as typeof fetch;
const response = await perplexityFetchText("https://example.com", {
method: "GET",
headers: {},
});
expect(response.ok).toBe(true);
expect(response.status).toBe(200);
expect(response.bodyText).toBe("ok");
expect(response.cookies).toEqual([
"first=1; Path=/; HttpOnly",
"second=2; Path=/; Secure",
]);
});
test("fails when the Node fetch runtime cannot expose Set-Cookie headers", async () => {
Object.defineProperty(Headers.prototype, "getSetCookie", {
configurable: true,
writable: true,
value: undefined,
});
globalThis.fetch = mock(async () => new Response("ok", { status: 200 })) as unknown as typeof fetch;
await expect(perplexityFetchText("https://example.com", { method: "GET", headers: {} })).rejects.toThrow(
"Headers.getSetCookie",
);
});
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "./test-helpers.js";
import { renderPerplexityCall } from "../src/render/call.js";
import { renderPerplexityResult } from "../src/render/result.js";
@@ -12,7 +12,7 @@ describe("renderPerplexityCall", () => {
test("shows the selected model in the tool call row", () => {
const rendered = renderPerplexityCall(
{
query: "latest bun release notes",
query: "latest Node release notes",
model: "claude46sonnetthinking",
recency: "week",
limit: 5,
+28 -5
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "../test-helpers.js";
import { SearchError } from "../../src/search/types.js";
@@ -20,7 +20,7 @@ describe("searchPerplexity", () => {
const originalFetch = globalThis.fetch;
beforeEach(async () => {
const mod = await import(`../../src/search/client.ts?t=${Date.now()}`);
const mod = await import(`../../src/search/client.js?t=${Date.now()}`);
searchPerplexity = mod.searchPerplexity;
});
@@ -57,7 +57,7 @@ describe("searchPerplexity", () => {
const controller = new AbortController();
const result = await searchPerplexity(
{ query: "latest bun release notes", recency: "week", model: "pplx_pro_upgraded", incognito: true },
{ query: "latest Node release notes", recency: "week", model: "pplx_pro_upgraded", incognito: true },
"jwt-token",
controller.signal,
);
@@ -85,8 +85,8 @@ describe("searchPerplexity", () => {
};
};
expect(body.query_str).toBe("latest bun release notes");
expect(body.params.query_str).toBe("latest bun release notes");
expect(body.query_str).toBe("latest Node release notes");
expect(body.params.query_str).toBe("latest Node release notes");
expect(body.params.mode).toBe("copilot");
expect(body.params.model_preference).toBe("pplx_pro_upgraded");
expect(body.params.is_incognito).toBe(true);
@@ -138,6 +138,29 @@ describe("searchPerplexity", () => {
expect(body.params.is_incognito).toBe(true);
});
test("cancels the response body after a terminal event", async () => {
let cancelCalled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('data: {"status":"COMPLETED","final":true,"text":"answer"}\n\n'));
},
cancel() {
cancelCalled = true;
},
});
globalThis.fetch = (async () =>
new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
})) as unknown as typeof fetch;
const result = await searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt");
expect(result.answer).toBe("answer");
expect(cancelCalled).toBe(true);
});
test("maps 401 and 403 responses to AUTH error", async () => {
for (const status of [401, 403]) {
globalThis.fetch = (async () => new Response("auth fail", { status })) as unknown as typeof fetch;
+1 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "../test-helpers.js";
import { formatForLLM } from "../../src/search/format.js";
+7 -7
View File
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test";
import { readFile } from "node:fs/promises";
import { describe, expect, test } from "../test-helpers.js";
import { mergeEvent, mergeMarkdownBlock, readSseEvents } from "../../src/search/stream.js";
import type { StreamEvent } from "../../src/search/types.js";
@@ -26,7 +28,7 @@ async function collectEvents(stream: ReadableStream<Uint8Array>): Promise<Stream
describe("SSE stream parsing", () => {
test("parses multiline data payloads and stops at [DONE]", async () => {
const fixture = await Bun.file("test/fixtures/sse-basic.txt").text();
const fixture = await readFile("test/fixtures/sse-basic.txt", "utf8");
const events = await collectEvents(streamFromString(fixture, 5));
expect(events).toHaveLength(2);
@@ -36,7 +38,7 @@ describe("SSE stream parsing", () => {
expect(events[1].final).toBe(true);
});
test("skips invalid JSON payloads and continues parsing", async () => {
test("throws on invalid JSON payloads", async () => {
const payload = [
"data: {invalid-json}",
"",
@@ -46,9 +48,7 @@ describe("SSE stream parsing", () => {
"",
].join("\n");
const events = await collectEvents(streamFromString(payload));
expect(events).toHaveLength(1);
expect(events[0].text).toBe("ok");
await expect(collectEvents(streamFromString(payload))).rejects.toThrow(SyntaxError);
});
});
@@ -89,7 +89,7 @@ describe("event merging", () => {
});
test("incremental fixture merges markdown and metadata", async () => {
const fixture = await Bun.file("test/fixtures/sse-incremental.txt").text();
const fixture = await readFile("test/fixtures/sse-incremental.txt", "utf8");
let snapshot: StreamEvent = {};
for await (const event of readSseEvents(streamFromString(fixture, 11))) {
+274
View File
@@ -0,0 +1,274 @@
import { AssertionError } from "node:assert";
import { afterEach, beforeEach, describe, mock as nodeMock, test } from "node:test";
import { isDeepStrictEqual } from "node:util";
export { afterEach, beforeEach, describe, test };
type AnyFunction = (...args: any[]) => any;
type MockFunction<T extends AnyFunction> = ((...args: Parameters<T>) => ReturnType<T>) & {
mock: { calls: Parameters<T>[] };
};
interface MockApi {
<T extends AnyFunction>(implementation?: T): MockFunction<T>;
module(specifier: string, factory: () => Record<string, unknown>): unknown;
restore(): void;
}
function fail(message: string): never {
throw new AssertionError({ message });
}
function format(value: unknown): string {
return typeof value === "string" ? JSON.stringify(value) : String(value);
}
function createMock<T extends AnyFunction>(implementation?: T): MockFunction<T> {
const calls: Parameters<T>[] = [];
const fn = function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
calls.push(args);
return implementation?.apply(this, args) as ReturnType<T>;
} as MockFunction<T>;
fn.mock = { calls };
return fn;
}
function callerUrl(): string | null {
const stack = new Error().stack?.split("\n") ?? [];
for (const line of stack) {
if (line.includes("test-helpers.js") || line.includes("test-helpers.ts")) {
continue;
}
const match = line.match(/(file:\/\/.*):(\d+):(\d+)/);
if (match) {
return match[1];
}
}
return null;
}
function resolveMockSpecifier(specifier: string): string {
if (!specifier.startsWith(".") && !specifier.startsWith("/")) {
return specifier;
}
const base = callerUrl();
return base ? new URL(specifier, base).href : specifier;
}
export const mock: MockApi = Object.assign(
<T extends AnyFunction>(implementation?: T) => createMock(implementation),
{
module(specifier: string, factory: () => Record<string, unknown>) {
const moduleMock = nodeMock.module as unknown as (
specifier: string,
options: { exports: Record<string, unknown> },
) => unknown;
return moduleMock.call(nodeMock, resolveMockSpecifier(specifier), { exports: factory() });
},
restore() {
nodeMock.restoreAll();
},
},
);
function getCalls(value: unknown): unknown[][] {
const calls = (value as { mock?: { calls?: unknown[] } }).mock?.calls;
if (!Array.isArray(calls)) {
return [];
}
return calls.map((call) => {
if (Array.isArray(call)) {
return call;
}
if (call && typeof call === "object" && "arguments" in call) {
return Array.from((call as { arguments: Iterable<unknown> }).arguments);
}
return [];
});
}
function matchesObject(actual: unknown, expected: unknown): boolean {
if (!expected || typeof expected !== "object") {
return isDeepStrictEqual(actual, expected);
}
if (!actual || typeof actual !== "object") {
return false;
}
for (const [key, expectedValue] of Object.entries(expected)) {
if (!(key in actual)) {
return false;
}
const actualValue = (actual as Record<string, unknown>)[key];
if (!matchesObject(actualValue, expectedValue)) {
return false;
}
}
return true;
}
function hasProperty(actual: unknown, property: string): boolean {
return actual !== null && actual !== undefined && property in Object(actual);
}
function matchesThrown(error: unknown, expected?: unknown): boolean {
if (expected === undefined) {
return true;
}
if (typeof expected === "string") {
return error instanceof Error && error.message.includes(expected);
}
if (expected instanceof RegExp) {
return error instanceof Error && expected.test(error.message);
}
if (typeof expected === "function") {
return error instanceof (expected as new (...args: never[]) => Error);
}
return matchesObject(error, expected);
}
function assertPass(pass: boolean, negated: boolean, message: string): void {
if (negated ? pass : !pass) {
fail(`${negated ? "Expected not: " : "Expected: "}${message}`);
}
}
interface Matchers {
not: Matchers;
rejects: {
toMatchObject(expected: unknown): Promise<void>;
toThrow(expected?: unknown): Promise<void>;
};
toBe(expected: unknown): void;
toEqual(expected: unknown): void;
toContain(expected: unknown): void;
toContainEqual(expected: unknown): void;
toBeNull(): void;
toBeDefined(): void;
toBeTruthy(): void;
toBeFalsy(): void;
toHaveLength(expected: number): void;
toHaveProperty(property: string): void;
toBeInstanceOf(expected: new (...args: any[]) => unknown): void;
toBeLessThan(expected: number): void;
toMatchObject(expected: unknown): void;
toHaveBeenCalledTimes(expected: number): void;
toHaveBeenCalledWith(...expectedArgs: unknown[]): void;
toThrow(expected?: unknown): void;
}
function createMatchers(actual: unknown, negated = false): Matchers {
const matchers = {
get not() {
return createMatchers(actual, !negated);
},
rejects: {
async toMatchObject(expected: unknown) {
let rejected: unknown;
try {
await actual;
} catch (error) {
rejected = error;
}
assertPass(rejected !== undefined && matchesObject(rejected, expected), negated, "promise to reject with matching object");
},
async toThrow(expected?: unknown) {
let rejected: unknown;
try {
await actual;
} catch (error) {
rejected = error;
}
assertPass(rejected !== undefined && matchesThrown(rejected, expected), negated, "promise to reject with matching error");
},
},
toBe(expected: unknown) {
assertPass(Object.is(actual, expected), negated, `${format(actual)} to be ${format(expected)}`);
},
toEqual(expected: unknown) {
assertPass(isDeepStrictEqual(actual, expected), negated, `${format(actual)} to equal ${format(expected)}`);
},
toContain(expected: unknown) {
const pass =
typeof actual === "string"
? actual.includes(String(expected))
: Array.isArray(actual) && actual.includes(expected);
assertPass(pass, negated, `${format(actual)} to contain ${format(expected)}`);
},
toContainEqual(expected: unknown) {
const pass = Array.isArray(actual) && actual.some((value) => isDeepStrictEqual(value, expected));
assertPass(pass, negated, `${format(actual)} to contain equal ${format(expected)}`);
},
toBeNull() {
assertPass(actual === null, negated, `${format(actual)} to be null`);
},
toBeDefined() {
assertPass(actual !== undefined, negated, `${format(actual)} to be defined`);
},
toBeTruthy() {
assertPass(Boolean(actual), negated, `${format(actual)} to be truthy`);
},
toBeFalsy() {
assertPass(!actual, negated, `${format(actual)} to be falsy`);
},
toHaveLength(expected: number) {
assertPass((actual as { length?: number })?.length === expected, negated, `${format(actual)} to have length ${expected}`);
},
toHaveProperty(property: string) {
assertPass(hasProperty(actual, property), negated, `${format(actual)} to have property ${property}`);
},
toBeInstanceOf(expected: new (...args: any[]) => unknown) {
assertPass(actual instanceof expected, negated, `${format(actual)} to be instance of ${expected.name}`);
},
toBeLessThan(expected: number) {
assertPass(typeof actual === "number" && actual < expected, negated, `${format(actual)} to be less than ${expected}`);
},
toMatchObject(expected: unknown) {
assertPass(matchesObject(actual, expected), negated, `${format(actual)} to match object ${format(expected)}`);
},
toHaveBeenCalledTimes(expected: number) {
assertPass(getCalls(actual).length === expected, negated, `mock to be called ${expected} times`);
},
toHaveBeenCalledWith(...expectedArgs: unknown[]) {
const pass = getCalls(actual).some((args) => isDeepStrictEqual(args, expectedArgs));
assertPass(pass, negated, `mock to be called with ${format(expectedArgs)}`);
},
toThrow(expected?: unknown) {
if (typeof actual !== "function") {
fail("Expected value to be a function");
}
let thrown: unknown;
try {
actual();
} catch (error) {
thrown = error;
}
assertPass(thrown !== undefined && matchesThrown(thrown, expected), negated, "function to throw matching error");
},
} satisfies Matchers;
return matchers;
}
export function expect(actual: unknown): Matchers {
return createMatchers(actual);
}