diff --git a/.oxlintrc.json b/.oxlintrc.json index 0faaf34..abc2e07 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["typescript", "oxc", "import", "jsx-a11y"], + "plugins": ["typescript", "oxc", "import", "jsx-a11y", "vitest"], "jsPlugins": ["@e18e/eslint-plugin"], "options": { "typeAware": true diff --git a/db/index.test.ts b/db/index.test.ts index d89d30e..a9927d6 100644 --- a/db/index.test.ts +++ b/db/index.test.ts @@ -8,8 +8,10 @@ describe("db connection", () => { it("creates and caches the native connection lazily", async () => { const database = { sql: {} }; - const getConnectionString = vi.fn(() => "postgres://app:secret@database.example/test"); - const getDatabase = vi.fn(() => database); + const getConnectionString = vi.fn<() => string>( + () => "postgres://app:secret@database.example/test", + ); + const getDatabase = vi.fn<() => typeof database>(() => database); vi.doMock("@netlify/database", () => ({ getConnectionString, getDatabase })); const mod = await import("./index"); @@ -25,8 +27,8 @@ describe("db connection", () => { it("adds the username missing from Netlify's local Database URL", async () => { const database = { sql: {} }; - const getConnectionString = vi.fn(() => "postgres://localhost:5432/postgres"); - const getDatabase = vi.fn(() => database); + const getConnectionString = vi.fn<() => string>(() => "postgres://localhost:5432/postgres"); + const getDatabase = vi.fn<() => typeof database>(() => database); vi.doMock("@netlify/database", () => ({ getConnectionString, getDatabase })); const mod = await import("./index"); @@ -41,10 +43,10 @@ describe("db connection", () => { }); it("does not hide a missing Database configuration", async () => { - const getConnectionString = vi.fn(() => { + const getConnectionString = vi.fn<() => string>(() => { throw new Error("Database is not configured"); }); - const getDatabase = vi.fn(); + const getDatabase = vi.fn<() => void>(); vi.doMock("@netlify/database", () => ({ getConnectionString, getDatabase })); const mod = await import("./index"); diff --git a/db/schema.test.ts b/db/schema.test.ts index fb861c5..dd56374 100644 --- a/db/schema.test.ts +++ b/db/schema.test.ts @@ -38,7 +38,7 @@ describe("database row contracts", () => { updatedAt: new Date(), }, ]), - ).toThrow(); + ).toThrow(/Expected.*running.*done.*error.*finished/); }); it("serializes arrays as JSON rather than Postgres array parameters", () => { diff --git a/netlify/tests/edge-functions/audit-stream.test.ts b/netlify/tests/edge-functions/audit-stream.test.ts index 43d429a..c704aeb 100644 --- a/netlify/tests/edge-functions/audit-stream.test.ts +++ b/netlify/tests/edge-functions/audit-stream.test.ts @@ -2,12 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocked = vi.hoisted(() => ({ - createJobIfAbsent: vi.fn(), - finishJob: vi.fn(), - getJob: vi.fn(), - runAudit: vi.fn(), - saveReportSnapshot: vi.fn(), - updateJobLog: vi.fn(), + createJobIfAbsent: + vi.fn(), + finishJob: vi.fn(), + getJob: vi.fn(), + runAudit: vi.fn(), + saveReportSnapshot: + vi.fn(), + updateJobLog: vi.fn(), })); vi.mock("../../functions/_shared/audit-jobs.ts", () => ({ diff --git a/netlify/tests/edge-functions/user-publishes-stream.test.ts b/netlify/tests/edge-functions/user-publishes-stream.test.ts index aa63e2b..6d614a9 100644 --- a/netlify/tests/edge-functions/user-publishes-stream.test.ts +++ b/netlify/tests/edge-functions/user-publishes-stream.test.ts @@ -1,7 +1,9 @@ // @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const runUserPublishes = vi.hoisted(() => vi.fn()); +const runUserPublishes = vi.hoisted(() => + vi.fn(), +); vi.mock("../../../src/lib/reports.ts", () => ({ runUserPublishes })); diff --git a/src/App.test.ts b/src/App.test.ts index a39da3c..934d783 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -6,11 +6,16 @@ import { auditResult } from "./test/fixtures"; import { streamAudit } from "./lib/auditStream"; import { formatCompactDateTime } from "./lib/dateFormatting"; import { streamUserPublishes } from "./lib/userPublishStream"; +import { mockFetch, mockResolvedFetch } from "./test/mock"; import { requestUrl } from "./test/request"; // Both server-side workflows stream through these client adapters. -vi.mock("./lib/auditStream", () => ({ streamAudit: vi.fn() })); -vi.mock("./lib/userPublishStream", () => ({ streamUserPublishes: vi.fn() })); +vi.mock("./lib/auditStream", () => ({ + streamAudit: vi.fn(), +})); +vi.mock("./lib/userPublishStream", () => ({ + streamUserPublishes: vi.fn(), +})); const mockedStreamAudit = vi.mocked(streamAudit); const mockedStreamUserPublishes = vi.mocked(streamUserPublishes); @@ -32,7 +37,7 @@ describe("App", () => { vi.unstubAllGlobals(); vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ + mockResolvedFetch({ ok: true, json: async () => ({ orgs: [], points: [] }), }), @@ -157,7 +162,7 @@ describe("App", () => { test("streams an audit, renders results, shows the saved link, and copies it on request", async () => { const user = userEvent.setup(); const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const fetchMock = mockFetch(async (input) => { if (requestUrl(input) === "/api/reports/netlify-2026-06-27-abc12345/schedule-daily") { return { ok: true, @@ -173,7 +178,7 @@ describe("App", () => { } return { ok: true, json: async () => ({ orgs: ["netlify"], points: [] }) }; }); - const scrollIntoView = vi.fn(); + const scrollIntoView = vi.fn(); Object.defineProperty(window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: scrollIntoView, diff --git a/src/AppRouter.test.ts b/src/AppRouter.test.ts index f6cf228..e7bc875 100644 --- a/src/AppRouter.test.ts +++ b/src/AppRouter.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import AppRouter from "./AppRouter.svelte"; import { formatDateTime } from "./lib/dateFormatting"; import { auditResult, trustReport } from "./test/fixtures"; +import { mockFetch, mockResolvedFetch } from "./test/mock"; import { requestUrl } from "./test/request"; const points = [ @@ -57,7 +58,7 @@ afterEach(() => { describe("AppRouter", () => { test("decodes report ids from direct permalink loads", async () => { window.history.replaceState(null, "", "/report/netlify%20report/"); - const fetchMock = vi.fn().mockResolvedValue({ + const fetchMock = mockResolvedFetch({ ok: true, status: 200, json: async () => ({ @@ -87,7 +88,7 @@ describe("AppRouter", () => { }>((resolve) => { resolveSecond = resolve; }); - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const fetchMock = mockFetch(async (input) => { const url = requestUrl(input); if (url === "/api/reports/one") { return { ok: true, status: 200, json: async () => reportRecord("one", "first") }; @@ -146,7 +147,7 @@ describe("AppRouter", () => { window.history.replaceState(null, "", "/report/one"); vi.stubGlobal( "fetch", - vi.fn(async (input: RequestInfo | URL) => ({ + mockFetch(async (input) => ({ ok: true, status: 200, json: async () => diff --git a/src/SharedReport.test.ts b/src/SharedReport.test.ts index 9e08db2..c43ca64 100644 --- a/src/SharedReport.test.ts +++ b/src/SharedReport.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import SharedReport from "./SharedReport.svelte"; import { formatCompactDateTime, formatDate, formatDateTime } from "./lib/dateFormatting"; import { auditResult, trustReport } from "./test/fixtures"; +import { mockFetch, mockResolvedFetch } from "./test/mock"; import { requestUrl } from "./test/request"; afterEach(() => vi.unstubAllGlobals()); @@ -11,7 +12,7 @@ describe("SharedReport", () => { test("loads and renders a read-only shared report", async () => { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ + mockResolvedFetch({ ok: true, status: 200, json: async () => ({ @@ -72,7 +73,7 @@ describe("SharedReport", () => { }>((resolve) => { resolveHistory = resolve; }); - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const fetchMock = mockFetch(async (input) => { if (requestUrl(input) === "/api/reports/report-id") { return { ok: true, @@ -155,7 +156,7 @@ describe("SharedReport", () => { }); test("still renders the report when its history request fails", async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const fetchMock = mockFetch(async (input) => { if (requestUrl(input) === "/api/reports/report-id") { return { ok: true, @@ -194,7 +195,7 @@ describe("SharedReport", () => { test("shows the specific not-found error for 404s", async () => { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ + mockResolvedFetch({ ok: false, status: 404, }), @@ -209,7 +210,7 @@ describe("SharedReport", () => { test("rejects malformed stored report data at the client boundary", async () => { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ + mockResolvedFetch({ ok: true, status: 200, json: async () => ({ id: "report-id", payload: { failures: [] } }), @@ -226,7 +227,7 @@ describe("SharedReport", () => { test("shows the upstream status when loading a report fails", async () => { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ + mockResolvedFetch({ ok: false, status: 503, }), diff --git a/src/components/DailyTrackingButton.test.ts b/src/components/DailyTrackingButton.test.ts index 771c1dc..a0b84a9 100644 --- a/src/components/DailyTrackingButton.test.ts +++ b/src/components/DailyTrackingButton.test.ts @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, test, vi } from "vitest"; import { formatCompactDateTime, formatDateTime } from "../lib/dateFormatting"; +import { mockFetch, mockResolvedFetch } from "../test/mock"; import DailyTrackingButton from "./DailyTrackingButton.svelte"; afterEach(() => vi.unstubAllGlobals()); @@ -10,7 +11,7 @@ describe("DailyTrackingButton", () => { test("renders an existing schedule as a compact status without making a request", () => { const nextRunAt = "2026-06-28T12:34:56.000Z"; const nextRun = formatCompactDateTime(nextRunAt); - const fetchMock = vi.fn(); + const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); render(DailyTrackingButton, { @@ -35,7 +36,7 @@ describe("DailyTrackingButton", () => { const user = userEvent.setup(); vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ + mockResolvedFetch({ ok: true, json: async () => ({ orgs: ["netlify"], @@ -65,7 +66,10 @@ describe("DailyTrackingButton", () => { ok: boolean; json: () => Promise; }>(); - vi.stubGlobal("fetch", vi.fn().mockReturnValue(request.promise)); + vi.stubGlobal( + "fetch", + mockFetch(() => request.promise), + ); render(DailyTrackingButton, { props: { reportId: "report-id" } }); await user.click(screen.getByRole("button", { name: "Track daily" })); @@ -101,8 +105,8 @@ describe("DailyTrackingButton", () => { ], ])("shows %s and permits a retry", async (_name, response, message) => { const user = userEvent.setup(); - const onToast = vi.fn(); - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); + const onToast = vi.fn<(message: string) => void>(); + vi.stubGlobal("fetch", mockResolvedFetch(response)); render(DailyTrackingButton, { props: { reportId: "report/id", onToast }, diff --git a/src/components/ExportButtons.test.ts b/src/components/ExportButtons.test.ts index 83de018..3a19f17 100644 --- a/src/components/ExportButtons.test.ts +++ b/src/components/ExportButtons.test.ts @@ -10,7 +10,7 @@ afterEach(() => { describe("ExportButtons", () => { test("copies JSON and reports success or clipboard failure", async () => { const user = userEvent.setup(); - const onToast = vi.fn(); + const onToast = vi.fn<(message: string) => void>(); const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); render(ExportButtons, { props: { @@ -38,7 +38,7 @@ describe("ExportButtons", () => { test("downloads CSV and reports the generated filename", async () => { const user = userEvent.setup(); - const onToast = vi.fn(); + const onToast = vi.fn<(message: string) => void>(); const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:test"); const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); diff --git a/src/components/ExternalView.test.ts b/src/components/ExternalView.test.ts index ad751c8..d967ede 100644 --- a/src/components/ExternalView.test.ts +++ b/src/components/ExternalView.test.ts @@ -7,7 +7,7 @@ describe("ExternalView", () => { render(ExternalView, { props: { report: { rows: [], distinctUsers: 0, byUser: [] }, - onToast: vi.fn(), + onToast: vi.fn<(message: string) => void>(), }, }); @@ -26,7 +26,7 @@ describe("ExternalView", () => { { user: "mallory", pkg: "beta" }, ], }, - onToast: vi.fn(), + onToast: vi.fn<(message: string) => void>(), }, }); diff --git a/src/components/HistoryPanel.test.ts b/src/components/HistoryPanel.test.ts index f30d921..a36b069 100644 --- a/src/components/HistoryPanel.test.ts +++ b/src/components/HistoryPanel.test.ts @@ -1,6 +1,7 @@ import { fireEvent, render, screen, within } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import { mockFetch, mockResolvedFetch } from "../test/mock"; import { formatDate, formatDateTime } from "../lib/dateFormatting"; import type { ReportHistoryResponse, ReportTrustHistoryPoint } from "../lib/reportHistory"; import HistoryPanel from "./HistoryPanel.svelte"; @@ -8,7 +9,7 @@ import HistoryPanel from "./HistoryPanel.svelte"; function mockHistory(body: ReportHistoryResponse) { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ + mockResolvedFetch({ ok: true, json: async () => body, }), @@ -45,7 +46,10 @@ describe("HistoryPanel", () => { }); test("exposes its loading state", () => { - vi.stubGlobal("fetch", vi.fn().mockReturnValue(new Promise(() => {}))); + vi.stubGlobal( + "fetch", + mockFetch(() => new Promise(() => {})), + ); render(HistoryPanel, { props: { orgs: ["netlify"] } }); @@ -90,7 +94,7 @@ describe("HistoryPanel", () => { test("falls back to the empty state when history cannot load", async () => { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ + mockResolvedFetch({ ok: false, status: 502, json: async () => ({}), diff --git a/src/components/ManualView.test.ts b/src/components/ManualView.test.ts index 95b0247..3144d20 100644 --- a/src/components/ManualView.test.ts +++ b/src/components/ManualView.test.ts @@ -12,7 +12,7 @@ describe("ManualView", () => { byPublisher: [{ who: "alice", count: 1 }], rows: [{ when: "2026-06-01T02:03:04.000Z", who: "alice", ref: "pkg@1.0.0" }], }, - onToast: vi.fn(), + onToast: vi.fn<(message: string) => void>(), }, }); diff --git a/src/components/RecentReports.test.ts b/src/components/RecentReports.test.ts index ad18d2c..c1a5013 100644 --- a/src/components/RecentReports.test.ts +++ b/src/components/RecentReports.test.ts @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/svelte"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import { mockFetch, mockResolvedFetch } from "../test/mock"; import { formatDate, formatDateTime } from "../lib/dateFormatting"; import type { RecentTrustReportsResponse } from "../lib/reportHistory"; import RecentReports from "./RecentReports.svelte"; @@ -7,7 +8,7 @@ import RecentReports from "./RecentReports.svelte"; function mockRecentReports(body: RecentTrustReportsResponse) { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ + mockResolvedFetch({ ok: true, json: async () => body, }), @@ -20,7 +21,10 @@ describe("RecentReports", () => { }); test("exposes its loading state", () => { - vi.stubGlobal("fetch", vi.fn().mockReturnValue(new Promise(() => {}))); + vi.stubGlobal( + "fetch", + mockFetch(() => new Promise(() => {})), + ); render(RecentReports); @@ -73,7 +77,7 @@ describe("RecentReports", () => { test("falls back to the empty state when recent reports cannot load", async () => { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ + mockResolvedFetch({ ok: false, status: 502, json: async () => ({}), diff --git a/src/components/ResultsView.test.ts b/src/components/ResultsView.test.ts index 64e451e..7cbdaf8 100644 --- a/src/components/ResultsView.test.ts +++ b/src/components/ResultsView.test.ts @@ -15,7 +15,7 @@ describe("ResultsView", () => { render(ResultsView, { props: { result: auditResult, - onToast: vi.fn(), + onToast: vi.fn<(message: string) => void>(), initialTab: "trust", }, }); @@ -29,7 +29,7 @@ describe("ResultsView", () => { render(ResultsView, { props: { result: { trust: trustReport, failures: [] }, - onToast: vi.fn(), + onToast: vi.fn<(message: string) => void>(), }, }); @@ -54,7 +54,7 @@ describe("ResultsView", () => { render(ResultsView, { props: { result: auditResult, - onToast: vi.fn(), + onToast: vi.fn<(message: string) => void>(), initialTab: "manual", }, }); @@ -73,7 +73,11 @@ describe("ResultsView", () => { const user = userEvent.setup(); history.replaceState(null, "", "/"); render(ResultsView, { - props: { result: auditResult, onToast: vi.fn(), initialTab: "trust" }, + props: { + result: auditResult, + onToast: vi.fn<(message: string) => void>(), + initialTab: "trust", + }, }); const trustTab = screen.getByRole("tab", { name: /package trust level 2/i }); diff --git a/src/components/TagInput.test.ts b/src/components/TagInput.test.ts index a13ca76..d175f09 100644 --- a/src/components/TagInput.test.ts +++ b/src/components/TagInput.test.ts @@ -8,7 +8,7 @@ describe("TagInput", () => { render(TagInput, { props: { values: [], - onChange: vi.fn(), + onChange: vi.fn<(values: string[]) => void>(), ariaInvalid: true, ariaDescribedby: "org-help org-error", }, @@ -20,7 +20,7 @@ describe("TagInput", () => { test("commits comma and enter separated values with dedupe", async () => { const user = userEvent.setup(); - const onChange = vi.fn(); + const onChange = vi.fn<(values: string[]) => void>(); render(TagInput, { props: { values: ["netlify"], @@ -37,7 +37,7 @@ describe("TagInput", () => { test("commits on blur and lowercases when requested", async () => { const user = userEvent.setup(); - const onChange = vi.fn(); + const onChange = vi.fn<(values: string[]) => void>(); render(TagInput, { props: { values: [], @@ -55,7 +55,7 @@ describe("TagInput", () => { test("removes chips by button and with empty backspace", async () => { const user = userEvent.setup(); - const onChange = vi.fn(); + const onChange = vi.fn<(values: string[]) => void>(); render(TagInput, { props: { values: ["alpha", "beta"], diff --git a/src/components/ThemeToggle.test.ts b/src/components/ThemeToggle.test.ts index 70e0c67..bb1a5b1 100644 --- a/src/components/ThemeToggle.test.ts +++ b/src/components/ThemeToggle.test.ts @@ -7,10 +7,15 @@ import ThemeToggle from "./ThemeToggle.svelte"; function stubMatchMedia(matches: boolean) { Object.defineProperty(window, "matchMedia", { configurable: true, - value: vi.fn((query: string) => ({ + value: vi.fn((query) => ({ matches: query.includes("dark") ? matches : !matches, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => true, })), }); } diff --git a/src/components/UserPublishView.test.ts b/src/components/UserPublishView.test.ts index e0989f9..003ffd5 100644 --- a/src/components/UserPublishView.test.ts +++ b/src/components/UserPublishView.test.ts @@ -8,7 +8,7 @@ describe("UserPublishView", () => { render(UserPublishView, { props: { report: { user: "alice", scanned: 4, rows: [] }, - onToast: vi.fn(), + onToast: vi.fn<(message: string) => void>(), }, }); @@ -25,7 +25,7 @@ describe("UserPublishView", () => { scanned: 2, rows: [{ when: "2026-06-01T02:03:04.000Z", ref: "pkg@1.0.0" }], }, - onToast: vi.fn(), + onToast: vi.fn<(message: string) => void>(), }, }); diff --git a/src/lib/auditStream.test.ts b/src/lib/auditStream.test.ts index 310231e..8aafce4 100644 --- a/src/lib/auditStream.test.ts +++ b/src/lib/auditStream.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { mockFetch, mockResolvedFetch } from "../test/mock"; import { streamAudit, type AuditStreamRequest } from "./auditStream"; const evt = (event: string, data: unknown, id?: number) => { @@ -61,7 +62,7 @@ describe("streamAudit", () => { it("streams log lines and returns the result + saved report link", async () => { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue( + mockResolvedFetch( sseResponse([ evt("log", "[trust] listing packages"), evt("log", "Done."), @@ -86,7 +87,7 @@ describe("streamAudit", () => { it("reassembles frames split across stream chunks", async () => { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue( + mockResolvedFetch( sseResponse( [evt("log", "chunky"), evt("result", RESULT), evt("done", { id: "x", url: "/report/x" })], { @@ -104,11 +105,9 @@ describe("streamAudit", () => { it("surfaces a save failure via saveError instead of throwing", async () => { vi.stubGlobal( "fetch", - vi - .fn() - .mockResolvedValue( - sseResponse([evt("result", RESULT), evt("done", { error: "db unavailable" })]), - ), + mockResolvedFetch( + sseResponse([evt("result", RESULT), evt("done", { error: "db unavailable" })]), + ), ); const outcome = await streamAudit(REQUEST, () => {}); expect(outcome.result).not.toBeNull(); @@ -117,37 +116,35 @@ describe("streamAudit", () => { }); it("throws when the audit itself errors", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue(sseResponse([evt("error", "upstream exploded")])), - ); + vi.stubGlobal("fetch", mockResolvedFetch(sseResponse([evt("error", "upstream exploded")]))); await expect(streamAudit(REQUEST, () => {})).rejects.toThrow("upstream exploded"); }); it("throws on a non-ok response", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 429, body: null })); + vi.stubGlobal("fetch", mockResolvedFetch({ ok: false, status: 429, body: null })); await expect(streamAudit(REQUEST, () => {})).rejects.toThrow("Audit failed (429)"); }); it("posts the request to the audit-stream endpoint", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue( - sseResponse([evt("result", RESULT), evt("done", { id: "x", url: "/report/x" })]), - ); + const fetchMock = mockResolvedFetch( + sseResponse([evt("result", RESULT), evt("done", { id: "x", url: "/report/x" })]), + ); vi.stubGlobal("fetch", fetchMock); await streamAudit(REQUEST, () => {}); expect(fetchMock).toHaveBeenCalledWith( "/api/audit-stream", expect.objectContaining({ method: "POST" }), ); - const body = JSON.parse(String(fetchMock.mock.calls[0][1].body)); + const requestBody = fetchMock.mock.calls[0]?.[1]?.body; + if (typeof requestBody !== "string") throw new TypeError("Expected a string request body"); + const body = JSON.parse(requestBody); expect(body).toMatchObject({ orgs: ["netlify"], kinds: ["trust"], all: true }); }); it("reconnects with jobId + from and resumes after a mid-stream disconnect", async () => { const bodies: Array<{ jobId: string; from: number }> = []; - const fetchMock = vi.fn(async (_url: string, init: { body: string }) => { + const fetchMock = mockFetch(async (_url, init) => { + if (typeof init?.body !== "string") throw new TypeError("Expected a string request body"); bodies.push(JSON.parse(init.body)); if (bodies.length === 1) { // First connection: two log lines, then the stream ends with NO terminal @@ -182,17 +179,15 @@ describe("streamAudit", () => { it("ignores SSE keepalive comment frames", async () => { vi.stubGlobal( "fetch", - vi - .fn() - .mockResolvedValue( - sseResponse([ - ": keepalive\n\n", - evt("log", "working"), - ": keepalive\n\n", - evt("result", RESULT), - evt("done", { id: "x", url: "/report/x" }), - ]), - ), + mockResolvedFetch( + sseResponse([ + ": keepalive\n\n", + evt("log", "working"), + ": keepalive\n\n", + evt("result", RESULT), + evt("done", { id: "x", url: "/report/x" }), + ]), + ), ); const logs: string[] = []; const outcome = await streamAudit(REQUEST, (line) => logs.push(line)); diff --git a/src/lib/discovery.test.ts b/src/lib/discovery.test.ts index f5e3bb1..08f9edf 100644 --- a/src/lib/discovery.test.ts +++ b/src/lib/discovery.test.ts @@ -13,7 +13,7 @@ function jsonResponse(body: unknown) { describe("discovery", () => { it("lists org packages from registry responses with dedupe and sorting", async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const fetchMock = vi.fn(async (input) => { if (requestUrl(input).endsWith("/-/org/Netlify/package")) { return jsonResponse({ zebra: {}, alpha: {} }); } @@ -29,7 +29,7 @@ describe("discovery", () => { }); it("resolves fast-npm-meta batches without registry fallback", async () => { - const fetchMock = vi.fn().mockResolvedValue( + const fetchMock = vi.fn().mockResolvedValue( jsonResponse([ { name: "@scope/pkg", @@ -77,7 +77,7 @@ describe("discovery", () => { it("records incomplete discovery when fast-npm-meta returns unparseable or empty batches", async () => { const fetchMock = vi - .fn() + .fn() .mockResolvedValueOnce(new Response("rate limited", { status: 200 })) .mockResolvedValueOnce(jsonResponse({ error: "too many packages" })); vi.stubGlobal("fetch", fetchMock); diff --git a/src/lib/downloads.test.ts b/src/lib/downloads.test.ts index 784a131..7ff218d 100644 --- a/src/lib/downloads.test.ts +++ b/src/lib/downloads.test.ts @@ -16,7 +16,7 @@ describe("fetchWeeklyDownloads", () => { it("uses bulk unscoped downloads and paced sequential scoped downloads", async () => { vi.useFakeTimers(); const seen: string[] = []; - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const fetchMock = vi.fn(async (input) => { const url = requestUrl(input); seen.push(url); if (url.endsWith("/left-pad,is-number")) { @@ -61,7 +61,7 @@ describe("fetchWeeklyDownloads", () => { it("batches unscoped in one bulk request, paces scoped 500ms apart, and treats null as 0", async () => { vi.useFakeTimers(); const seen: string[] = []; - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const fetchMock = vi.fn(async (input) => { const url = requestUrl(input); seen.push(url); if (url.includes("/a,b")) return jsonResponse({ a: { downloads: 5 }, b: null }); diff --git a/src/lib/npmClient.test.ts b/src/lib/npmClient.test.ts index 7927619..c80f2ee 100644 --- a/src/lib/npmClient.test.ts +++ b/src/lib/npmClient.test.ts @@ -32,7 +32,7 @@ describe("npm client", () => { }); it("fetches npm directly and records exhausted retryable failures", async () => { - const fetchMock = vi.fn().mockResolvedValue(textResponse("rate limited", 429)); + const fetchMock = vi.fn().mockResolvedValue(textResponse("rate limited", 429)); vi.stubGlobal("fetch", fetchMock); const failures = new FailureLog(); const url = "https://registry.npmjs.org/@scope%2fpkg?write=true"; @@ -46,7 +46,7 @@ describe("npm client", () => { }); it("sends the request URL verbatim (scoped names and + separators intact)", async () => { - const fetchMock = vi.fn().mockResolvedValue(textResponse("[]")); + const fetchMock = vi.fn().mockResolvedValue(textResponse("[]")); vi.stubGlobal("fetch", fetchMock); const url = "https://npm.antfu.dev/@scope/pkg+left-pad?metadata=true"; @@ -60,7 +60,7 @@ describe("npm client", () => { it("honors retry-after before retrying and does not log successful retries", async () => { vi.useFakeTimers(); const fetchMock = vi - .fn() + .fn() .mockResolvedValueOnce(textResponse("temporary", 503, { "retry-after": "2" })) .mockResolvedValueOnce(textResponse("ok")); vi.stubGlobal("fetch", fetchMock); @@ -76,7 +76,7 @@ describe("npm client", () => { it("treats 404 as legitimately empty but logs a non-empty unparseable body as a failure", async () => { const fetchMock = vi - .fn() + .fn() .mockResolvedValueOnce(textResponse("missing", 404)) .mockResolvedValueOnce(textResponse("not json")); vi.stubGlobal("fetch", fetchMock); @@ -94,7 +94,7 @@ describe("npm client", () => { }); it("logs a parseable response that violates the supplied JSON schema", async () => { - const fetchMock = vi.fn().mockResolvedValue(textResponse('{"downloads":"many"}')); + const fetchMock = vi.fn().mockResolvedValue(textResponse('{"downloads":"many"}')); vi.stubGlobal("fetch", fetchMock); const failures = new FailureLog(); const url = "https://api.npmjs.org/downloads/point/last-week/left-pad"; diff --git a/src/lib/reports.test.ts b/src/lib/reports.test.ts index c7526af..2ad690b 100644 --- a/src/lib/reports.test.ts +++ b/src/lib/reports.test.ts @@ -24,7 +24,7 @@ function jsonResponse(body: unknown) { function installRoutes(routes: Record) { vi.stubGlobal( "fetch", - vi.fn(async (input: RequestInfo | URL) => { + vi.fn(async (input) => { const key = requestUrl(input); if (!(key in routes)) return new Response(`missing route: ${key}`, { status: 500 }); return jsonResponse(routes[key]); @@ -59,7 +59,7 @@ describe("report builders", () => { }, ], }); - const log = vi.fn(); + const log = vi.fn<(message: string) => void>(); await expect(discoverInScope(config, new FailureLog(), log)).resolves.toEqual([ { @@ -98,7 +98,7 @@ describe("report builders", () => { }, ], }); - const log = vi.fn(); + const log = vi.fn<(message: string) => void>(); await expect(discoverInScope({ ...config, all: true }, new FailureLog(), log)).resolves.toEqual( [ @@ -139,7 +139,7 @@ describe("report builders", () => { }, }); const failures = new FailureLog(); - const log = vi.fn(); + const log = vi.fn<(message: string) => void>(); const promise = runTrust(config, failures, log, [ { @@ -222,7 +222,12 @@ describe("report builders", () => { }, }); - const report = await runManual(config, ["pkg"], new FailureLog(), vi.fn()); + const report = await runManual( + config, + ["pkg"], + new FailureLog(), + vi.fn<(message: string) => void>(), + ); expect(report.totalScanned).toBe(2); expect(report.rows).toEqual([ @@ -248,7 +253,12 @@ describe("report builders", () => { }, }); - const report = await runExternal(config, ["alice"], new FailureLog(), vi.fn()); + const report = await runExternal( + config, + ["alice"], + new FailureLog(), + vi.fn<(message: string) => void>(), + ); expect(report.rows).toEqual([ { user: "mallory", pkg: "other" }, @@ -285,7 +295,7 @@ describe("report builders", () => { }, }); - const log = vi.fn(); + const log = vi.fn<(message: string) => void>(); const report = await runUserPublishes("alice", 6, 2, ["extra", "mine"], new FailureLog(), log); expect(report.scanned).toBe(2); diff --git a/src/lib/runAudit.test.ts b/src/lib/runAudit.test.ts index d257629..5175df9 100644 --- a/src/lib/runAudit.test.ts +++ b/src/lib/runAudit.test.ts @@ -4,10 +4,10 @@ import { discoverInScope, runExternal, runManual, runTrust } from "./reports"; import type { AuditConfig, PkgMeta } from "./types"; vi.mock("./reports", () => ({ - discoverInScope: vi.fn(), - runExternal: vi.fn(), - runManual: vi.fn(), - runTrust: vi.fn(), + discoverInScope: vi.fn(), + runExternal: vi.fn(), + runManual: vi.fn(), + runTrust: vi.fn(), })); const config: AuditConfig = { @@ -66,7 +66,7 @@ describe("runAudit", () => { }); it("shares discovery for trust/manual and skips external without members", async () => { - const log = vi.fn(); + const log = vi.fn<(message: string) => void>(); const result = await runAudit(config, ["trust", "manual", "external"], [], log); @@ -84,7 +84,7 @@ describe("runAudit", () => { }); it("runs external-only audits without trust/manual discovery", async () => { - const log = vi.fn(); + const log = vi.fn<(message: string) => void>(); const result = await runAudit(config, ["external"], ["alice"], log); @@ -100,7 +100,7 @@ describe("runAudit", () => { failures.add("https://registry.npmjs.org/pkg", "http 500"); return { rows: [], distinctUsers: 0, byUser: [] }; }); - const log = vi.fn(); + const log = vi.fn<(message: string) => void>(); const result = await runAudit(config, ["external"], ["alice"], log); diff --git a/src/lib/userPublishStream.test.ts b/src/lib/userPublishStream.test.ts index 18eb8d4..7543c49 100644 --- a/src/lib/userPublishStream.test.ts +++ b/src/lib/userPublishStream.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { mockResolvedFetch } from "../test/mock"; import { streamUserPublishes } from "./userPublishStream"; const evt = (event: string, data: unknown) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; @@ -29,11 +30,9 @@ describe("streamUserPublishes", () => { it("streams logs and returns the report", async () => { vi.stubGlobal( "fetch", - vi - .fn() - .mockResolvedValue( - sseResponse([evt("log", "scanning…"), evt("result", REPORT), evt("done", {})]), - ), + mockResolvedFetch( + sseResponse([evt("log", "scanning…"), evt("result", REPORT), evt("done", {})]), + ), ); const logs: string[] = []; const report = await streamUserPublishes(REQUEST, (line) => logs.push(line)); @@ -43,26 +42,26 @@ describe("streamUserPublishes", () => { }); it("throws when the lookup errors", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(sseResponse([evt("error", "npm down")]))); + vi.stubGlobal("fetch", mockResolvedFetch(sseResponse([evt("error", "npm down")]))); await expect(streamUserPublishes(REQUEST, () => {})).rejects.toThrow("npm down"); }); it("throws on a non-ok response", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500, body: null })); + vi.stubGlobal("fetch", mockResolvedFetch({ ok: false, status: 500, body: null })); await expect(streamUserPublishes(REQUEST, () => {})).rejects.toThrow("Lookup failed (500)"); }); it("posts to the user-publishes endpoint with the request", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(sseResponse([evt("result", REPORT), evt("done", {})])); + const fetchMock = mockResolvedFetch(sseResponse([evt("result", REPORT), evt("done", {})])); vi.stubGlobal("fetch", fetchMock); await streamUserPublishes(REQUEST, () => {}); expect(fetchMock).toHaveBeenCalledWith( "/api/user-publishes-stream", expect.objectContaining({ method: "POST" }), ); - expect(JSON.parse(String(fetchMock.mock.calls[0][1].body))).toMatchObject({ + const body = fetchMock.mock.calls[0]?.[1]?.body; + if (typeof body !== "string") throw new TypeError("Expected a string request body"); + expect(JSON.parse(body)).toMatchObject({ user: "alice", useCachePackages: ["alpha"], }); diff --git a/src/main.test.ts b/src/main.test.ts index 8ffd402..d45161c 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -10,7 +10,7 @@ afterEach(() => { async function importMain() { document.body.innerHTML = '
'; - const mount = vi.fn(); + const mount = vi.fn<(component: unknown, options: { target: Element | null }) => unknown>(); const AppRouter = { name: "AppRouter" }; vi.doMock("svelte", () => ({ mount })); vi.doMock("./AppRouter.svelte", () => ({ default: AppRouter })); @@ -28,7 +28,7 @@ describe("main entry", () => { }); test("throws when the root element is missing", async () => { - vi.doMock("svelte", () => ({ mount: vi.fn() })); + vi.doMock("svelte", () => ({ mount: vi.fn<() => void>() })); vi.doMock("./AppRouter.svelte", () => ({ default: {} })); await expect(import("./main")).rejects.toThrow("Missing #root mount point"); diff --git a/src/test/mock.ts b/src/test/mock.ts new file mode 100644 index 0000000..24cd9e9 --- /dev/null +++ b/src/test/mock.ts @@ -0,0 +1,9 @@ +import { vi } from "vitest"; + +export function mockFetch(implementation: (...args: Parameters) => Promise) { + return vi.fn(implementation); +} + +export function mockResolvedFetch(response: T) { + return vi.fn<(...args: Parameters) => Promise>().mockResolvedValue(response); +} diff --git a/src/test/setup.ts b/src/test/setup.ts index 54334c8..b12f043 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -47,7 +47,7 @@ if (typeof window !== "undefined") { Object.defineProperty(navigator, "clipboard", { configurable: true, value: { - writeText: vi.fn().mockResolvedValue(undefined), + writeText: vi.fn<(data: string) => Promise>().mockResolvedValue(undefined), }, }); });