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:
+12
-3
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user