diff --git a/apps/dashboard/src/components/chat/tool-renderers/details.test.tsx b/apps/dashboard/src/components/chat/tool-renderers/details.test.tsx new file mode 100644 index 000000000..3306be180 --- /dev/null +++ b/apps/dashboard/src/components/chat/tool-renderers/details.test.tsx @@ -0,0 +1,197 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { DetailsTableData } from "./details-table"; +import { getMonitorDetails } from "./get-monitor"; +import { getMonitorSummaryDetails } from "./get-monitor-summary"; +import { getResponseLogDetails } from "./get-response-log"; + +// safe because the mappers only read the fields each fixture sets +function asFixture(value: Record): T { + return value as T; +} + +function text(node: ReactNode) { + return renderToStaticMarkup(<>{node}).replace(/<[^>]+>/g, ""); +} + +function labels(data: DetailsTableData) { + return data.sections.map((s) => s.rows.map((r) => r.label)); +} + +function value(data: DetailsTableData, label: string) { + for (const section of data.sections) { + const row = section.rows.find((r) => r.label === label); + if (row) return text(row.value); + } + return undefined; +} + +describe("getMonitorDetails", () => { + type Input = Parameters[0]; + type Output = Parameters[1]; + + const monitor = { + id: 1, + name: "API", + url: "https://api.openstatus.dev", + jobType: "http", + method: "GET", + active: true, + periodicity: "1m", + regions: ["ams", "iad"], + timeout: 45000, + degradedAfter: 3000, + retry: 3, + followRedirects: true, + public: false, + tags: [{ name: "prod" }, { name: "api" }], + notifications: [{ name: "Ops", provider: "slack" }], + privateLocationIds: [7, 8], + }; + const input = asFixture({ id: 1 }); + + it("groups rows into sections", () => { + const data = getMonitorDetails(input, asFixture(monitor)); + expect(data.sections.map((s) => s.title)).toEqual([ + "Monitor", + "Behavior", + "Visibility", + "Private locations", + ]); + expect(value(data, "Type")).toBe("HTTP"); + expect(value(data, "Regions")).toBe("ams, iad"); + expect(value(data, "Degraded after")).toBe("3000ms"); + expect(value(data, "Tags")).toBe("prod, api"); + expect(value(data, "Notifications")).toBe("Ops (slack)"); + expect(value(data, "IDs")).toBe("7, 8"); + }); + + it("drops optional rows and sections when absent", () => { + const data = getMonitorDetails( + input, + asFixture({ + ...monitor, + jobType: "tcp", + method: null, + degradedAfter: null, + privateLocationIds: [], + }), + ); + expect(data.sections).toHaveLength(3); + expect(labels(data).flat()).not.toContain("Method"); + expect(labels(data).flat()).not.toContain("Degraded after"); + }); +}); + +describe("getMonitorSummaryDetails", () => { + type Input = Parameters[0]; + type Output = Parameters[1]; + + it("shows counts, percentiles and the window", () => { + const data = getMonitorSummaryDetails( + asFixture({ timeRange: "7d" }), + asFixture({ + monitorId: 1, + lastPingAt: null, + totalSuccessful: 90, + totalDegraded: 5, + totalFailed: 5, + p50: 100, + p75: 150, + p90: 200, + p95: 250, + p99: 400, + }), + ); + expect(labels(data)).toEqual([ + ["ID", "Window", "Last check"], + ["Successful", "Degraded", "Failed"], + ["p50", "p75", "p90", "p95", "p99"], + ]); + expect(value(data, "Window")).toBe("7d"); + expect(value(data, "Last check")).toBe("-"); + expect(value(data, "Failed")).toBe("5"); + expect(value(data, "p99")).toBe("400ms"); + }); +}); + +describe("getResponseLogDetails", () => { + type Input = Parameters[0]; + type Output = Parameters[1]; + + const log = { + id: "log_1", + monitorId: "1", + region: "ams", + requestStatus: "success", + trigger: "api", + statusCode: 200, + latency: 200, + cronTimestamp: 0, + timestamp: Date.UTC(2024, 0, 1), + url: "https://openstatus.dev", + error: false, + message: null, + headers: {}, + body: null, + assertions: null, + timing: null, + }; + const input = asFixture({ id: "log_1" }); + + it("shows only the request section for a bare log", () => { + const data = getResponseLogDetails(input, asFixture(log)); + expect(data.sections).toHaveLength(1); + expect(value(data, "Status code")).toBe("200"); + expect(value(data, "Error")).toBe("false"); + }); + + it("adds headers, timing, message, body and assertions when present", () => { + const data = getResponseLogDetails( + input, + asFixture({ + ...log, + headers: { "content-type": "text/html" }, + timing: { dns: 50, connect: 50, tls: 0, ttfb: 100, transfer: 0 }, + message: "Timeout", + body: "

ok

", + assertions: "status eq 200", + }), + ); + expect(labels(data)).toEqual([ + expect.any(Array), + ["Headers"], + ["DNS", "CONNECT", "TLS", "TTFB", "TRANSFER"], + ["Message"], + ["Body"], + ["Assertions"], + ]); + expect(value(data, "Headers")).toContain("content-type"); + expect(value(data, "DNS")).toBe("25%50 ms"); + expect(value(data, "TTFB")).toBe("50%100 ms"); + }); + + it("does not divide by zero for a zero-latency timing", () => { + const data = getResponseLogDetails( + input, + asFixture({ + ...log, + latency: 0, + timing: { dns: 0, connect: 0, tls: 0, ttfb: 0, transfer: 0 }, + }), + ); + expect(value(data, "DNS")).toBe("0%0 ms"); + }); + + // statusCode is nullable, but TableCellNumber renders Number(null) as 0. + it.ignore("does not show 0 for a missing status code", () => { + const data = getResponseLogDetails( + input, + asFixture({ ...log, statusCode: null }), + ); + expect(value(data, "Status code")).not.toBe("0"); + }); +}); diff --git a/apps/dashboard/src/components/chat/tool-renderers/get-audit-log.test.ts b/apps/dashboard/src/components/chat/tool-renderers/get-audit-log.test.ts new file mode 100644 index 000000000..a7ed3b83a --- /dev/null +++ b/apps/dashboard/src/components/chat/tool-renderers/get-audit-log.test.ts @@ -0,0 +1,65 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { getAuditLogChanges } from "./get-audit-log"; + +type Output = Parameters[0]; + +// safe because getAuditLogChanges only reads before, after and changedFields +function asOutput(value: Record): Output { + return value as Output; +} + +describe("getAuditLogChanges", () => { + it("diffs only the changed fields of an update, hiding bookkeeping", () => { + expect( + getAuditLogChanges( + asOutput({ + before: { name: "old", url: "a", updatedAt: 1 }, + after: { name: "new", url: "a", updatedAt: 2 }, + changedFields: ["name", "updatedAt"], + }), + ), + ).toEqual([{ field: "name", before: "old", after: "new" }]); + }); + + it("returns nothing for an update without changed fields", () => { + expect( + getAuditLogChanges( + asOutput({ before: { a: 1 }, after: { a: 1 }, changedFields: null }), + ), + ).toEqual([]); + }); + + it("lists the created fields", () => { + expect( + getAuditLogChanges( + asOutput({ + before: null, + after: { name: "api", workspaceId: 1 }, + changedFields: null, + }), + ), + ).toEqual([{ field: "name", after: "api" }]); + }); + + it("lists the deleted fields", () => { + expect( + getAuditLogChanges( + asOutput({ + before: { name: "api", deletedAt: 1 }, + after: null, + changedFields: null, + }), + ), + ).toEqual([{ field: "name", before: "api" }]); + }); + + it("returns nothing without snapshots", () => { + expect( + getAuditLogChanges( + asOutput({ before: null, after: null, changedFields: ["x"] }), + ), + ).toEqual([]); + }); +}); diff --git a/apps/dashboard/src/components/chat/tool-renderers/incidents.test.tsx b/apps/dashboard/src/components/chat/tool-renderers/incidents.test.tsx new file mode 100644 index 000000000..cd2ee601c --- /dev/null +++ b/apps/dashboard/src/components/chat/tool-renderers/incidents.test.tsx @@ -0,0 +1,187 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + declareIncidentChanges, + getIncidentDetails, + listIncidentsTable, + resolveIncidentChanges, + updateIncidentChanges, +} from "./incidents"; + +// safe because the mappers only read the fields each fixture sets +function asFixture(value: Record): T { + return value as T; +} + +function text(node: ReactNode) { + return renderToStaticMarkup(<>{node}).replace(/<[^>]+>/g, ""); +} + +describe("declareIncidentChanges", () => { + type Input = Parameters[0]; + + it("lists title and severity for a minimal draft", () => { + expect( + declareIncidentChanges( + asFixture({ title: "Checkout down", severity: "critical" }), + ), + ).toEqual([ + { field: "title", after: "Checkout down" }, + { field: "severity", after: "critical" }, + ]); + }); + + it("leads with the created id and keeps a commander id of 0", () => { + const rows = declareIncidentChanges( + asFixture({ + title: "Checkout down", + severity: "major", + summary: "Payments failing", + commanderId: 0, + startedAt: "2024-01-01T00:00:00Z", + statusReportId: 4, + }), + { id: 12 }, + ); + expect(rows.map((r) => r.field)).toEqual([ + "id", + "title", + "severity", + "summary", + "commanderId", + "startedAt", + "statusReportId", + ]); + expect(rows[0]).toEqual({ field: "id", after: 12 }); + expect(rows[4]).toEqual({ field: "commanderId", after: 0 }); + }); +}); + +describe("updateIncidentChanges", () => { + type Input = Parameters[0]; + + it("lists only the provided fields, keeping explicit nulls", () => { + expect( + updateIncidentChanges( + asFixture({ id: 3, severity: "minor", commanderId: null }), + ), + ).toEqual([ + { field: "incidentId", after: 3 }, + { field: "severity", after: "minor" }, + { field: "commanderId", after: null }, + ]); + }); +}); + +describe("resolveIncidentChanges", () => { + type Input = Parameters[0]; + + it("marks the incident resolved and appends a note when given", () => { + expect(resolveIncidentChanges(asFixture({ id: 3 }))).toEqual([ + { field: "incidentId", after: 3 }, + { field: "status", after: "resolved" }, + ]); + expect( + resolveIncidentChanges( + asFixture({ id: 3, note: "Rolled back" }), + ).at(-1), + ).toEqual({ field: "note", after: "Rolled back" }); + }); +}); + +describe("listIncidentsTable", () => { + type Output = Parameters[0]; + + it("has an empty message and handles a missing output", () => { + const table = listIncidentsTable(undefined as unknown as Output); + expect(table.rows).toEqual([]); + expect(table.empty).toBe("No incidents."); + }); + + it("marks closed incidents and falls back for a missing commander", () => { + const table = listIncidentsTable( + asFixture({ + items: [ + { + id: 1, + title: "DNS", + severity: "major", + status: "resolved", + closed: true, + commander: null, + }, + { + id: 2, + title: "API", + severity: "minor", + status: "open", + closed: false, + commander: { name: "Ada" }, + }, + ], + }), + ); + expect(table.columns.map((c) => c.key)).toEqual([ + "title", + "severity", + "status", + "commander", + "id", + ]); + const [closed, open] = table.rows; + expect(text(closed.cells.status)).toBe("resolved · closed"); + expect(text(closed.cells.commander)).toBe("-"); + expect(text(open.cells.status)).toBe("open"); + expect(text(open.cells.commander)).toBe("Ada"); + expect(text(open.cells.id)).toBe("2"); + }); +}); + +describe("getIncidentDetails", () => { + type Output = Parameters[0]; + + const output = asFixture({ + id: 5, + title: "Checkout down", + severity: "critical", + status: "open", + commander: null, + summary: null, + startedAt: "2024-01-01T00:00:00Z", + statusReport: { title: "Payments degraded" }, + events: Array.from({ length: 12 }, (_, i) => ({ + type: "status_changed", + message: `event ${i}`, + })), + }); + + it("builds an overview section", () => { + const [overview] = getIncidentDetails(output).sections; + expect(overview.title).toBeUndefined(); + expect(overview.rows.map((r) => r.label)).toEqual([ + "ID", + "Title", + "Severity", + "Status", + "Commander", + "Summary", + "Started", + "Status report", + ]); + const value = (label: string) => + text(overview.rows.find((r) => r.label === label)?.value); + expect(value("Commander")).toBe("-"); + expect(value("Status report")).toBe("Payments degraded"); + }); + + it("caps the timeline at ten events and humanizes their type", () => { + const timeline = getIncidentDetails(output).sections[1]; + expect(timeline.title).toBe("Timeline"); + expect(timeline.rows).toHaveLength(10); + expect(timeline.rows[0].label).toBe("status changed"); + expect(text(timeline.rows[9].value)).toBe("event 9"); + }); +}); diff --git a/apps/dashboard/src/components/chat/tool-renderers/list-tables.test.tsx b/apps/dashboard/src/components/chat/tool-renderers/list-tables.test.tsx new file mode 100644 index 000000000..08a247974 --- /dev/null +++ b/apps/dashboard/src/components/chat/tool-renderers/list-tables.test.tsx @@ -0,0 +1,202 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { listMonitorsTable } from "./list-monitors"; +import { listNotificationsTable } from "./list-notifications"; +import { listPageComponentsTable } from "./list-page-components"; +import { listPrivateLocationsTable } from "./list-private-locations"; +import { listStatusPagesTable } from "./list-status-pages"; +import { listStatusReportsTable } from "./list-status-reports"; +import { searchContentTable } from "./search-content"; +import { searchDocsTable } from "./search-docs"; + +// safe because the mappers only read the fields each fixture sets +function asOutput(value: Record | undefined): T { + return value as T; +} + +function html(node: ReactNode) { + return renderToStaticMarkup(<>{node}); +} + +function text(node: ReactNode) { + return html(node).replace(/<[^>]+>/g, ""); +} + +function hrefOf(node: ReactNode) { + return html(node).match(/]*href="([^"]+)"/)?.[1]; +} + +describe("listMonitorsTable", () => { + const table = listMonitorsTable( + asOutput({ + items: [ + { + id: 3, + name: "API", + jobType: "http", + periodicity: "1m", + regions: [], + active: true, + }, + ], + }), + ); + + it("links to the monitor overview", () => { + expect(hrefOf(table.rows[0].cells.name)).toBe("/monitors/3/overview"); + }); + + it("upper-cases the job type and shows a dash without regions", () => { + expect(text(table.rows[0].cells.jobType)).toBe("HTTP"); + expect(text(table.rows[0].cells.regions)).toBe("—"); + }); + + it("handles a missing output", () => { + const empty = listMonitorsTable(asOutput(undefined)); + expect(empty.rows).toEqual([]); + expect(empty.empty).toBe("No monitors."); + }); +}); + +describe("listNotificationsTable", () => { + const table = listNotificationsTable( + asOutput({ + items: [ + { id: 1, name: "Slack", provider: "slack", monitorIds: [4, 5] }, + { id: 2, name: "Mail", provider: "email", monitorIds: [] }, + ], + }), + ); + + it("links to the notification", () => { + expect(hrefOf(table.rows[0].cells.name)).toBe("/notifications/1"); + }); + + it("summarises attached monitors", () => { + expect(text(table.rows[0].cells.monitors)).toBe("2 (4, 5)"); + expect(text(table.rows[1].cells.monitors)).toBe("none"); + }); +}); + +describe("listPageComponentsTable", () => { + const table = listPageComponentsTable( + asOutput({ + items: [ + { id: 9, name: "API", type: "monitor", pageId: 2, monitorId: 4 }, + { id: 10, name: "Docs", type: "static", pageId: 2, monitorId: null }, + ], + }), + ); + + it("links to the page's components", () => { + expect(hrefOf(table.rows[0].cells.name)).toBe("/status-pages/2/components"); + }); + + it("shows the monitor id, or a placeholder for static components", () => { + expect(text(table.rows[0].cells.monitor)).toContain("4"); + expect(text(table.rows[1].cells.monitor).trim()).toBe("-"); + }); +}); + +describe("listPrivateLocationsTable", () => { + const table = listPrivateLocationsTable( + asOutput({ + items: [ + { + id: 1, + name: "office", + monitorIds: [], + metadata: { rack: "a1" }, + lastSeenAt: null, + }, + ], + }), + ); + + it("links to the private locations settings", () => { + expect(hrefOf(table.rows[0].cells.name)).toBe( + "/settings/private-locations", + ); + }); + + it("shows 'never' for a location that never reported", () => { + expect(text(table.rows[0].cells.lastSeenAt)).toBe("never"); + expect(text(table.rows[0].cells.monitors)).toBe("none"); + }); + + it("renders metadata as pills", () => { + expect(text(table.rows[0].cells.metadata)).toContain("a1"); + }); +}); + +describe("listStatusPagesTable", () => { + it("links to the page's status reports", () => { + const table = listStatusPagesTable( + asOutput({ items: [{ id: 2, title: "Acme", slug: "acme" }] }), + ); + expect(hrefOf(table.rows[0].cells.title)).toBe( + "/status-pages/2/status-reports", + ); + expect(text(table.rows[0].cells.slug)).toBe("acme"); + }); +}); + +describe("listStatusReportsTable", () => { + const table = listStatusReportsTable( + asOutput({ + items: [ + { id: 5, title: "Outage", status: "investigating", pageId: 2 }, + { id: 6, title: "Orphan", status: "resolved", pageId: null }, + ], + }), + ); + + it("links a report to its page", () => { + expect(hrefOf(table.rows[0].cells.title)).toBe( + "/status-pages/2/status-reports/5", + ); + }); + + it("does not link a report without a page", () => { + expect(hrefOf(table.rows[1].cells.title)).toBeUndefined(); + expect(text(table.rows[1].cells.title)).toContain("Orphan"); + }); +}); + +describe("search tables", () => { + const results = [ + { + path: "/docs/a", + url: "https://docs.openstatus.dev/a", + title: "A", + snippet: "about a", + type: "blog", + }, + ]; + + it("links docs results externally in a new tab", () => { + const out = html( + searchDocsTable(asOutput({ results })).rows[0].cells.title, + ); + expect(out).toContain('href="https://docs.openstatus.dev/a"'); + expect(out).toContain('target="_blank"'); + }); + + it("uses the result path as the row id", () => { + expect(searchContentTable(asOutput({ results })).rows[0].id).toBe( + "/docs/a", + ); + }); + + it("surfaces the search error as the empty message", () => { + expect(searchDocsTable(asOutput({ error: "rate limited" })).empty).toBe( + "rate limited", + ); + expect(searchContentTable(asOutput({ results: [] })).empty).toBe( + "No results.", + ); + }); +}); diff --git a/apps/dashboard/src/components/chat/tool-renderers/maintenance-changes.test.ts b/apps/dashboard/src/components/chat/tool-renderers/maintenance-changes.test.ts new file mode 100644 index 000000000..d3f5c954f --- /dev/null +++ b/apps/dashboard/src/components/chat/tool-renderers/maintenance-changes.test.ts @@ -0,0 +1,108 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { createMaintenanceChanges } from "./create-maintenance"; +import { + addMaintenanceUpdateChanges, + deleteMaintenanceUpdateChanges, + updateMaintenanceUpdateChanges, +} from "./maintenance-update"; + +// safe because the mappers only read the fields each fixture sets +function asInput(value: Record): T { + return value as T; +} + +describe("createMaintenanceChanges", () => { + const input = asInput[0]>({ + title: "DB upgrade", + message: "Short downtime", + from: "2024-01-01T00:00:00Z", + to: "2024-01-01T01:00:00Z", + pageId: 1, + pageComponentIds: [2, 3], + notify: true, + }); + + it("lists every field in a fixed order", () => { + expect(createMaintenanceChanges(input)).toEqual([ + { field: "title", after: "DB upgrade" }, + { field: "message", after: "Short downtime" }, + { field: "from", after: "2024-01-01T00:00:00Z" }, + { field: "to", after: "2024-01-01T01:00:00Z" }, + { field: "pageId", after: 1 }, + { field: "pageComponentIds", after: [2, 3] }, + { field: "notify", after: true }, + ]); + }); + + it("prefers the applied notify flag", () => { + const rows = createMaintenanceChanges(input, { id: 1, notified: false }); + expect(rows.at(-1)).toEqual({ field: "notify", after: false }); + }); +}); + +describe("addMaintenanceUpdateChanges", () => { + type Input = Parameters[0]; + + it("omits the id and date for a draft without a date", () => { + expect( + addMaintenanceUpdateChanges( + asInput({ maintenanceId: 4, message: "Started", notify: true }), + ), + ).toEqual([ + { field: "maintenanceId", after: 4 }, + { field: "message", after: "Started" }, + { field: "notify", after: true }, + ]); + }); + + it("leads with the applied id and uses the applied notify flag", () => { + expect( + addMaintenanceUpdateChanges( + asInput({ + maintenanceId: 4, + message: "Started", + date: "2024-01-01T00:00:00Z", + notify: true, + }), + { id: 11, notified: false }, + ), + ).toEqual([ + { field: "id", after: 11 }, + { field: "maintenanceId", after: 4 }, + { field: "message", after: "Started" }, + { field: "date", after: "2024-01-01T00:00:00Z" }, + { field: "notify", after: false }, + ]); + }); +}); + +describe("updateMaintenanceUpdateChanges", () => { + type Input = Parameters[0]; + + it("lists only the provided fields", () => { + expect(updateMaintenanceUpdateChanges(asInput({ id: 3 }))).toEqual([ + { field: "id", after: 3 }, + ]); + expect( + updateMaintenanceUpdateChanges( + asInput({ id: 3, message: "Edited", date: "2024-01-01" }), + ), + ).toEqual([ + { field: "id", after: 3 }, + { field: "message", after: "Edited" }, + { field: "date", after: "2024-01-01" }, + ]); + }); +}); + +describe("deleteMaintenanceUpdateChanges", () => { + it("shows the id as removed", () => { + const rows = deleteMaintenanceUpdateChanges( + asInput[0]>({ id: 8 }), + ); + expect(rows).toEqual([{ field: "id", before: 8 }]); + expect("after" in rows[0]).toBe(false); + }); +}); diff --git a/apps/dashboard/src/components/chat/tool-renderers/registry.test.tsx b/apps/dashboard/src/components/chat/tool-renderers/registry.test.tsx new file mode 100644 index 000000000..40e7a274a --- /dev/null +++ b/apps/dashboard/src/components/chat/tool-renderers/registry.test.tsx @@ -0,0 +1,146 @@ +import { expect } from "@std/expect"; +import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; +import { type Stub, stub } from "@std/testing/mock"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + renderToolDraft, + renderToolResult, + summarizeToolOutput, + toolRenderers, +} from "./index"; + +let warnStub: Stub | undefined; +beforeEach(() => { + warnStub = stub(console, "warn"); +}); +afterEach(() => warnStub?.restore()); + +describe("summarizeToolOutput", () => { + it("pluralises item counts", () => { + expect(summarizeToolOutput("list_monitors", { items: [] })).toBe( + "0 results", + ); + expect(summarizeToolOutput("list_monitors", { items: [{}] })).toBe( + "1 result", + ); + expect(summarizeToolOutput("list_monitors", { items: [{}, {}] })).toBe( + "2 results", + ); + }); + + it("flags paginated response logs", () => { + expect( + summarizeToolOutput("list_response_logs", { + logs: [{}], + hasMore: true, + }), + ).toBe("1 log (more available)"); + }); + + it("prefers the search error over the count", () => { + expect( + summarizeToolOutput("search_docs", { error: "down", results: [{}] }), + ).toBe("down"); + expect(summarizeToolOutput("search_docs", { results: [{}] })).toBe( + "1 result", + ); + }); + + it("describes doc page reads", () => { + expect( + summarizeToolOutput("get_doc_page", { + url: "/docs/a", + truncated: true, + }), + ).toBe("read /docs/a (truncated)"); + }); + + it("totals monitor summary checks", () => { + expect( + summarizeToolOutput("get_monitor_summary", { + totalSuccessful: 8, + totalDegraded: 1, + totalFailed: 1, + p95: 120, + }), + ).toBe("10 checks · p95 120ms"); + }); + + it("handles a missing postmortem", () => { + expect(summarizeToolOutput("get_postmortem", { exists: false })).toBe( + "no postmortem", + ); + }); + + it("returns undefined while the output is pending", () => { + expect(summarizeToolOutput("list_monitors", undefined)).toBeUndefined(); + }); +}); + +describe("renderToolDraft", () => { + it("builds inline drafts", () => { + expect( + renderToolDraft("set_incident_status", { id: 4, status: "mitigated" }), + ).toEqual([ + { field: "incidentId", after: 4 }, + { field: "status", after: "mitigated" }, + ]); + }); + + it("includes the note only when present", () => { + expect( + renderToolDraft("set_incident_status", { + id: 4, + status: "mitigated", + note: "rolled back", + })?.at(-1), + ).toEqual({ field: "note", after: "rolled back" }); + }); + + it("returns undefined for read-only tools", () => { + expect(renderToolDraft("list_monitors", {})).toBeUndefined(); + }); +}); + +describe("renderToolResult", () => { + it("returns undefined while the output is pending", () => { + expect(renderToolResult("list_monitors", {}, undefined)).toBeUndefined(); + }); + + it("renders a result table for list tools", () => { + const node = renderToolResult( + "list_status_pages", + {}, + { items: [{ id: 1, title: "Acme", slug: "acme" }] }, + ); + expect(renderToStaticMarkup(<>{node})).toContain("Acme"); + }); + + it("has no result renderer for full-page reads", () => { + expect( + renderToolResult("get_doc_page", {}, { url: "/docs/a" }), + ).toBeUndefined(); + }); +}); + +describe("unknown tools", () => { + it("fall back to undefined and warn once per name", () => { + expect(summarizeToolOutput("not_a_tool", {})).toBeUndefined(); + expect(renderToolDraft("not_a_tool", {})).toBeUndefined(); + expect(renderToolResult("not_a_tool", {}, {})).toBeUndefined(); + expect(warnStub?.calls).toHaveLength(1); + }); +}); + +describe("toolRenderers", () => { + it("gives every tool at least a summary or a renderer", () => { + for (const [name, renderer] of Object.entries(toolRenderers)) { + const r = renderer as Record; + expect({ + name, + covered: Boolean(r.summary || r.renderDraft || r.renderResult), + }).toEqual({ name, covered: true }); + } + }); +}); diff --git a/apps/dashboard/src/components/chat/tool-renderers/status-report-changes.test.ts b/apps/dashboard/src/components/chat/tool-renderers/status-report-changes.test.ts new file mode 100644 index 000000000..1e7417799 --- /dev/null +++ b/apps/dashboard/src/components/chat/tool-renderers/status-report-changes.test.ts @@ -0,0 +1,180 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { addStatusReportUpdateChanges } from "./add-status-report-update"; +import { createStatusReportChanges } from "./create-status-report"; +import { resolveStatusReportChanges } from "./resolve-status-report"; +import { updateStatusReportChanges } from "./update-status-report"; + +type CreateInput = Parameters[0]; +type AddInput = Parameters[0]; +type UpdateInput = Parameters[0]; +type ResolveInput = Parameters[0]; + +// safe because the mappers only read the fields each fixture sets +function asInput(value: Record): T { + return value as T; +} + +const fields = (rows: { field: string }[]) => rows.map((r) => r.field); + +describe("createStatusReportChanges", () => { + const base = { + title: "API outage", + status: "investigating", + message: "Looking into it", + pageId: 7, + notify: true, + }; + + it("lists the required fields for a draft", () => { + expect(createStatusReportChanges(asInput(base))).toEqual([ + { field: "title", after: "API outage" }, + { field: "status", after: "investigating" }, + { field: "message", after: "Looking into it" }, + { field: "pageId", after: 7 }, + { field: "notify", after: true }, + ]); + }); + + it("adds optional fields only when present", () => { + const rows = createStatusReportChanges( + asInput({ + ...base, + pageComponentIds: [1, 2], + componentImpacts: [{ pageComponentId: 1, impact: "major_outage" }], + date: "2024-01-01T00:00:00Z", + }), + ); + expect(rows.slice(5)).toEqual([ + { field: "pageComponentIds", after: [1, 2] }, + { field: "componentImpacts", after: ["1 → major_outage"] }, + { field: "date", after: "2024-01-01T00:00:00Z" }, + ]); + }); + + it("skips empty component lists and a null date", () => { + const rows = createStatusReportChanges( + asInput({ + ...base, + pageComponentIds: [], + componentImpacts: [], + date: null, + }), + ); + expect(fields(rows)).toEqual([ + "title", + "status", + "message", + "pageId", + "notify", + ]); + }); + + it("prefers the applied notify flag and appends createdAt", () => { + const rows = createStatusReportChanges(asInput(base), { + id: 1, + notified: false, + createdAt: "2024-01-02T00:00:00Z", + }); + expect(rows.find((r) => r.field === "notify")?.after).toBe(false); + expect(rows.at(-1)).toEqual({ + field: "createdAt", + after: "2024-01-02T00:00:00Z", + }); + }); + + it("keeps the input notify flag when the result omits it", () => { + const rows = createStatusReportChanges(asInput(base), { + id: 1, + }); + expect(rows.find((r) => r.field === "notify")?.after).toBe(true); + expect(fields(rows)).not.toContain("createdAt"); + }); +}); + +describe("addStatusReportUpdateChanges", () => { + const base = { + statusReportId: 3, + status: "monitoring", + message: "Fix deployed", + notify: false, + }; + + it("lists the update fields", () => { + expect(addStatusReportUpdateChanges(asInput(base))).toEqual([ + { field: "statusReportId", after: 3 }, + { field: "status", after: "monitoring" }, + { field: "message", after: "Fix deployed" }, + { field: "notify", after: false }, + ]); + }); + + it("formats impacts, keeps the date and uses the applied notify flag", () => { + const rows = addStatusReportUpdateChanges( + asInput({ + ...base, + componentImpacts: [{ pageComponentId: 4, impact: "operational" }], + date: "2024-01-01T00:00:00Z", + }), + { statusReportUpdateId: 9, notified: true }, + ); + expect(rows.find((r) => r.field === "notify")?.after).toBe(true); + expect(rows.slice(4)).toEqual([ + { field: "componentImpacts", after: ["4 → operational"] }, + { field: "date", after: "2024-01-01T00:00:00Z" }, + ]); + }); +}); + +describe("updateStatusReportChanges", () => { + it("only lists the fields the model proposed", () => { + expect( + updateStatusReportChanges( + asInput({ statusReportId: 5, title: "New title" }), + ), + ).toEqual([ + { field: "statusReportId", after: 5 }, + { field: "title", after: "New title" }, + ]); + }); + + it("keeps an explicitly empty component list", () => { + expect( + fields( + updateStatusReportChanges( + asInput({ + statusReportId: 5, + status: "resolved", + pageComponentIds: [], + }), + ), + ), + ).toEqual(["statusReportId", "status", "pageComponentIds"]); + }); +}); + +describe("resolveStatusReportChanges", () => { + const base = { statusReportId: 2, message: "All good", notify: true }; + + it("always sets the status to resolved", () => { + expect(resolveStatusReportChanges(asInput(base))).toEqual([ + { field: "statusReportId", after: 2 }, + { field: "status", after: "resolved" }, + { field: "message", after: "All good" }, + { field: "notify", after: true }, + ]); + }); + + it("appends the date and uses the applied notify flag", () => { + const rows = resolveStatusReportChanges( + asInput({ ...base, date: "2024-01-01T00:00:00Z" }), + { statusReportUpdateId: 1, notified: false }, + ); + expect(rows.find((r) => r.field === "notify")?.after).toBe(false); + expect(rows.at(-1)).toEqual({ + field: "date", + after: "2024-01-01T00:00:00Z", + }); + }); +}); diff --git a/apps/dashboard/src/components/chat/tool-renderers/tables.test.tsx b/apps/dashboard/src/components/chat/tool-renderers/tables.test.tsx new file mode 100644 index 000000000..d5b601a22 --- /dev/null +++ b/apps/dashboard/src/components/chat/tool-renderers/tables.test.tsx @@ -0,0 +1,208 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { DetailsTable } from "./details-table"; +import { getMonitorStatusTable } from "./get-monitor-status"; +import { listAuditLogsTable } from "./list-audit-logs"; +import { listMaintenancesTable } from "./list-maintenances"; +import { listResponseLogsTable } from "./list-response-logs"; +import { ResultTable } from "./result-table"; + +// safe because the mappers only read the fields each fixture sets +function asOutput(value: Record): T { + return value as T; +} + +function html(node: ReactNode) { + return renderToStaticMarkup(<>{node}); +} + +function strip(markup: string) { + return markup.replace(/<[^>]+>/g, ""); +} + +function text(node: ReactNode) { + return strip(html(node)); +} + +describe("ResultTable", () => { + it("shows the empty message without rows", () => { + const out = html(); + expect(out).not.toContain(" { + const out = html( + , + ); + expect(out.match(/]*>([^<]*)<\/th>/g)?.map(strip)).toEqual([ + "B", + "A", + ]); + expect(out.match(/]*>([^<]*)<\/td>/g)?.map(strip)).toEqual([ + "b1", + "a1", + ]); + expect(out).toMatch(/]*class="[^"]*font-mono[^"]*"[^>]*>a1/); + }); +}); + +describe("DetailsTable", () => { + it("falls back to a default empty message", () => { + expect(text()).toBe( + "No details to show.", + ); + expect(text()).toBe("Gone."); + }); + + it("renders section titles and label/value rows", () => { + const out = text( + , + ); + expect(out).toBe("MonitorID42NameAPI"); + }); +}); + +describe("listAuditLogsTable", () => { + type Output = Parameters[0]; + + it("colours actions by verb and joins the entity reference", () => { + const table = listAuditLogsTable( + asOutput({ + items: [ + "monitor.create", + "page.update", + "page.delete", + "user.login", + ].map((action, i) => ({ + id: i, + action, + entityType: "monitor", + entityId: "7", + actor: "user:1", + createdAt: "2024-01-01T00:00:00Z", + })), + }), + ); + const badges = table.rows.map((r) => html(r.cells.action)); + expect(badges[0]).toContain("text-success"); + expect(badges[1]).toContain("text-info"); + expect(badges[2]).toContain("text-destructive"); + expect(badges[3]).toContain("text-muted-foreground"); + expect(text(table.rows[0].cells.entity)).toBe("monitor#7"); + expect(text(table.rows[0].cells.actor)).toBe("user:1"); + }); +}); + +describe("listMaintenancesTable", () => { + type Output = Parameters[0]; + + it("splits the duration into amount and unit", () => { + const table = listMaintenancesTable( + asOutput({ + items: [ + { + id: 1, + title: "Upgrade", + from: "2024-01-01T00:00:00Z", + to: "2024-01-01T02:00:00Z", + pageId: null, + }, + { + id: 2, + title: "Patch", + from: "2024-01-01T00:00:00Z", + to: "2024-01-01T00:30:00Z", + pageId: 9, + }, + ], + }), + ); + expect(text(table.rows[0].cells.duration)).toBe("2hours"); + expect(text(table.rows[1].cells.duration)).toBe("30minutes"); + expect(text(table.rows[0].cells.page)).toBe("-"); + expect(text(table.rows[1].cells.page)).toBe("9"); + }); + + it("handles a missing output", () => { + expect(listMaintenancesTable(undefined as unknown as Output).rows).toEqual( + [], + ); + }); +}); + +describe("listResponseLogsTable", () => { + type Output = Parameters[0]; + + const log = { + id: "log_1", + monitorId: "1", + region: "ams", + requestStatus: "error", + trigger: "cron", + statusCode: 503, + latency: 120, + cronTimestamp: 0, + timestamp: Date.UTC(2024, 0, 1), + timing: null, + }; + + it("colours the indicator and status code", () => { + const [row] = listResponseLogsTable(asOutput({ logs: [log] })).rows; + expect(row.id).toBe("log_1"); + expect(html(row.cells.indicator)).toContain("bg-destructive"); + expect(html(row.cells.status)).toContain("text-destructive"); + expect(text(row.cells.status)).toBe("503"); + expect(text(row.cells.latency)).toBe("120ms"); + }); + + it("uses placeholders for missing id, status and timing", () => { + const [row] = listResponseLogsTable( + asOutput({ + logs: [{ ...log, id: null, requestStatus: null, statusCode: null }], + }), + ).rows; + expect(row.id).toBe("row-0"); + expect(text(row.cells.indicator)).toBe("-"); + expect(text(row.cells.status)).toBe("—"); + expect(text(row.cells.timing)).toBe("-"); + expect(text(row.cells.id)).toBe("—"); + }); +}); + +describe("getMonitorStatusTable", () => { + type Output = Parameters[0]; + + it("keys rows by region and colours the status", () => { + const table = getMonitorStatusTable( + asOutput({ + regions: [ + { region: "ams", status: "active" }, + { region: "iad", status: "degraded" }, + { region: "sin", status: "error" }, + ], + }), + ); + expect(table.rows.map((r) => r.id)).toEqual(["ams", "iad", "sin"]); + expect(table.rows.map((r) => html(r.cells.status))).toEqual([ + expect.stringContaining("text-success"), + expect.stringContaining("text-warning"), + expect.stringContaining("text-destructive"), + ]); + }); +}); diff --git a/apps/dashboard/src/components/command-menu/groups/entities.test.ts b/apps/dashboard/src/components/command-menu/groups/entities.test.ts new file mode 100644 index 000000000..213981dde --- /dev/null +++ b/apps/dashboard/src/components/command-menu/groups/entities.test.ts @@ -0,0 +1,190 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { + incidentsGroup, + maintenancesGroup, + monitorsGroup, + statusPagesGroup, + statusReportsGroup, + workspacesGroup, +} from "./entities"; + +// safe because each group builder only reads the fields set in the fixtures +function rows(items: Record[]): T[] { + return items as unknown as T[]; +} + +type Arg unknown> = NonNullable< + Parameters[0] +>[number]; + +describe("empty input", () => { + it("returns null so the group is hidden", () => { + expect(incidentsGroup(undefined)).toBeNull(); + expect(incidentsGroup([])).toBeNull(); + expect(monitorsGroup(undefined)).toBeNull(); + expect(statusPagesGroup([])).toBeNull(); + expect(statusReportsGroup(undefined)).toBeNull(); + expect(maintenancesGroup([], new Map())).toBeNull(); + expect(workspacesGroup([], () => {})).toBeNull(); + }); +}); + +describe("incidentsGroup", () => { + const group = incidentsGroup( + rows>([ + { + id: 1, + title: "API down", + status: "open", + severity: "critical", + commander: { name: "Ada" }, + }, + { + id: 2, + title: "Slow DB", + status: "mitigated", + severity: "minor", + commander: null, + }, + ]), + ); + + it("caps idle items and links to the incident", () => { + expect(group?.idleLimit).toBe(5); + expect(group?.items[0]).toMatchObject({ + value: "incident-1", + label: "API down", + description: "open · critical", + action: { type: "navigate", href: "/incidents/1" }, + }); + }); + + it("searches by commander only when one is assigned", () => { + expect(group?.items[0].keywords).toEqual([ + "API down", + "open", + "critical", + "Ada", + ]); + expect(group?.items[1].keywords).toEqual(["Slow DB", "mitigated", "minor"]); + }); +}); + +describe("monitorsGroup", () => { + it("pushes the monitor scope page", () => { + const group = monitorsGroup( + rows>([ + { id: 3, name: "API", url: "https://api.acme.dev" }, + ]), + ); + expect(group?.items[0]).toMatchObject({ + value: "monitor-3", + description: "https://api.acme.dev", + keywords: ["API", "https://api.acme.dev"], + action: { type: "push", page: { type: "monitor", id: 3, name: "API" } }, + }); + }); +}); + +describe("statusPagesGroup", () => { + it("pushes the status page scope and indexes the custom domain", () => { + const group = statusPagesGroup( + rows>([ + { id: 4, title: "Acme", slug: "acme", customDomain: "status.acme.dev" }, + { id: 5, title: "Beta", slug: "beta", customDomain: "" }, + ]), + ); + expect(group?.items[0]).toMatchObject({ + value: "status-page-4", + description: "acme", + keywords: ["Acme", "acme", "status.acme.dev"], + action: { + type: "push", + page: { type: "status-page", id: 4, title: "Acme" }, + }, + }); + expect(group?.items[1].keywords).toEqual(["Beta", "beta"]); + }); +}); + +describe("statusReportsGroup", () => { + it("links into the report under its page", () => { + const group = statusReportsGroup( + rows>([ + { + id: 9, + pageId: 4, + title: "Outage", + status: "investigating", + page: { title: "Acme" }, + }, + ]), + ); + expect(group?.items[0]).toMatchObject({ + value: "status-report-9", + description: "investigating · Acme", + keywords: ["Outage", "investigating", "Acme"], + action: { type: "navigate", href: "/status-pages/4/status-reports/9" }, + }); + }); +}); + +describe("maintenancesGroup", () => { + const from = new Date("2024-06-01T10:00:00Z"); + + it("links to the page's maintenances and indexes the page title", () => { + const group = maintenancesGroup( + rows>([ + { id: 11, pageId: 4, title: "DB upgrade", from }, + ]), + new Map([[4, "Acme"]]), + ); + expect(group?.items[0]).toMatchObject({ + value: "maintenance-11", + description: from.toLocaleString(), + keywords: ["DB upgrade", "Acme"], + action: { type: "navigate", href: "/status-pages/4/maintenances" }, + }); + }); + + it("drops the page keyword when the page is unknown", () => { + const group = maintenancesGroup( + rows>([ + { id: 12, pageId: 99, title: "Patch", from }, + ]), + new Map(), + ); + expect(group?.items[0].keywords).toEqual(["Patch"]); + }); +}); + +describe("workspacesGroup", () => { + it("switches to the selected workspace slug", () => { + const switched: string[] = []; + const group = workspacesGroup( + rows[0][number]>([ + { id: 1, name: "Acme", slug: "acme" }, + { id: 2, name: "", slug: "unnamed" }, + ]), + (slug) => switched.push(slug), + ); + const [acme, unnamed] = group?.items ?? []; + expect(acme.value).toBe("workspace-1"); + expect(unnamed.label).toBe("Untitled Workspace"); + expect(unnamed.keywords).toEqual(["Untitled Workspace", "unnamed"]); + if (unnamed.action.type === "run") unnamed.action.run(); + expect(switched).toEqual(["unnamed"]); + }); + + it("does not cap idle items", () => { + const group = workspacesGroup( + rows[0][number]>([ + { id: 1, name: "Acme", slug: "acme" }, + ]), + () => {}, + ); + expect(group?.idleLimit).toBeUndefined(); + }); +}); diff --git a/apps/dashboard/src/components/command-menu/groups/scope.test.ts b/apps/dashboard/src/components/command-menu/groups/scope.test.ts new file mode 100644 index 000000000..31ce56dd8 --- /dev/null +++ b/apps/dashboard/src/components/command-menu/groups/scope.test.ts @@ -0,0 +1,115 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { MONITOR_TABS } from "@/app/(dashboard)/monitors/[id]/constants"; +import { STATUS_PAGE_TABS } from "@/app/(dashboard)/status-pages/[id]/constants"; + +import { monitorScopeGroups } from "./monitor-scope"; +import { statusPageScopeGroups } from "./status-page-scope"; + +type ScopeInput = Parameters[0]; + +// safe because statusPageScopeGroups only reads the fields set in the fixtures +function rows( + items: Record[], +): ScopeInput[K] { + return items as unknown as ScopeInput[K]; +} + +describe("monitorScopeGroups", () => { + it("lists every monitor tab under the monitor name", () => { + const [group] = monitorScopeGroups({ type: "monitor", id: 3, name: "API" }); + expect(group.heading).toBe("API"); + expect(group.items.map((i) => i.action)).toEqual( + MONITOR_TABS.map((t) => ({ + type: "navigate", + href: `/monitors/3/${t.value}`, + })), + ); + }); +}); + +describe("statusPageScopeGroups", () => { + const page = { type: "status-page" as const, id: 4, title: "Acme" }; + const from = new Date("2024-06-01T10:00:00Z"); + + const groups = statusPageScopeGroups({ + page, + statusReports: rows<"statusReports">([ + { id: 1, pageId: 4, title: "Outage", status: "investigating" }, + { id: 2, pageId: 4, title: "Old", status: "resolved" }, + { id: 3, pageId: 5, title: "Elsewhere", status: "investigating" }, + ]), + maintenances: rows<"maintenances">([ + { id: 10, pageId: 4, title: "DB upgrade", from }, + { id: 11, pageId: 5, title: "Other page", from }, + ]), + }); + const byHeading = Object.fromEntries(groups.map((g) => [g.heading, g])); + + it("returns the groups in order", () => { + expect(groups.map((g) => g.heading)).toEqual([ + "Actions", + "Status Reports", + "Maintenances", + "Pages", + ]); + }); + + it("scopes create actions to the page", () => { + const [report, maintenance] = byHeading.Actions.items; + expect(report.action).toEqual({ + type: "sheet", + sheet: { sheet: "status-report", pageId: 4 }, + }); + expect(maintenance.action).toEqual({ + type: "sheet", + sheet: { sheet: "maintenance", pageId: 4 }, + }); + }); + + it("offers an update only for this page's unresolved reports", () => { + const updates = byHeading.Actions.items.slice(2); + expect(updates.map((i) => i.label)).toEqual(["Add Update to Outage"]); + expect(updates[0].action).toEqual({ + type: "sheet", + sheet: { sheet: "status-report-update", reportId: 1 }, + }); + }); + + it("lists all of this page's reports, resolved included", () => { + expect(byHeading["Status Reports"].items.map((i) => i.action)).toEqual([ + { type: "navigate", href: "/status-pages/4/status-reports/1" }, + { type: "navigate", href: "/status-pages/4/status-reports/2" }, + ]); + }); + + it("lists only this page's maintenances", () => { + expect(byHeading.Maintenances.items.map((i) => i.value)).toEqual([ + "maintenance-10", + ]); + }); + + it("links every page tab", () => { + expect(byHeading.Pages.items.map((i) => i.action)).toEqual( + STATUS_PAGE_TABS.map((t) => ({ + type: "navigate", + href: `/status-pages/4/${t.value}`, + })), + ); + }); + + it("keeps the static groups when data is still loading", () => { + const loading = statusPageScopeGroups({ + page, + statusReports: undefined, + maintenances: undefined, + }); + expect(loading.map((g) => g.items.length)).toEqual([ + 2, + 0, + 0, + STATUS_PAGE_TABS.length, + ]); + }); +}); diff --git a/apps/dashboard/src/components/command-menu/groups/static.test.ts b/apps/dashboard/src/components/command-menu/groups/static.test.ts new file mode 100644 index 000000000..4df096607 --- /dev/null +++ b/apps/dashboard/src/components/command-menu/groups/static.test.ts @@ -0,0 +1,160 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { HELP_LINKS } from "@/config/help"; +import { SETTINGS_TABS } from "@/config/settings"; + +import { + createGroup, + createMaintenanceItem, + createStatusReportItem, + helpGroup, + navigationGroup, + settingsGroup, + themeGroup, +} from "./static"; + +describe("navigationGroup", () => { + it("places Incidents right after Overview", () => { + const labels = navigationGroup().items.map((i) => i.label); + expect(labels.slice(0, 2)).toEqual(["Overview", "Incidents"]); + }); + + it("lists Incidents exactly once", () => { + const labels = navigationGroup().items.map((i) => i.label); + expect(labels.filter((l) => l === "Incidents")).toHaveLength(1); + }); + + it("navigates to each item's href", () => { + expect(navigationGroup().items.map((item) => item.action)).toEqual( + [ + "/overview", + "/incidents", + "/monitors", + "/status-pages", + "/notifications", + "/chat", + "/agents/slack", + "/agents/cli", + "/agents/mcp", + ].map((href) => ({ type: "navigate", href })), + ); + }); + + it("keeps values unique", () => { + const values = navigationGroup().items.map((i) => i.value); + expect(new Set(values).size).toBe(values.length); + }); +}); + +describe("createStatusReportItem / createMaintenanceItem", () => { + it("opens the sheet without a page at the root", () => { + expect(createStatusReportItem().action).toEqual({ + type: "sheet", + sheet: { sheet: "status-report", pageId: undefined }, + }); + expect(createMaintenanceItem().action).toEqual({ + type: "sheet", + sheet: { sheet: "maintenance", pageId: undefined }, + }); + }); + + it("passes the page id through when scoped", () => { + expect(createStatusReportItem(7).action).toEqual({ + type: "sheet", + sheet: { sheet: "status-report", pageId: 7 }, + }); + expect(createMaintenanceItem(7).action).toEqual({ + type: "sheet", + sheet: { sheet: "maintenance", pageId: 7 }, + }); + }); + + it("uses a distinct value when scoped so it never collides with the root item", () => { + expect(createStatusReportItem().value).toBe("Create Status Report"); + expect(createStatusReportItem(7).value).toBe("create-status-report"); + expect(createMaintenanceItem().value).toBe("Create Maintenance"); + expect(createMaintenanceItem(7).value).toBe("create-maintenance"); + }); +}); + +describe("createGroup", () => { + it("leads with Declare Incident", () => { + const [first] = createGroup().items; + expect(first.label).toBe("Declare Incident"); + expect(first.action).toEqual({ + type: "sheet", + sheet: { sheet: "declare-incident" }, + }); + }); + + it("routes monitor and status page creation to their pages", () => { + const hrefs = createGroup().items.flatMap((i) => + i.action.type === "navigate" ? [i.action.href] : [], + ); + expect(hrefs).toEqual(["/monitors/create", "/status-pages/create"]); + }); +}); + +describe("settingsGroup", () => { + it("starts with every settings tab", () => { + const items = settingsGroup().items; + expect(items.slice(0, SETTINGS_TABS.length).map((i) => i.label)).toEqual( + SETTINGS_TABS.map((t) => t.label), + ); + }); + + it("deep-links to settings anchors", () => { + const apiKeys = settingsGroup().items.find((i) => i.label === "API Keys"); + expect(apiKeys?.action).toEqual({ + type: "navigate", + href: "/settings/general#api-keys", + }); + }); + + it("keeps values unique", () => { + const values = settingsGroup().items.map((i) => i.value); + expect(new Set(values).size).toBe(values.length); + }); +}); + +describe("helpGroup", () => { + it("opens the support sheet first", () => { + const [support] = helpGroup().items; + expect(support.action).toEqual({ + type: "sheet", + sheet: { sheet: "support" }, + }); + }); + + it("opens help links externally", () => { + const links = helpGroup().items.slice(1); + expect(links.map((i) => i.action)).toEqual( + HELP_LINKS.map((l) => ({ type: "external", href: l.href })), + ); + }); +}); + +describe("themeGroup", () => { + function toggle(resolvedTheme: string | undefined) { + const calls: string[] = []; + const [item] = themeGroup(resolvedTheme, (t) => calls.push(t)).items; + if (item.action.type === "run") item.action.run(); + return { label: item.label, calls }; + } + + it("switches dark to light", () => { + expect(toggle("dark")).toEqual({ + label: "Switch to light theme", + calls: ["light"], + }); + }); + + it("switches light or unknown to dark", () => { + expect(toggle("light").calls).toEqual(["dark"]); + expect(toggle(undefined)).toEqual({ + label: "Switch to dark theme", + calls: ["dark"], + }); + }); +}); diff --git a/apps/dashboard/src/components/command-menu/types.test.ts b/apps/dashboard/src/components/command-menu/types.test.ts new file mode 100644 index 000000000..7f3ae5965 --- /dev/null +++ b/apps/dashboard/src/components/command-menu/types.test.ts @@ -0,0 +1,16 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { pageLabel } from "./types"; + +describe("pageLabel", () => { + it("uses the monitor name", () => { + expect(pageLabel({ type: "monitor", id: 1, name: "API" })).toBe("API"); + }); + + it("uses the status page title", () => { + expect( + pageLabel({ type: "status-page", id: 2, title: "Acme Status" }), + ).toBe("Acme Status"); + }); +}); diff --git a/apps/dashboard/src/components/common/changes-table.test.tsx b/apps/dashboard/src/components/common/changes-table.test.tsx new file mode 100644 index 000000000..f3aff5032 --- /dev/null +++ b/apps/dashboard/src/components/common/changes-table.test.tsx @@ -0,0 +1,109 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + ChangesTable, + buildAuditLogChangeRows, + formatValue, +} from "./changes-table"; + +describe("formatValue", () => { + for (const [label, value, expected] of [ + ["null", null, "—"], + ["undefined", undefined, "—"], + ["empty string", "", "—"], + ["empty array", [], "—"], + ["string", "hello", "hello"], + ["zero", 0, "0"], + ["false", false, "false"], + ["date", new Date("2024-01-02T03:04:05Z"), "2024-01-02T03:04:05.000Z"], + ["array", [1, 2], "[1,2]"], + ["object", { a: 1 }, '{"a":1}'], + ] as const) { + it(`formats ${label}`, () => { + expect(formatValue(value)).toBe(expected); + }); + } +}); + +describe("buildAuditLogChangeRows", () => { + it("uses changedFields for an update and hides bookkeeping fields", () => { + expect( + buildAuditLogChangeRows({ + before: { name: "old", url: "a", updatedAt: 1 }, + after: { name: "new", url: "a", updatedAt: 2 }, + changedFields: ["name", "updatedAt"], + }), + ).toEqual([{ field: "name", before: "old", after: "new" }]); + }); + + it("returns nothing for an update without changedFields", () => { + expect( + buildAuditLogChangeRows({ + before: { name: "a" }, + after: { name: "b" }, + changedFields: null, + }), + ).toEqual([]); + }); + + it("lists the created fields for a create", () => { + expect( + buildAuditLogChangeRows({ + before: null, + after: { name: "x", workspaceId: 1, createdAt: 0 }, + changedFields: null, + }), + ).toEqual([{ field: "name", after: "x" }]); + }); + + it("lists the removed fields for a delete", () => { + const rows = buildAuditLogChangeRows({ + before: { name: "x", deletedAt: 0 }, + after: undefined, + changedFields: undefined, + }); + expect(rows).toEqual([{ field: "name", before: "x" }]); + expect("after" in rows[0]).toBe(false); + }); + + it("returns nothing without before or after", () => { + expect( + buildAuditLogChangeRows({ before: null, after: null, changedFields: [] }), + ).toEqual([]); + }); +}); + +describe("ChangesTable", () => { + it("renders nothing for no changes", () => { + expect(renderToStaticMarkup()).toBe(""); + }); + + it("renders a removed and an added line for an update", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("name"); + expect(html).toMatch(/data-kind="removed".*old.*data-kind="added".*new/); + }); + + it("renders only an added line for a create", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-kind="added"'); + expect(html).not.toContain('data-kind="removed"'); + }); + + it("distinguishes an explicit null from an absent value", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-kind="removed"'); + expect(html).toContain("—"); + expect(html).not.toContain('data-kind="added"'); + }); +}); diff --git a/apps/dashboard/src/components/common/status-badge.test.tsx b/apps/dashboard/src/components/common/status-badge.test.tsx new file mode 100644 index 000000000..e504b0430 --- /dev/null +++ b/apps/dashboard/src/components/common/status-badge.test.tsx @@ -0,0 +1,32 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { StatusBadge } from "./status-badge"; + +describe("StatusBadge", () => { + it("tints the badge by variant", () => { + const html = renderToStaticMarkup( + Down, + ); + expect(html).toContain("bg-destructive/10"); + expect(html).toContain("Down"); + expect(html).not.toContain('data-slot="status-dot"'); + }); + + it("stays neutral and renders a coloured dot with dot", () => { + const html = renderToStaticMarkup( + + Up + , + ); + expect(html).not.toMatch(/bg-(?:success|warning|destructive|info)\/10/); + expect(html).toMatch(/data-slot="status-dot"[^>]*bg-success/); + }); + + it("uses the default tint without a variant", () => { + expect(renderToStaticMarkup(Idle)).toContain( + "bg-muted/50", + ); + }); +}); diff --git a/apps/dashboard/src/components/data-table/table-cell-metadata.test.tsx b/apps/dashboard/src/components/data-table/table-cell-metadata.test.tsx new file mode 100644 index 000000000..87eb14ab9 --- /dev/null +++ b/apps/dashboard/src/components/data-table/table-cell-metadata.test.tsx @@ -0,0 +1,47 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { TableCellMetadata, getMetadataEntries } from "./table-cell-metadata"; + +describe("getMetadataEntries", () => { + it("returns no entries for non-objects", () => { + for (const value of [null, undefined, "x", 1, ["a"]]) { + expect(getMetadataEntries(value)).toEqual([]); + } + }); + + it("sorts keys and keeps only non-blank strings", () => { + expect( + getMetadataEntries({ zone: "eu", app: "api", blank: " ", count: 3 }), + ).toEqual([ + ["app", "api"], + ["zone", "eu"], + ]); + }); +}); + +describe("TableCellMetadata", () => { + it("renders a dash without entries", () => { + expect(renderToStaticMarkup()).toMatch( + />-<\/div>$/, + ); + }); + + it("collapses entries beyond maxEntries into a +n chip", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("+2"); + }); + + it("shows every entry without maxEntries", () => { + const html = renderToStaticMarkup( + , + ); + for (const part of ["a", "1", "b", "2"]) { + expect(html).toContain(`>${part}<`); + } + expect(html).not.toMatch(/>\+\d+[0]; + +// safe because hasIncidentActions only reads status, closedAt and deletable +function incident(fields: { + status: string; + closedAt: Date | null; + deletable: boolean; +}): Incident { + return fields as unknown as Incident; +} + +describe("hasIncidentActions", () => { + it("offers closing a resolved, unclosed incident", () => { + expect( + hasIncidentActions( + incident({ status: "resolved", closedAt: null, deletable: false }), + ), + ).toBe(true); + }); + + it("offers deleting a deletable incident", () => { + expect( + hasIncidentActions( + incident({ status: "open", closedAt: null, deletable: true }), + ), + ).toBe(true); + }); + + it("offers nothing for an open or already closed incident", () => { + expect( + hasIncidentActions( + incident({ status: "open", closedAt: null, deletable: false }), + ), + ).toBe(false); + expect( + hasIncidentActions( + incident({ + status: "resolved", + closedAt: new Date(), + deletable: false, + }), + ), + ).toBe(false); + }); +}); diff --git a/apps/dashboard/src/components/onboarding/checks-table-adapter.test.ts b/apps/dashboard/src/components/onboarding/checks-table-adapter.test.ts new file mode 100644 index 000000000..f47183b5d --- /dev/null +++ b/apps/dashboard/src/components/onboarding/checks-table-adapter.test.ts @@ -0,0 +1,103 @@ +import type { CheckResult } from "@openstatus/services/monitor"; +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { checkResultToResponseLog } from "./checks-table-adapter"; + +const timing = { + dnsStart: 100, + dnsDone: 110, + connectStart: 110, + connectDone: 130, + tlsHandshakeStart: 0, + tlsHandshakeDone: 0, + firstByteStart: 130, + firstByteDone: 180, + transferStart: 180, + transferDone: 185, +}; + +function success(status: number): CheckResult { + return { + state: "success", + type: "http", + region: "ams", + status, + latency: 85, + timestamp: 1_700_000_000_000, + timing, + headers: { "content-type": "text/html" }, + }; +} + +describe("checkResultToResponseLog", () => { + it("maps a successful check", () => { + const row = checkResultToResponseLog(success(200), 42, "https://x.dev"); + expect(row).toMatchObject({ + id: "ams", + region: "ams", + statusCode: 200, + latency: 85, + requestStatus: "success", + monitorId: "42", + url: "https://x.dev", + error: false, + message: null, + body: null, + headers: { "content-type": "text/html" }, + timestamp: 1_700_000_000_000, + cronTimestamp: 1_700_000_000_000, + trigger: "api", + }); + }); + + it("derives phase durations and zeroes phases that never fired", () => { + const row = checkResultToResponseLog(success(200), 1, ""); + expect(row.type === "http" && row.timing).toEqual({ + dns: 10, + connect: 20, + tls: 0, + ttfb: 50, + transfer: 5, + }); + }); + + for (const [status, expected] of [ + [100, "error"], + [204, "success"], + [301, "success"], + [404, "error"], + [503, "error"], + ] as const) { + it(`treats status ${status} as ${expected}`, () => { + expect( + checkResultToResponseLog(success(status), 1, "").requestStatus, + ).toBe(expected); + }); + } + + it("maps a failed check", () => { + const row = checkResultToResponseLog( + { + state: "error", + type: "http", + region: "iad", + message: "timeout", + timestamp: 5, + }, + 7, + "https://x.dev", + ); + expect(row).toMatchObject({ + id: "iad", + statusCode: 0, + latency: 0, + requestStatus: "error", + error: true, + message: "timeout", + timing: null, + monitorId: "7", + timestamp: 5, + }); + }); +}); diff --git a/apps/dashboard/src/components/ui/checkbox-tree.test.tsx b/apps/dashboard/src/components/ui/checkbox-tree.test.tsx new file mode 100644 index 000000000..642cd27c4 --- /dev/null +++ b/apps/dashboard/src/components/ui/checkbox-tree.test.tsx @@ -0,0 +1,77 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { CheckboxTree, toCheckboxTreeItems } from "./checkbox-tree"; + +describe("toCheckboxTreeItems", () => { + it("nests grouped components and keeps ungrouped ones as leaves", () => { + expect( + toCheckboxTreeItems( + [ + { id: 1, name: "Website", order: 0 }, + { id: 2, name: "REST", order: 1, groupId: 7, groupOrder: 0 }, + { id: 3, name: "GraphQL", order: 2, groupId: 7, groupOrder: 1 }, + ], + [{ id: 7, name: "API" }], + ), + ).toEqual([ + { id: 1, label: "Website" }, + { + id: 7, + label: "API", + children: [ + { id: 2, label: "REST" }, + { id: 3, label: "GraphQL" }, + ], + }, + ]); + }); +}); + +describe("CheckboxTree", () => { + const noop = () => {}; + const items = [ + { id: 1, label: "Website" }, + { + id: 7, + label: "API", + children: [ + { id: 2, label: "REST" }, + { id: 3, label: "GraphQL" }, + ], + }, + ]; + + function states(value: number[]) { + const html = renderToStaticMarkup( + , + ); + return [...html.matchAll(/role="checkbox" aria-checked="([^"]+)"/g)].map( + (m) => m[1], + ); + } + + it("renders a row per leaf, group and child", () => { + expect(states([])).toEqual(["false", "false", "false", "false"]); + }); + + it("marks a partially selected group as indeterminate", () => { + expect(states([2])).toEqual(["false", "mixed", "true", "false"]); + }); + + it("marks a fully selected group as checked", () => { + expect(states([1, 2, 3])).toEqual(["true", "true", "true", "true"]); + }); + + it("hides a group without children", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain("Empty group"); + }); +}); diff --git a/apps/dashboard/src/data/metrics.client.test.ts b/apps/dashboard/src/data/metrics.client.test.ts new file mode 100644 index 000000000..3ba9fb785 --- /dev/null +++ b/apps/dashboard/src/data/metrics.client.test.ts @@ -0,0 +1,139 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { + getMonitorListMetrics, + isPaidPeriod, + mapMetrics, + mapRegionMetrics, +} from "./metrics.client"; + +type Monitors = Parameters[0]; +type Metrics = Parameters[0]; +type Timeline = Parameters[0]; + +describe("isPaidPeriod", () => { + it("gates only the long periods", () => { + expect(isPaidPeriod("30d")).toBe(true); + expect(isPaidPeriod("90d")).toBe(true); + expect(isPaidPeriod("14d")).toBe(false); + expect(isPaidPeriod("1d")).toBe(false); + }); +}); + +describe("mapMetrics", () => { + it("counts degraded checks as up", () => { + const [row] = + mapMetrics({ + data: [ + { + p50Latency: 1, + p75Latency: 2, + p90Latency: 3, + p95Latency: 4, + p99Latency: 5, + count: 10, + success: 7, + degraded: 2, + error: 1, + lastTimestamp: 123, + }, + ], + } as unknown as Metrics) ?? []; + expect(row).toEqual({ + p50: 1, + p75: 2, + p90: 3, + p95: 4, + p99: 5, + total: 10, + uptime: 0.9, + degraded: 2, + error: 1, + lastTimestamp: 123, + }); + }); +}); + +describe("mapRegionMetrics", () => { + function row(region: string, timestamp: number, p50: number, p99: number) { + return { + region, + timestamp, + p50Latency: p50, + p75Latency: null, + p90Latency: p50, + p95Latency: null, + p99Latency: p99, + }; + } + + it("returns empty sorted entries without a timeline", () => { + expect(mapRegionMetrics(undefined, ["iad", "ams"], "p50")).toEqual([ + { region: "ams", p50: 0, p90: 0, p99: 0, trend: [] }, + { region: "iad", p50: 0, p90: 0, p99: 0, trend: [] }, + ]); + }); + + it("averages percentiles and reverses the trend per region", () => { + const timeline = { + data: [ + row("iad", 2, 100, 300), + row("iad", 1, 201, 400), + row("ams", 2, 50, 60), + row("fra", 2, 999, 999), + ], + } as unknown as Timeline; + + const result = mapRegionMetrics(timeline, ["ams", "iad"], "p99"); + expect(result.map((r) => r.region)).toEqual(["ams", "iad"]); + + const iad = result[1]; + expect(iad.p50).toBe(151); + expect(iad.p99).toBe(350); + expect(iad.trend).toEqual([ + { latency: 400, timestamp: 1, iad: 400 }, + { latency: 300, timestamp: 2, iad: 300 }, + ]); + }); + + it("treats missing percentile values as 0", () => { + const timeline = { + data: [row("ams", 1, 10, 20)], + } as unknown as Timeline; + expect(mapRegionMetrics(timeline, ["ams"], "p75")[0].trend).toEqual([ + { latency: 0, timestamp: 1, ams: 0 }, + ]); + }); +}); + +describe("getMonitorListMetrics", () => { + const monitors = [ + { status: "active", active: true }, + { status: "active", active: true }, + { status: "degraded", active: true }, + { status: "error", active: true }, + { status: "error", active: false }, + ] as unknown as Monitors; + + it("counts active monitors per status and inactive ones separately", () => { + const metrics = getMonitorListMetrics(monitors, [ + { monitorId: "1", p95Latency: 120 }, + { monitorId: "2", p95Latency: 1500 }, + ]); + expect( + metrics.map(({ key, value, variant }) => ({ key, value, variant })), + ).toEqual([ + { key: "active", value: 2, variant: "success" }, + { key: "degraded", value: 1, variant: "warning" }, + { key: "error", value: 1, variant: "destructive" }, + { key: "inactive", value: 1, variant: "default" }, + { key: "p95", value: "1.5 sec", variant: "ghost" }, + ]); + }); + + it("shows N/A without latency data", () => { + const p95 = getMonitorListMetrics().find((m) => m.key === "p95"); + expect(p95?.value).toBe("N/A"); + }); +}); diff --git a/apps/dashboard/src/data/overview-events.client.test.ts b/apps/dashboard/src/data/overview-events.client.test.ts new file mode 100644 index 000000000..6137ca3fc --- /dev/null +++ b/apps/dashboard/src/data/overview-events.client.test.ts @@ -0,0 +1,163 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { + buildOverviewData, + getIncidentStatus, + getMaintenanceStatus, +} from "./overview-events.client"; + +type Input = Parameters[0]; + +const now = new Date("2024-06-15T12:00:00Z"); +const daysAgo = (n: number) => new Date(now.getTime() - n * 86_400_000); + +// safe because buildOverviewData only reads the fields set below +function input(partial: Partial>): Input { + return { + monitors: [], + pages: [], + monitorIncidents: [], + statusReports: [], + maintenances: [], + ...partial, + } as unknown as Input; +} + +function metric(data: ReturnType, title: string) { + return data.metrics.find((m) => m.title === title); +} + +describe("getIncidentStatus", () => { + it("prefers resolved over acknowledged", () => { + expect( + getIncidentStatus({ acknowledgedAt: daysAgo(2), resolvedAt: daysAgo(1) }), + ).toBe("resolved"); + expect( + getIncidentStatus({ acknowledgedAt: daysAgo(2), resolvedAt: null }), + ).toBe("acknowledged"); + expect(getIncidentStatus({ acknowledgedAt: null, resolvedAt: null })).toBe( + "ongoing", + ); + }); +}); + +describe("getMaintenanceStatus", () => { + const window = { from: daysAgo(1), to: daysAgo(-1) }; + + it("classifies relative to now", () => { + expect(getMaintenanceStatus(window, daysAgo(2))).toBe("scheduled"); + expect(getMaintenanceStatus(window, now)).toBe("in-progress"); + expect(getMaintenanceStatus(window, daysAgo(-2))).toBe("completed"); + }); + + it("counts the window bounds as in progress", () => { + expect(getMaintenanceStatus(window, window.from)).toBe("in-progress"); + expect(getMaintenanceStatus(window, window.to)).toBe("in-progress"); + }); +}); + +describe("buildOverviewData", () => { + it("splits monitor incidents into open and recently resolved", () => { + const open = { startedAt: daysAgo(1), resolvedAt: null }; + const recent = { startedAt: daysAgo(3), resolvedAt: daysAgo(2) }; + const old = { startedAt: daysAgo(30), resolvedAt: daysAgo(20) }; + const data = buildOverviewData( + input({ monitorIncidents: [open, recent, old] }), + now, + ); + expect(data.needsAttention.map((e) => e.type)).toEqual(["incident"]); + expect(data.recentlyResolved).toHaveLength(1); + expect(metric(data, "Open Incidents")).toMatchObject({ + value: 1, + variant: "destructive", + href: undefined, + }); + }); + + it("counts managed incidents instead of monitor incidents when provided", () => { + const data = buildOverviewData( + { + ...input({ monitorIncidents: [{ resolvedAt: null }] }), + managedIncidents: [ + { status: "open" }, + { status: "mitigated" }, + ] as unknown as Input["managedIncidents"], + endedIncidents: [ + { + status: "resolved", + resolvedAt: daysAgo(1), + closedAt: daysAgo(1), + }, + ] as unknown as Input["endedIncidents"], + }, + now, + ); + expect(metric(data, "Open Incidents")).toMatchObject({ + value: 2, + href: "/incidents", + }); + expect( + data.recentlyResolved.filter((e) => e.type === "managedIncident"), + ).toHaveLength(1); + }); + + it("separates maintenances from triage", () => { + const upcoming = { from: daysAgo(-1), to: daysAgo(-2) }; + const finished = { from: daysAgo(3), to: daysAgo(2) }; + const data = buildOverviewData( + input({ maintenances: [upcoming, finished] }), + now, + ); + expect(data.needsAttention).toEqual([]); + expect(data.upcomingMaintenances).toHaveLength(1); + expect(data.recentlyResolved).toHaveLength(1); + expect(metric(data, "Scheduled Maintenances")).toMatchObject({ + value: 1, + variant: "info", + }); + }); + + it("flags open status reports", () => { + const data = buildOverviewData( + input({ + statusReports: [ + { status: "investigating", createdAt: daysAgo(1), updates: [] }, + { + status: "resolved", + createdAt: daysAgo(3), + updatedAt: daysAgo(2), + updates: [{ date: daysAgo(2) }], + }, + ], + }), + now, + ); + expect(data.needsAttention.map((e) => e.type)).toEqual(["report"]); + expect(data.recentlyResolved.map((e) => e.type)).toEqual(["report"]); + expect(metric(data, "Open Reports")).toMatchObject({ + value: 1, + variant: "warning", + }); + }); + + it("uses default variants when nothing is open", () => { + const data = buildOverviewData( + input({ monitors: [{}, {}], pages: [{}] }), + now, + ); + expect( + data.metrics.map(({ title, value, variant }) => ({ + title, + value, + variant, + })), + ).toEqual([ + { title: "Monitors", value: 2, variant: "default" }, + { title: "Status Pages", value: 1, variant: "default" }, + { title: "Open Incidents", value: 0, variant: "default" }, + { title: "Open Reports", value: 0, variant: "default" }, + { title: "Scheduled Maintenances", value: 0, variant: "default" }, + ]); + }); +}); diff --git a/apps/dashboard/src/data/page-components.client.test.ts b/apps/dashboard/src/data/page-components.client.test.ts new file mode 100644 index 000000000..742ecc9f2 --- /dev/null +++ b/apps/dashboard/src/data/page-components.client.test.ts @@ -0,0 +1,91 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { + getActions, + toComponentSections, + toGroupNameLookup, +} from "./page-components.client"; + +const groups = [ + { id: 1, name: "API" }, + { id: 2, name: "Empty" }, +]; + +function component( + id: number, + order: number, + groupId: number | null = null, + groupOrder: number | null = null, +) { + return { id, name: `c${id}`, order, groupId, groupOrder }; +} + +function shape(sections: ReturnType) { + return sections.map((s) => ({ + group: s.group?.name ?? null, + items: s.items.map((c) => c.id), + })); +} + +describe("toComponentSections", () => { + it("places a group at its first member's order and sorts members by groupOrder", () => { + const sections = toComponentSections( + [ + component(10, 0), + component(11, 3, 1, 2), + component(12, 2, 1, 1), + component(13, 5), + ], + groups, + ); + expect(shape(sections)).toEqual([ + { group: null, items: [10] }, + { group: "API", items: [12, 11] }, + { group: null, items: [13] }, + ]); + }); + + it("merges consecutive ungrouped components into one section", () => { + const sections = toComponentSections( + [component(3, 2), component(1, 0), component(2, 1)], + groups, + ); + expect(shape(sections)).toEqual([{ group: null, items: [1, 2, 3] }]); + }); + + it("drops empty groups and treats unknown groupIds as ungrouped", () => { + const sections = toComponentSections([component(1, 0, 99)], groups); + expect(shape(sections)).toEqual([{ group: null, items: [1] }]); + }); + + it("treats missing order as 0", () => { + const sections = toComponentSections( + [component(1, 1), { id: 2, name: "c2" }], + [], + ); + expect(shape(sections)).toEqual([{ group: null, items: [2, 1] }]); + }); +}); + +describe("toGroupNameLookup", () => { + it("maps grouped components to their group name only", () => { + const lookup = toGroupNameLookup( + [ + { id: 1, groupId: 1 }, + { id: 2, groupId: null }, + { id: 3, groupId: 99 }, + ], + groups, + ); + expect([...lookup]).toEqual([[1, "API"]]); + }); +}); + +describe("getActions", () => { + it("attaches the delete handler", () => { + const onDelete = () => {}; + expect(getActions({ delete: onDelete })[0].onClick).toBe(onDelete); + expect(getActions({})[0].onClick).toBeUndefined(); + }); +}); diff --git a/apps/dashboard/src/data/status-codes.test.ts b/apps/dashboard/src/data/status-codes.test.ts new file mode 100644 index 000000000..6b0e5d101 --- /dev/null +++ b/apps/dashboard/src/data/status-codes.test.ts @@ -0,0 +1,24 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { getStatusCodeVariant } from "./status-codes"; + +describe("getStatusCodeVariant", () => { + for (const [code, variant] of [ + [200, "success"], + [204, "success"], + [301, "info"], + [404, "warning"], + [429, "warning"], + [500, "destructive"], + [503, "destructive"], + [100, "muted"], + [0, "muted"], + [null, "muted"], + [undefined, "muted"], + ] as const) { + it(`${code} -> ${variant}`, () => { + expect(getStatusCodeVariant(code)).toBe(variant); + }); + } +}); diff --git a/apps/dashboard/src/data/status-page-history.test.ts b/apps/dashboard/src/data/status-page-history.test.ts new file mode 100644 index 000000000..9cb61f0b1 --- /dev/null +++ b/apps/dashboard/src/data/status-page-history.test.ts @@ -0,0 +1,104 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { FakeTime } from "@std/testing/time"; + +import { + type HistoryEvent, + cellFromPercentage, + eventsForMonth, + getColumnVisibility, + monthKeyToFullLabel, + monthKeyToLabel, + parseWindow, +} from "./status-page-history"; + +describe("parseWindow", () => { + it("accepts the known windows", () => { + expect(parseWindow("6")).toBe(6); + expect(parseWindow("12")).toBe(12); + expect(parseWindow("24")).toBe(24); + }); + + it("falls back to the first window", () => { + expect(parseWindow("3")).toBe(6); + expect(parseWindow("abc")).toBe(6); + expect(parseWindow("")).toBe(6); + }); +}); + +describe("cellFromPercentage", () => { + it("marks missing data", () => { + expect(cellFromPercentage(null)).toEqual({ + percentage: null, + status: "no-data", + }); + }); + + it("marks the current month as in progress", () => { + expect(cellFromPercentage(50, true).status).toBe("in-progress"); + }); + + it("applies the thresholds", () => { + expect(cellFromPercentage(100).status).toBe("operational"); + expect(cellFromPercentage(99.9).status).toBe("operational"); + expect(cellFromPercentage(99.89).status).toBe("degraded"); + expect(cellFromPercentage(99).status).toBe("degraded"); + expect(cellFromPercentage(98.99).status).toBe("down"); + }); +}); + +describe("eventsForMonth", () => { + function event(from: string, to: string | null) { + return { + from: new Date(from), + to: to ? new Date(to) : null, + } as unknown as HistoryEvent; + } + + it("keeps events overlapping the UTC month", () => { + const inside = event("2025-03-10T00:00:00Z", "2025-03-11T00:00:00Z"); + const spanningStart = event("2025-02-28T23:00:00Z", "2025-03-01T01:00:00Z"); + const spanningEnd = event("2025-03-31T23:00:00Z", "2025-04-01T01:00:00Z"); + const before = event("2025-02-01T00:00:00Z", "2025-02-28T23:59:59Z"); + const after = event("2025-04-01T00:00:00Z", "2025-04-02T00:00:00Z"); + expect( + eventsForMonth( + [inside, spanningStart, spanningEnd, before, after], + "2025-03", + ), + ).toEqual([inside, spanningStart, spanningEnd]); + }); + + it("treats open-ended events as lasting until now", () => { + const time = new FakeTime(new Date("2025-05-15T00:00:00Z")); + try { + const open = event("2025-03-20T00:00:00Z", null); + expect(eventsForMonth([open], "2025-04")).toEqual([open]); + expect(eventsForMonth([open], "2025-06")).toEqual([]); + } finally { + time.restore(); + } + }); +}); + +describe("month labels", () => { + it("formats short and full labels", () => { + expect(monthKeyToLabel("2025-10")).toBe("Oct 25"); + expect(monthKeyToFullLabel("2025-10")).toBe("October 2025"); + }); +}); + +describe("getColumnVisibility", () => { + it("shows everything when the window covers all months", () => { + expect(getColumnVisibility(12, 12)).toEqual({}); + expect(getColumnVisibility(24, 12)).toEqual({}); + }); + + it("hides the slots beyond the window", () => { + expect(getColumnVisibility(6, 9)).toEqual({ + "7": false, + "8": false, + "9": false, + }); + }); +}); diff --git a/apps/dashboard/src/data/status-reports.client.test.ts b/apps/dashboard/src/data/status-reports.client.test.ts new file mode 100644 index 000000000..85dca282c --- /dev/null +++ b/apps/dashboard/src/data/status-reports.client.test.ts @@ -0,0 +1,111 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { incidentEndedAt } from "./managed-incidents.client"; +import { + getActions, + reportResolvedAt, + reportStartedAt, +} from "./status-reports.client"; + +const d = (iso: string) => new Date(iso); + +function report( + status: string, + updates: string[], + createdAt: string | null = "2024-01-01T00:00:00Z", + updatedAt: string | null = "2024-01-09T00:00:00Z", +) { + return { + status, + createdAt: createdAt ? d(createdAt) : null, + updatedAt: updatedAt ? d(updatedAt) : null, + updates: updates.map((date) => ({ date: d(date) })), + }; +} + +describe("reportStartedAt", () => { + it("uses the earliest update regardless of order", () => { + expect( + reportStartedAt( + report("investigating", [ + "2024-01-05T00:00:00Z", + "2024-01-03T00:00:00Z", + ]), + ), + ).toEqual(d("2024-01-03T00:00:00Z")); + }); + + it("falls back to createdAt, then the epoch", () => { + expect(reportStartedAt(report("investigating", []))).toEqual( + d("2024-01-01T00:00:00Z"), + ); + expect(reportStartedAt(report("investigating", [], null))).toEqual( + new Date(0), + ); + }); +}); + +describe("reportResolvedAt", () => { + it("is null while the report is open", () => { + expect( + reportResolvedAt(report("monitoring", ["2024-01-05T00:00:00Z"])), + ).toBeNull(); + }); + + it("uses the latest update once resolved", () => { + expect( + reportResolvedAt( + report("resolved", ["2024-01-07T00:00:00Z", "2024-01-05T00:00:00Z"]), + ), + ).toEqual(d("2024-01-07T00:00:00Z")); + }); + + it("falls back to updatedAt, then createdAt, for legacy reports", () => { + expect(reportResolvedAt(report("resolved", []))).toEqual( + d("2024-01-09T00:00:00Z"), + ); + expect( + reportResolvedAt(report("resolved", [], "2024-01-01T00:00:00Z", null)), + ).toEqual(d("2024-01-01T00:00:00Z")); + }); +}); + +describe("getActions", () => { + it("wires handlers by action id", () => { + const onDelete = () => {}; + const actions = getActions({ delete: onDelete }); + expect(actions.map((a) => a.id)).toEqual(["view-report", "delete"]); + expect(actions[0].onClick).toBeUndefined(); + expect(actions[1].onClick).toBe(onDelete); + }); +}); + +describe("incidentEndedAt", () => { + const resolvedAt = d("2024-01-02T00:00:00Z"); + const closedAt = d("2024-01-03T00:00:00Z"); + + it("prefers resolvedAt for resolved incidents", () => { + expect( + incidentEndedAt({ status: "resolved", resolvedAt, closedAt }), + ).toEqual(resolvedAt); + }); + + it("uses closedAt for canceled incidents", () => { + expect( + incidentEndedAt({ status: "canceled", resolvedAt: null, closedAt }), + ).toEqual(closedAt); + }); + + it("uses closedAt for a resolved incident missing resolvedAt", () => { + expect( + incidentEndedAt({ status: "resolved", resolvedAt: null, closedAt }), + ).toEqual(closedAt); + }); + + it("is null while ongoing", () => { + expect( + incidentEndedAt({ status: "open", resolvedAt: null, closedAt: null }), + ).toBeNull(); + }); +}); diff --git a/apps/dashboard/src/lib/composition.test.ts b/apps/dashboard/src/lib/composition.test.ts new file mode 100644 index 000000000..1f21141e1 --- /dev/null +++ b/apps/dashboard/src/lib/composition.test.ts @@ -0,0 +1,82 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { composeEventHandlers, composeRefs } from "./composition"; + +describe("composeEventHandlers", () => { + it("runs the original handler before ours", () => { + const calls: string[] = []; + const handler = composeEventHandlers( + () => calls.push("original"), + () => calls.push("ours"), + ); + handler(new Event("click")); + expect(calls).toEqual(["original", "ours"]); + }); + + it("skips ours when the original prevents default", () => { + let called = false; + const handler = composeEventHandlers( + (e: Event) => e.preventDefault(), + () => { + called = true; + }, + ); + handler(new Event("click", { cancelable: true })); + expect(called).toBe(false); + }); + + it("runs ours anyway when the check is disabled", () => { + let called = false; + const handler = composeEventHandlers( + (e: Event) => e.preventDefault(), + () => { + called = true; + }, + { checkForDefaultPrevented: false }, + ); + handler(new Event("click", { cancelable: true })); + expect(called).toBe(true); + }); + + it("tolerates missing handlers", () => { + expect(() => composeEventHandlers()(new Event("click"))).not.toThrow(); + }); +}); + +describe("composeRefs", () => { + it("assigns object refs and calls callback refs", () => { + const objectRef = { current: null as string | null }; + let callbackValue: string | null = null; + const ref = composeRefs(objectRef, (v) => { + callbackValue = v; + }); + ref("node"); + expect(objectRef.current).toBe("node"); + expect(callbackValue).toBe("node"); + }); + + it("ignores null and undefined refs", () => { + const ref = composeRefs(null, undefined); + expect(ref("node")).toBeUndefined(); + }); + + it("returns no cleanup when no ref returns one", () => { + const ref = composeRefs({ current: null }, () => {}); + expect(ref("node")).toBeUndefined(); + }); + + it("runs ref cleanups and nulls the other refs on cleanup", () => { + const objectRef = { current: null as string | null }; + let cleaned = false; + const ref = composeRefs(objectRef, () => () => { + cleaned = true; + }); + const cleanup = ref("node"); + expect(objectRef.current).toBe("node"); + expect(typeof cleanup).toBe("function"); + (cleanup as () => void)(); + expect(cleaned).toBe(true); + expect(objectRef.current).toBeNull(); + }); +}); diff --git a/apps/dashboard/src/lib/domains.test.ts b/apps/dashboard/src/lib/domains.test.ts new file mode 100644 index 000000000..e24e0d3a9 --- /dev/null +++ b/apps/dashboard/src/lib/domains.test.ts @@ -0,0 +1,62 @@ +import { expect } from "@std/expect"; +import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; +import { type Stub, stub } from "@std/testing/mock"; + +import { extractDomain, getApexDomain, getSubdomain } from "./domains"; + +let errorStub: Stub | undefined; +beforeEach(() => { + errorStub = stub(console, "error"); +}); +afterEach(() => errorStub?.restore()); + +describe("getSubdomain", () => { + it("returns null for the apex itself", () => { + expect(getSubdomain("openstatus.dev", "openstatus.dev")).toBeNull(); + }); + + it("strips the apex and its dot", () => { + expect(getSubdomain("status.openstatus.dev", "openstatus.dev")).toBe( + "status", + ); + expect(getSubdomain("a.b.openstatus.dev", "openstatus.dev")).toBe("a.b"); + }); +}); + +describe("getApexDomain", () => { + it("keeps a two-label hostname", () => { + expect(getApexDomain("https://openstatus.dev/pricing")).toBe( + "openstatus.dev", + ); + }); + + it("drops subdomains", () => { + expect(getApexDomain("https://status.app.openstatus.dev")).toBe( + "openstatus.dev", + ); + }); + + it("returns an empty string for an invalid URL", () => { + expect(getApexDomain("not a url")).toBe(""); + }); +}); + +describe("extractDomain", () => { + it("returns the label of a bare domain", () => { + expect(extractDomain("https://mxkaske.dev")).toBe("mxkaske"); + }); + + it("joins subdomains with dashes", () => { + expect(extractDomain("https://craft.mxkaske.dev")).toBe("craft-mxkaske"); + expect(extractDomain("https://a.b.mxkaske.dev")).toBe("a-b-mxkaske"); + }); + + it("returns an empty string for single-label hosts", () => { + expect(extractDomain("http://localhost:3000")).toBe(""); + }); + + it("returns an empty string for blank or invalid input", () => { + expect(extractDomain(" ")).toBe(""); + expect(extractDomain("craft.mxkaske.dev")).toBe(""); + }); +}); diff --git a/apps/dashboard/src/lib/formatter.test.ts b/apps/dashboard/src/lib/formatter.test.ts new file mode 100644 index 000000000..9612f3d97 --- /dev/null +++ b/apps/dashboard/src/lib/formatter.test.ts @@ -0,0 +1,126 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { FakeTime } from "@std/testing/time"; + +import { + formatDateForInput, + formatDateRange, + formatDistanceToNowShort, + formatMilliseconds, + formatNumber, + formatPercentage, +} from "./formatter"; + +describe("formatMilliseconds", () => { + it("keeps milliseconds up to one second", () => { + expect(formatMilliseconds(250)).toBe("250 ms"); + expect(formatMilliseconds(1000)).toBe("1,000 ms"); + }); + + it("switches to seconds above one second", () => { + expect(formatMilliseconds(1500)).toBe("1.5 sec"); + expect(formatMilliseconds(12_345)).toBe("12.35 sec"); + }); +}); + +describe("formatPercentage", () => { + it("uses two fraction digits by default", () => { + expect(formatPercentage(0.99951)).toBe("99.95%"); + expect(formatPercentage(1)).toBe("100.00%"); + }); + + it("honours a custom precision", () => { + expect(formatPercentage(0.5, 0)).toBe("50%"); + }); + + it("treats NaN as full uptime", () => { + expect(formatPercentage(Number.NaN)).toBe("100%"); + }); +}); + +describe("formatNumber", () => { + it("groups thousands", () => { + expect(formatNumber(1234567)).toBe("1,234,567"); + }); +}); + +describe("formatDateRange", () => { + it("returns 'All time' without bounds", () => { + expect(formatDateRange()).toBe("All time"); + }); + + it("describes open-ended ranges", () => { + const date = new Date(2024, 0, 15, 9, 5); + expect(formatDateRange(date)).toMatch(/^Since January 15/); + expect(formatDateRange(undefined, date)).toMatch(/^Until January 15/); + }); + + it("shows only the end time for a same-day range", () => { + const range = formatDateRange( + new Date(2024, 0, 15, 9, 5), + new Date(2024, 0, 15, 17, 30), + ); + expect(range).toMatch(/^January 15.* - 5:30\sPM$/); + }); + + it("shows only dates for whole-day ranges", () => { + expect( + formatDateRange( + new Date(2024, 0, 1), + new Date(2024, 0, 31, 23, 59, 59, 999), + ), + ).toBe("January 1, 2024 - January 31, 2024"); + }); + + it("shows date and time for partial-day ranges", () => { + const range = formatDateRange( + new Date(2024, 0, 1, 8, 0), + new Date(2024, 0, 3, 18, 0), + ); + expect(range).toMatch(/^January 1.*8:00\sAM - January 3.*6:00\sPM$/); + }); +}); + +describe("formatDistanceToNowShort", () => { + const now = new Date("2024-06-01T12:00:00Z"); + + for (const [secondsAgo, expected] of [ + [0, "0s ago"], + [59, "59s ago"], + [60, "1m ago"], + [59 * 60, "59m ago"], + [60 * 60, "1h ago"], + [23 * 60 * 60, "23h ago"], + [24 * 60 * 60, "1d ago"], + [10 * 24 * 60 * 60, "10d ago"], + ] as const) { + it(`${secondsAgo}s -> ${expected}`, () => { + const time = new FakeTime(now); + try { + const date = new Date(now.getTime() - secondsAgo * 1000); + expect(formatDistanceToNowShort(date)).toBe(expected); + } finally { + time.restore(); + } + }); + } + + it("clamps future dates to 0s", () => { + const time = new FakeTime(now); + try { + expect(formatDistanceToNowShort(new Date(now.getTime() + 60_000))).toBe( + "0s ago", + ); + } finally { + time.restore(); + } + }); +}); + +describe("formatDateForInput", () => { + it("formats a local datetime-local value with zero padding", () => { + expect(formatDateForInput(new Date(2024, 2, 5, 7, 4))).toBe( + "2024-03-05T07:04", + ); + }); +}); diff --git a/apps/dashboard/src/lib/onboarding/email-domain.test.ts b/apps/dashboard/src/lib/onboarding/email-domain.test.ts new file mode 100644 index 000000000..34112fc3d --- /dev/null +++ b/apps/dashboard/src/lib/onboarding/email-domain.test.ts @@ -0,0 +1,32 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { getCompanyDomainFromEmail } from "./email-domain"; + +describe("getCompanyDomainFromEmail", () => { + it("returns the domain of a company email", () => { + expect(getCompanyDomainFromEmail("max@openstatus.dev")).toBe( + "openstatus.dev", + ); + }); + + it("lowercases the domain", () => { + expect(getCompanyDomainFromEmail("Max@OpenStatus.DEV")).toBe( + "openstatus.dev", + ); + }); + + it("ignores generic providers regardless of case", () => { + expect(getCompanyDomainFromEmail("max@gmail.com")).toBeNull(); + expect(getCompanyDomainFromEmail("max@ProtonMail.com")).toBeNull(); + expect(getCompanyDomainFromEmail("max@pm.me")).toBeNull(); + }); + + it("returns null for missing or malformed input", () => { + expect(getCompanyDomainFromEmail(null)).toBeNull(); + expect(getCompanyDomainFromEmail(undefined)).toBeNull(); + expect(getCompanyDomainFromEmail("")).toBeNull(); + expect(getCompanyDomainFromEmail("no-at-sign")).toBeNull(); + expect(getCompanyDomainFromEmail("max@")).toBeNull(); + }); +}); diff --git a/apps/dashboard/src/test/next-link.mock.tsx b/apps/dashboard/src/test/next-link.mock.tsx new file mode 100644 index 000000000..03dcc1836 --- /dev/null +++ b/apps/dashboard/src/test/next-link.mock.tsx @@ -0,0 +1,26 @@ +// Test double for next/link, swapped in via --import-map: Deno cannot resolve +// Next's extensionless CJS entrypoints. Renders a plain anchor. +import type * as React from "react"; + +type LinkProps = Omit, "href"> & { + href: string | { pathname?: string; query?: Record }; + prefetch?: boolean | null; + replace?: boolean; + scroll?: boolean; +}; + +function toHref(href: LinkProps["href"]) { + if (typeof href === "string") return href; + const query = new URLSearchParams(href.query).toString(); + return `${href.pathname ?? ""}${query ? `?${query}` : ""}`; +} + +export default function Link({ + href, + prefetch: _prefetch, + replace: _replace, + scroll: _scroll, + ...props +}: LinkProps) { + return ; +} diff --git a/apps/dashboard/src/test/next-navigation.mock.ts b/apps/dashboard/src/test/next-navigation.mock.ts new file mode 100644 index 000000000..c5a9a0bc7 --- /dev/null +++ b/apps/dashboard/src/test/next-navigation.mock.ts @@ -0,0 +1,48 @@ +// Test double for next/navigation, swapped in via --import-map. Router calls +// are recorded on `routerCalls`; reset it between tests. +export const routerCalls: { method: string; args: unknown[] }[] = []; + +function record(method: string) { + return (...args: unknown[]) => { + routerCalls.push({ method, args }); + }; +} + +const router = { + push: record("push"), + replace: record("replace"), + refresh: record("refresh"), + back: record("back"), + forward: record("forward"), + prefetch: record("prefetch"), +}; + +export const navigationState: { + pathname: string; + params: Record; + searchParams: URLSearchParams; +} = { pathname: "/", params: {}, searchParams: new URLSearchParams() }; + +export function useRouter() { + return router; +} + +export function usePathname() { + return navigationState.pathname; +} + +export function useParams() { + return navigationState.params; +} + +export function useSearchParams() { + return navigationState.searchParams; +} + +export function notFound(): never { + throw new Error("NEXT_NOT_FOUND"); +} + +export function redirect(url: string): never { + throw new Error(`NEXT_REDIRECT ${url}`); +} diff --git a/apps/dashboard/test.importmap.json b/apps/dashboard/test.importmap.json index b9dd92bcf..4a9e12782 100644 --- a/apps/dashboard/test.importmap.json +++ b/apps/dashboard/test.importmap.json @@ -1,6 +1,9 @@ { "imports": { "@/": "./src/", - "@openstatus/upstash": "./src/test/upstash.mock.ts" + "@openstatus/upstash": "./src/test/upstash.mock.ts", + "@openstatus/tinybird/src/schema": "../../packages/tinybird/src/schema.ts", + "next/link": "./src/test/next-link.mock.tsx", + "next/navigation": "./src/test/next-navigation.mock.ts" } } diff --git a/apps/status-page/package.json b/apps/status-page/package.json index 4431ef339..eefbf6057 100644 --- a/apps/status-page/package.json +++ b/apps/status-page/package.json @@ -8,7 +8,7 @@ "build": "next build", "start": "next start", "lint": "next lint", - "test": "deno test --parallel -A --no-check --sloppy-imports", + "test": "deno test --parallel -A --no-check --sloppy-imports --import-map=test.importmap.json", "tsc": "tsc --noEmit" }, "dependencies": { diff --git a/apps/status-page/src/components/button/buttons.test.tsx b/apps/status-page/src/components/button/buttons.test.tsx new file mode 100644 index 000000000..ea891d4c2 --- /dev/null +++ b/apps/status-page/src/components/button/buttons.test.tsx @@ -0,0 +1,72 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ChartTooltipNumber } from "../chart/chart-tooltip-number"; +import { PopoverQuantile } from "../popover/popover-quantile"; +import { ButtonBack } from "./button-back"; +import { ButtonCopyLink } from "./button-copy-link"; + +describe("ButtonBack", () => { + it("links home by default", () => { + const html = renderToStaticMarkup(); + expect(html).toMatch(/]*href="\/"/); + expect(html).toContain("Back"); + }); + + it("links to a custom href", () => { + expect(renderToStaticMarkup()).toMatch( + /]*href="\/events"/, + ); + }); +}); + +describe("ButtonCopyLink", () => { + it("has an accessible label", () => { + expect(renderToStaticMarkup()).toMatch( + /Copy Link<\/span>/, + ); + }); +}); + +describe("PopoverQuantile", () => { + it("wraps its children in a popover trigger", () => { + const html = renderToStaticMarkup(p95); + expect(html).toContain('aria-haspopup="dialog"'); + expect(html).toContain("p95"); + }); +}); + +describe("ChartTooltipNumber", () => { + const chartConfig = { p50: { label: "P50" } }; + + it("uses the config label and a millisecond suffix", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("P50"); + expect(html).toMatch(/120]*>ms<\/span>/); + expect(html).toContain("--color-bg:var(--color-p50)"); + }); + + it("falls back to the series name", () => { + expect( + renderToStaticMarkup( + , + ), + ).toContain("p99"); + }); + + it("prefers a custom label formatter", () => { + expect( + renderToStaticMarkup( + `median (${name})`} + />, + ), + ).toContain("median (p50)"); + }); +}); diff --git a/apps/status-page/src/components/common/link.test.tsx b/apps/status-page/src/components/common/link.test.tsx new file mode 100644 index 000000000..2ad2294d0 --- /dev/null +++ b/apps/status-page/src/components/common/link.test.tsx @@ -0,0 +1,63 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { NuqsTestingAdapter } from "nuqs/adapters/testing"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { Link } from "./link"; + +function render(props: React.ComponentProps, searchParams = "") { + return renderToStaticMarkup( + + + , + ); +} + +describe("Link", () => { + it("keeps internal links in the same tab outside embeds", () => { + const html = render({ href: "/events", children: "Events" }); + expect(html).toContain('href="/events"'); + expect(html).not.toContain("target="); + }); + + it("opens internal links in a new tab inside an embed", () => { + const html = render({ href: "/events", children: "Events" }, "?embed=all"); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noopener noreferrer"'); + }); + + it("keeps a caller-provided rel inside an embed", () => { + const html = render( + { href: "/events", rel: "nofollow", children: "Events" }, + "?embed=feed", + ); + expect(html).toContain('rel="nofollow"'); + }); + + for (const href of [ + "https://openstatus.dev", + "mailto:ops@acme.dev", + "tel:+123", + "//cdn.acme.dev", + "#components", + ]) { + it(`leaves ${href} untouched inside an embed`, () => { + expect(render({ href, children: "x" }, "?embed=all")).not.toContain( + 'target="_blank"', + ); + }); + } + + it("respects an explicit target outside embeds", () => { + expect( + render({ href: "https://x.dev", target: "_blank", children: "x" }), + ).toContain('target="_blank"'); + }); + + it("applies the variant classes", () => { + expect(render({ href: "/", children: "x" })).toContain("font-medium"); + expect( + render({ href: "/", variant: "unstyled", children: "x" }), + ).not.toContain("font-medium"); + }); +}); diff --git a/apps/status-page/src/components/content/process-message.test.tsx b/apps/status-page/src/components/content/process-message.test.tsx new file mode 100644 index 000000000..75db588b1 --- /dev/null +++ b/apps/status-page/src/components/content/process-message.test.tsx @@ -0,0 +1,51 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ProcessMessage } from "./process-message"; + +function render(value: string) { + return renderToStaticMarkup(); +} + +describe("ProcessMessage", () => { + it("renders markdown formatting", () => { + const html = render("We are **investigating** `api` latency."); + expect(html).toContain("investigating"); + expect(html).toContain("api"); + }); + + it("opens links in a new tab without a referrer", () => { + const html = render("See [the docs](https://docs.openstatus.dev)."); + expect(html).toMatch( + /]*href="https:\/\/docs\.openstatus\.dev"[^>]*>the docs<\/a>/, + ); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noreferrer"'); + }); + + it("styles unordered lists and keeps their items", () => { + const html = render("- first\n- second"); + expect(html).toContain("list-disc"); + expect(html).toContain("
  • first
  • "); + expect(html).toContain("
  • second
  • "); + }); + + it("does not render raw HTML from the message", () => { + const html = render('\n\n'); + expect(html).not.toContain(" { + expect(render("[click](javascript:alert(1))")).not.toContain("alert(1)"); + }); + + // the `ol` override drops its props, so ordered list items vanish + it.ignore("keeps the items of ordered lists", () => { + const html = render("1. first\n2. second"); + expect(html).toContain("list-decimal"); + expect(html).toContain("
  • first
  • "); + }); +}); diff --git a/apps/status-page/src/components/date-picker.test.tsx b/apps/status-page/src/components/date-picker.test.tsx new file mode 100644 index 000000000..eb47b7471 --- /dev/null +++ b/apps/status-page/src/components/date-picker.test.tsx @@ -0,0 +1,89 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { DatePicker } from "./date-picker"; + +const day = 86_400_000; +const now = new Date(); +const presets = [ + { + id: "1d", + label: "Last day", + shortcut: "d", + values: { from: new Date(now.getTime() - day), to: now }, + }, + { + id: "7d", + label: "Last 7 days", + shortcut: "w", + values: { from: new Date(now.getTime() - 7 * day), to: now }, + }, +]; + +function presetVariants(html: string) { + return [ + ...html.matchAll(/data-variant="(\w+)"[^>]*>\s*([^<]+)<\/span>/g), + ].map((m) => [m[2], m[1]]); +} + +describe("DatePicker", () => { + it("highlights the preset matching the range", () => { + const html = renderToStaticMarkup( + {}} + presets={presets} + />, + ); + expect(presetVariants(html)).toEqual([ + ["Last day", "ghost"], + ["Last 7 days", "outline"], + ]); + }); + + it("highlights nothing for a custom range", () => { + const html = renderToStaticMarkup( + {}} + presets={presets} + />, + ); + expect(presetVariants(html).map(([, v]) => v)).toEqual(["ghost", "ghost"]); + }); + + it("fills the custom range inputs in local time", () => { + const from = new Date(2024, 0, 5, 9, 7); + const html = renderToStaticMarkup( + {}} presets={presets} />, + ); + expect(html).toMatch(/id="from"[^>]*value="2024-01-05T09:07"/); + expect(html).toMatch(/id="to"[^>]*value=""/); + }); + + it("limits the inputs to the oldest preset", () => { + const html = renderToStaticMarkup( + {}} + presets={presets} + />, + ); + const min = presets[1].values.from; + const pad = (n: number) => String(n).padStart(2, "0"); + const expected = `${min.getFullYear()}-${pad(min.getMonth() + 1)}-${pad(min.getDate())}T${pad(min.getHours())}:${pad(min.getMinutes())}`; + expect(html).toContain(`min="${expected}"`); + }); + + it("shows the preset shortcuts", () => { + const html = renderToStaticMarkup( + {}} + presets={presets} + />, + ); + expect(html).toMatch(/Last day[\s\S]*>d<\/kbd>/); + }); +}); diff --git a/apps/status-page/src/components/forms/form-manage-subscription.test.tsx b/apps/status-page/src/components/forms/form-manage-subscription.test.tsx new file mode 100644 index 000000000..63af75e18 --- /dev/null +++ b/apps/status-page/src/components/forms/form-manage-subscription.test.tsx @@ -0,0 +1,99 @@ +import type { RouterOutputs } from "@openstatus/api"; +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { FormManageSubscription } from "./form-manage-subscription"; + +type Page = NonNullable; + +// safe because the form only reads `page.trackers` +function asPage(trackers: unknown[]): Page { + return { trackers } as unknown as Page; +} + +const page = asPage([ + { + type: "group", + groupId: 1, + groupName: "API", + components: [ + { id: 2, name: "REST" }, + { id: 3, name: "GraphQL" }, + ], + }, + { type: "component", component: { id: 4, name: "Website" } }, +]); + +const onSubmit = async () => {}; + +function checkboxes(html: string) { + return [ + ...html.matchAll( + /role="checkbox" aria-checked="([^"]+)"[\s\S]*?]*>([^<]+)<\/label>/g, + ), + ].map((m) => [m[2], m[1]]); +} + +describe("FormManageSubscription", () => { + it("checks the selected components and marks a partial group as mixed", () => { + const html = renderToStaticMarkup( + , + ); + expect(checkboxes(html)).toEqual([ + ["Subscribe to specific components", "true"], + ["API", "mixed"], + ["REST", "true"], + ["GraphQL", "false"], + ["Website", "true"], + ]); + }); + + it("marks a group checked once all its components are selected", () => { + const html = renderToStaticMarkup( + , + ); + expect(checkboxes(html)).toContainEqual(["API", "true"]); + }); + + it("subscribes to specific components by default", () => { + const html = renderToStaticMarkup( + , + ); + expect(checkboxes(html)[0]).toEqual([ + "Subscribe to specific components", + "true", + ]); + expect(html).toContain("REST"); + }); + + it("hides the component list when subscribed to the whole page", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain("REST"); + expect(html).not.toContain("Website"); + }); + + it("shows an empty state for a page without components", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("No components to subscribe to"); + expect(html).toContain( + "This status page has no components to subscribe to.", + ); + }); +}); diff --git a/apps/status-page/src/components/forms/forms.test.tsx b/apps/status-page/src/components/forms/forms.test.tsx new file mode 100644 index 000000000..979a1cd42 --- /dev/null +++ b/apps/status-page/src/components/forms/forms.test.tsx @@ -0,0 +1,68 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + FormCard, + FormCardContent, + FormCardHeader, + FormCardTitle, +} from "./form-card"; +import { FormEmail } from "./form-email"; +import { FormPassword } from "./form-password"; +import { FormSubscribeEmail } from "./form-subscribe-email"; + +const onSubmit = async () => {}; + +describe("FormEmail", () => { + it("renders an empty, labelled email input", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('id="f"'); + expect(html).toMatch(/]*>Email<\/label>/); + expect(html).toMatch(/]*value=""/); + }); +}); + +describe("FormPassword", () => { + it("renders a masked, labelled password input", () => { + const html = renderToStaticMarkup(); + expect(html).toMatch(/]*>Password<\/label>/); + expect(html).toContain('type="password"'); + }); +}); + +describe("FormSubscribeEmail", () => { + it("starts subscribed to the whole page", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('placeholder="subscribe@me.com"'); + expect(html).toMatch( + /role="checkbox" aria-checked="false"[\s\S]*Subscribe to specific components/, + ); + expect(html).not.toContain("No components to subscribe to"); + }); +}); + +describe("FormCard", () => { + it("applies the variant border", () => { + expect( + renderToStaticMarkup(x), + ).toContain("border-destructive"); + expect( + renderToStaticMarkup(x), + ).toContain("border-info"); + }); + + it("composes header, title and content", () => { + const html = renderToStaticMarkup( + + + Subscribe + + Body + , + ); + expect(html).toMatch(/Subscribe[\s\S]*Body/); + }); +}); diff --git a/apps/status-page/src/components/locale-switcher.test.tsx b/apps/status-page/src/components/locale-switcher.test.tsx new file mode 100644 index 000000000..bedb8d2b2 --- /dev/null +++ b/apps/status-page/src/components/locale-switcher.test.tsx @@ -0,0 +1,23 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { LocaleSwitcher } from "./locale-switcher"; + +describe("LocaleSwitcher", () => { + it("renders nothing for a single-locale page", () => { + expect(renderToStaticMarkup()).toBe( + "", + ); + expect(renderToStaticMarkup()).toBe( + "", + ); + expect(renderToStaticMarkup()).toBe(""); + }); + + it("renders a skeleton before mount on a multi-locale page", () => { + expect( + renderToStaticMarkup(), + ).toContain('data-slot="status-locale-switcher-skeleton"'); + }); +}); diff --git a/apps/status-page/src/components/status-page/status-component-group.test.tsx b/apps/status-page/src/components/status-page/status-component-group.test.tsx new file mode 100644 index 000000000..7a8a10bb9 --- /dev/null +++ b/apps/status-page/src/components/status-page/status-component-group.test.tsx @@ -0,0 +1,35 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { StatusComponentGroup } from "./status-component-group"; + +describe("StatusComponentGroup", () => { + it("renders the title and carries the aggregated status", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("API"); + expect(html).toMatch(/]*data-variant="degraded"/); + }); + + it("starts collapsed by default", () => { + const html = renderToStaticMarkup( + + child component + , + ); + expect(html).toContain('aria-expanded="false"'); + expect(html).not.toContain("child component"); + }); + + it("shows children when open by default", () => { + const html = renderToStaticMarkup( + + child component + , + ); + expect(html).toContain('aria-expanded="true"'); + expect(html).toContain("child component"); + }); +}); diff --git a/apps/status-page/src/components/status-page/status-feed.test.tsx b/apps/status-page/src/components/status-page/status-feed.test.tsx new file mode 100644 index 000000000..a154a186f --- /dev/null +++ b/apps/status-page/src/components/status-page/status-feed.test.tsx @@ -0,0 +1,82 @@ +import { expect } from "@std/expect"; +import { afterEach, describe, it } from "@std/testing/bdd"; +import { NuqsTestingAdapter } from "nuqs/adapters/testing"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { pathnamePrefix } from "@/test/use-pathname-prefix.mock"; + +import { StatusFeed } from "./status-feed"; + +type Props = React.ComponentProps; + +function render(props: Props = {}) { + return renderToStaticMarkup( + + + , + ); +} + +const report = { + id: 7, + title: "API outage", + affected: ["API"], + updates: [ + { + date: new Date("2024-05-01T10:00:00Z"), + status: "investigating" as const, + message: "Looking into **errors**.", + }, + ], +}; + +const maintenance = { + id: 3, + title: "DB upgrade", + affected: [], + message: "Planned work", + from: new Date("2024-06-01T10:00:00Z"), + to: new Date("2024-06-01T11:00:00Z"), +}; + +afterEach(() => { + pathnamePrefix.value = ""; +}); + +describe("StatusFeed", () => { + it("links to the events history when empty", () => { + const html = render(); + expect(html).toContain('href="/events"'); + expect(html).toContain("View events history"); + }); + + it("links each event to its detail page", () => { + const html = render({ + statusReports: [report], + maintenances: [maintenance], + }); + expect(html).toContain('href="/events/report/7"'); + expect(html).toContain('href="/events/maintenance/3"'); + }); + + it("prefixes links with the locale or slug", () => { + pathnamePrefix.value = "acme"; + const html = render({ statusReports: [report] }); + expect(html).toContain('href="/acme/events/report/7"'); + expect(html).toContain('href="/acme/events"'); + }); + + it("lists the newest event first", () => { + const html = render({ + statusReports: [report], + maintenances: [maintenance], + }); + expect(html.indexOf("DB upgrade")).toBeLessThan(html.indexOf("API outage")); + }); + + it("renders report messages as markdown", () => { + expect(render({ statusReports: [report] })).toContain( + "errors", + ); + }); +}); diff --git a/apps/status-page/src/components/status-page/utils.test.ts b/apps/status-page/src/components/status-page/utils.test.ts new file mode 100644 index 000000000..ea5fa252d --- /dev/null +++ b/apps/status-page/src/components/status-page/utils.test.ts @@ -0,0 +1,88 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { + type ChartData, + getHighestPriorityStatus, + getHighestStatus, + getPercentagePriorityStatus, +} from "./utils"; + +function day( + counts: Partial>, +): ChartData { + return { + timestamp: 0, + success: 0, + degraded: 0, + error: 0, + info: 0, + ...counts, + }; +} + +describe("getHighestPriorityStatus", () => { + it("returns empty for a day without data", () => { + expect(getHighestPriorityStatus(day({}))).toBe("empty"); + }); + + it("ranks error over degraded over info over success", () => { + expect( + getHighestPriorityStatus( + day({ success: 1, info: 1, degraded: 1, error: 1 }), + ), + ).toBe("error"); + expect( + getHighestPriorityStatus(day({ success: 1, info: 1, degraded: 1 })), + ).toBe("degraded"); + expect(getHighestPriorityStatus(day({ success: 1, info: 1 }))).toBe("info"); + expect(getHighestPriorityStatus(day({ success: 1 }))).toBe("success"); + }); + + it("lets a single error minute outrank a full day of success", () => { + expect(getHighestPriorityStatus(day({ success: 1439, error: 1 }))).toBe( + "error", + ); + }); +}); + +describe("getPercentagePriorityStatus", () => { + it("returns empty for a day without data", () => { + expect(getPercentagePriorityStatus(day({}))).toBe("empty"); + }); + + it("applies the 95% and 75% success thresholds", () => { + expect(getPercentagePriorityStatus(day({ success: 95, error: 5 }))).toBe( + "success", + ); + expect(getPercentagePriorityStatus(day({ success: 94, error: 6 }))).toBe( + "degraded", + ); + expect(getPercentagePriorityStatus(day({ success: 75, error: 25 }))).toBe( + "degraded", + ); + expect(getPercentagePriorityStatus(day({ success: 74, error: 26 }))).toBe( + "error", + ); + }); + + it("treats a day with no successful checks as error", () => { + expect(getPercentagePriorityStatus(day({ error: 10 }))).toBe("error"); + expect(getPercentagePriorityStatus(day({ info: 10 }))).toBe("error"); + }); +}); + +describe("getHighestStatus", () => { + it("picks the most severe status", () => { + expect(getHighestStatus(["success", "info", "degraded", "error"])).toBe( + "error", + ); + expect(getHighestStatus(["success", "info", "degraded"])).toBe("degraded"); + expect(getHighestStatus(["success", "info"])).toBe("info"); + expect(getHighestStatus(["success"])).toBe("success"); + }); + + it("defaults to success for an empty list", () => { + expect(getHighestStatus([])).toBe("success"); + }); +}); diff --git a/apps/status-page/src/components/themes/theme-builder-provider.test.tsx b/apps/status-page/src/components/themes/theme-builder-provider.test.tsx new file mode 100644 index 000000000..ebb5dfd6b --- /dev/null +++ b/apps/status-page/src/components/themes/theme-builder-provider.test.tsx @@ -0,0 +1,65 @@ +import { THEMES } from "@openstatus/theme-store"; +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { NuqsTestingAdapter } from "nuqs/adapters/testing"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ThemePromptButton } from "./theme-agent-actions"; +import { + ThemeBuilderProvider, + useThemeBuilder, +} from "./theme-builder-provider"; +import { ThemePasteDialog } from "./theme-paste-dialog"; + +function Probe() { + const { theme, isModified } = useThemeBuilder(); + return ( +

    + {theme.id}|{String(isModified)} +

    + ); +} + +function render(node: React.ReactNode, searchParams = "") { + return renderToStaticMarkup( + + {node} + , + ); +} + +describe("ThemeBuilderProvider", () => { + it("starts from the default theme, unmodified", () => { + expect(render()).toBe(`

    ${THEMES.default.id}|false

    `); + }); + + it("starts from the theme in the `t` search param", () => { + expect(render(, "?t=dracula")).toBe("

    dracula|false

    "); + }); + + it("falls back to the default theme for an unknown key", () => { + expect(render(, "?t=nope")).toBe( + `

    ${THEMES.default.id}|false

    `, + ); + }); + + it("throws when used outside the provider", () => { + expect(() => renderToStaticMarkup()).toThrow( + "useThemeBuilder must be used within ThemeBuilderProvider", + ); + }); +}); + +describe("theme builder actions", () => { + it("renders the prompt and paste buttons", () => { + const html = render( + <> + + + , + ); + expect(html).toContain("Copy prompt"); + expect(html).toContain("Paste"); + expect(html).toContain('aria-haspopup="dialog"'); + }); +}); diff --git a/apps/status-page/src/components/ui/data-table/data-table-pagination.test.tsx b/apps/status-page/src/components/ui/data-table/data-table-pagination.test.tsx new file mode 100644 index 000000000..3c227689e --- /dev/null +++ b/apps/status-page/src/components/ui/data-table/data-table-pagination.test.tsx @@ -0,0 +1,149 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { + type ColumnDef, + type SortingState, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { DataTableColumnHeader } from "./data-table-column-header"; +import { + DataTablePagination, + DataTablePaginationSimple, +} from "./data-table-pagination"; + +type Row = { n: number }; +const rows: Row[] = Array.from({ length: 25 }, (_, n) => ({ n })); + +function Pagination({ + pageIndex, + selected = {}, + simple = false, +}: { + pageIndex: number; + selected?: Record; + simple?: boolean; +}) { + const table = useReactTable({ + data: rows, + columns: [{ accessorKey: "n" }], + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { + pagination: { pageIndex, pageSize: 10 }, + rowSelection: selected, + }, + }); + return simple ? ( + + ) : ( + + ); +} + +function render(props: Parameters[0]) { + return renderToStaticMarkup(); +} + +function disabledButtons(html: string) { + return [...html.matchAll(/]*)>\s*]*>([^<]+)<\/span>/g)] + .filter((m) => m[1].includes('disabled=""')) + .map((m) => m[2]); +} + +describe("DataTablePagination", () => { + it("shows the current page and selection count", () => { + const html = render({ pageIndex: 1, selected: { "0": true, "3": true } }); + expect(html).toContain("2 of 25 row(s) selected."); + expect(html).toContain("Page 2 of 3"); + }); + + it("disables backwards navigation on the first page", () => { + expect(disabledButtons(render({ pageIndex: 0 }))).toEqual([ + "Go to first page", + "Go to previous page", + ]); + }); + + it("disables forwards navigation on the last page", () => { + expect(disabledButtons(render({ pageIndex: 2 }))).toEqual([ + "Go to next page", + "Go to last page", + ]); + }); + + it("enables everything on a middle page", () => { + expect(disabledButtons(render({ pageIndex: 1 }))).toEqual([]); + }); +}); + +describe("DataTablePaginationSimple", () => { + it("shows the filtered row count", () => { + expect(render({ pageIndex: 0, simple: true })).toContain( + "25 of 25 row(s) filtered.", + ); + }); +}); + +describe("DataTableColumnHeader", () => { + function Header({ + sorting, + enableSorting = true, + }: { + sorting: SortingState; + enableSorting?: boolean; + }) { + const columns: ColumnDef[] = [ + { + accessorKey: "n", + enableSorting, + header: ({ column }) => ( + + ), + }, + ]; + const table = useReactTable({ + data: rows, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + state: { sorting }, + }); + const header = table.getHeaderGroups()[0].headers[0]; + const render = header.column.columnDef.header; + return typeof render === "function" ? render(header.getContext()) : null; + } + + function chevronClasses(html: string) { + return [...html.matchAll(/]*class="([^"]*)"/g)].map((m) => + m[1].includes("text-accent-foreground") ? "active" : "idle", + ); + } + + it("renders plain text for a column that cannot sort", () => { + const html = renderToStaticMarkup( +
    , + ); + expect(html).toMatch(/^]*>Number<\/div>$/); + }); + + it("highlights the active sort direction", () => { + expect( + chevronClasses( + renderToStaticMarkup(
    ), + ), + ).toEqual(["active", "idle"]); + expect( + chevronClasses( + renderToStaticMarkup(
    ), + ), + ).toEqual(["idle", "active"]); + expect( + chevronClasses(renderToStaticMarkup(
    )), + ).toEqual(["idle", "idle"]); + }); +}); diff --git a/apps/status-page/src/content/markdown/helpers.test.ts b/apps/status-page/src/content/markdown/helpers.test.ts new file mode 100644 index 000000000..9b5495769 --- /dev/null +++ b/apps/status-page/src/content/markdown/helpers.test.ts @@ -0,0 +1,290 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + canonicalUrl, + componentImpact, + componentImpactExplicit, + dominantDayStatus, + escapeCell, + escapeLinkLabel, + eventLog, + formatDate, + formatDay, + formatDayTime, + formatLogStamp, + formatMs, + formatPercent, + formatStamp, + frontmatter, + humanDuration, + mdUrl, + navLine, + relativeTime, + reportStatusGlyph, + statusGlyph, + statusLabel, + table, + uptimeBar, + withPoweredBy, + worstImpact, +} from "./helpers"; + +describe("statusLabel", () => { + test("labels component and report statuses", () => { + expect(statusLabel("error")).toBe("Outage"); + expect(statusLabel("info")).toBe("Maintenance"); + expect(statusLabel("identified")).toBe("Identified"); + }); + + test("passes unknown statuses through", () => { + expect(statusLabel("custom")).toBe("custom"); + }); +}); + +describe("frontmatter", () => { + const base = { + title: "Acme", + description: "Status", + baseUrl: "https://status.acme.com", + canonical: "https://status.acme.com", + }; + + test("quotes and escapes YAML scalars", () => { + const out = frontmatter({ + ...base, + title: 'Say "hi"\\\nnext\tline\u0001', + }); + expect(out).toContain('title: "Say \\"hi\\"\\\\\\nnext\\tline\\x01"'); + }); + + test("omits optional urls when absent", () => { + const out = frontmatter(base); + expect(out).not.toContain("homepage_url"); + expect(out).not.toContain("contact_url"); + expect(out.startsWith("---\n")).toBe(true); + expect(out.endsWith("---\n")).toBe(true); + }); + + test("includes live state and drops an invalid fetched_at", () => { + const out = frontmatter({ + ...base, + live: { + status: "degraded", + fetchedAt: "not a date", + activeReports: 1, + activeMaintenance: 0, + componentsOperational: 3, + componentsTotal: 4, + worstComponent: "API", + }, + }); + expect(out).toContain('status: "degraded"'); + expect(out).not.toContain("fetched_at"); + expect(out).toContain("components_operational: 3"); + expect(out).toContain('worst_component: "API"'); + }); + + test("renders fetched_at as ISO", () => { + const out = frontmatter({ + ...base, + live: { + status: "operational", + fetchedAt: Date.UTC(2026, 0, 2, 3, 4, 5), + activeReports: 0, + activeMaintenance: 0, + componentsOperational: 1, + componentsTotal: 1, + }, + }); + expect(out).toContain('fetched_at: "2026-01-02T03:04:05.000Z"'); + }); +}); + +describe("escapeCell / escapeLinkLabel / table", () => { + test("escapes pipes and backslashes and flattens newlines", () => { + expect(escapeCell(" a|b\\c\r\nd ")).toBe("a\\|b\\\\c d"); + }); + + test("escapes brackets in link labels", () => { + expect(escapeLinkLabel("[beta] api\\")).toBe("\\[beta\\] api\\\\"); + }); + + test("renders a header-only table when there are no rows", () => { + expect(table(["A", "B"], [])).toBe("| A | B |\n| --- | --- |"); + }); + + test("renders escaped rows", () => { + expect(table(["A"], [["x|y"]])).toBe("| A |\n| --- |\n| x\\|y |"); + }); +}); + +describe("number and date formatting", () => { + test("formatDate returns ISO or a dash", () => { + expect(formatDate(null)).toBe("—"); + expect(formatDate("nope")).toBe("—"); + expect(formatDate(0)).toBe("1970-01-01T00:00:00.000Z"); + }); + + test("formatMs rounds and handles missing values", () => { + expect(formatMs(12.6)).toBe("13ms"); + expect(formatMs(undefined)).toBe("—"); + expect(formatMs(Number.NaN)).toBe("—"); + }); + + test("formatPercent keeps three decimals", () => { + expect(formatPercent(0.99987)).toBe("99.987%"); + expect(formatPercent(1)).toBe("100.000%"); + }); + + const instant = Date.UTC(2026, 5, 18, 14, 5); + + test("formats in UTC", () => { + expect(formatDay(instant)).toBe("Jun 18, 2026"); + expect(formatDayTime(instant)).toBe("Jun 18, 2:05 PM"); + expect(formatLogStamp(instant)).toBe("2026-06-18 14:05"); + expect(formatStamp(instant)).toBe("Jun 18, 2026 14:05 (GMT+0)"); + }); + + test("formatDayTime uses 12 for midnight and noon", () => { + expect(formatDayTime(Date.UTC(2026, 0, 1, 0, 0))).toBe("Jan 1, 12:00 AM"); + expect(formatDayTime(Date.UTC(2026, 0, 1, 12, 0))).toBe("Jan 1, 12:00 PM"); + expect(formatDayTime("bad")).toBe("—"); + }); +}); + +describe("urls and navigation", () => { + test("canonicalUrl joins without a double slash", () => { + expect(canonicalUrl("https://x.dev", "/events")).toBe( + "https://x.dev/events", + ); + expect(canonicalUrl("https://x.dev")).toBe("https://x.dev"); + }); + + test("mdUrl maps the root to /.md", () => { + expect(mdUrl()).toBe("/.md"); + expect(mdUrl("/events/1")).toBe("/events/1.md"); + }); + + test("navLine links items with a url and escapes labels", () => { + expect( + navLine([{ label: "Home", url: "/.md" }, { label: "[Event]" }]), + ).toBe("[Home](/.md) › \\[Event\\]"); + }); +}); + +describe("status glyphs", () => { + test("falls back to the empty glyph", () => { + expect(statusGlyph("error")).toBe("x"); + expect(statusGlyph("unknown")).toBe("."); + }); + + test("maps report statuses", () => { + expect(reportStatusGlyph("resolved")).toBe("+"); + expect(reportStatusGlyph("maintenance")).toBe("="); + expect(reportStatusGlyph("investigating")).toBe("x"); + }); + + test("dominantDayStatus picks the worst non-empty segment", () => { + expect( + dominantDayStatus([ + { status: "success", height: 90 }, + { status: "degraded", height: 10 }, + { status: "error", height: 0 }, + ]), + ).toBe("degraded"); + expect(dominantDayStatus([])).toBe("empty"); + }); + + test("uptimeBar renders one glyph per day in a code span", () => { + expect(uptimeBar([])).toBe(""); + expect( + uptimeBar([ + { bar: [{ status: "success", height: 1 }] }, + { bar: [{ status: "error", height: 1 }] }, + { bar: [] }, + ]), + ).toBe("`+x.`"); + }); +}); + +describe("impacts", () => { + test("componentImpact hides operational", () => { + expect(componentImpact("API", "operational")).toBe("API"); + expect(componentImpact("API", null)).toBe("API"); + expect(componentImpact("API", "partial_outage")).toBe( + "API (partial outage)", + ); + }); + + test("componentImpactExplicit keeps operational", () => { + expect(componentImpactExplicit("API", "operational")).toBe( + "API (operational)", + ); + }); + + test("worstImpact returns the most severe", () => { + expect(worstImpact([])).toBeNull(); + expect( + worstImpact(["degraded_performance", "major_outage", "operational"]), + ).toBe("major_outage"); + }); +}); + +describe("eventLog", () => { + test("is empty without rows", () => { + expect(eventLog([])).toBe(""); + }); + + test("sorts newest first and strips newlines from titles", () => { + const out = eventLog([ + { + timestamp: Date.UTC(2026, 0, 1, 9, 0), + label: "Investigating", + glyph: "x", + ref: "#1", + title: "first", + }, + { + timestamp: Date.UTC(2026, 0, 2, 9, 0), + label: "Resolved", + glyph: "+", + ref: "#1", + title: "fixed\n```", + }, + ]).split("\n"); + expect(out[0]).toBe("```text"); + expect(out[2]).toContain("2026-01-02 09:00"); + expect(out[2]).toContain("fixed ```"); + expect(out[3]).toContain("2026-01-01 09:00"); + expect(out.at(-1)).toBe("```"); + expect(out).toHaveLength(5); + }); +}); + +describe("relativeTime / humanDuration", () => { + const now = Date.UTC(2026, 5, 1); + + test("reports just now under a minute", () => { + expect(relativeTime(now - 30_000, now)).toBe("just now"); + }); + + test("uses singular and plural units", () => { + expect(relativeTime(now - 60_000, now)).toBe("1 minute ago"); + expect(relativeTime(now - 3 * 3_600_000, now)).toBe("3 hours ago"); + expect(relativeTime(now - 86_400_000, now)).toBe("1 day ago"); + expect(relativeTime(now - 400 * 86_400_000, now)).toBe("1 year ago"); + }); + + test("humanDuration clamps negative spans", () => { + expect(humanDuration(now, now - 10_000)).toBe("1 minute"); + expect(humanDuration(now, now + 9 * 86_400_000)).toBe("9 days"); + }); +}); + +describe("withPoweredBy", () => { + test("appends the footer unless white-labeled", () => { + expect(withPoweredBy("# Hi", true)).toBe("# Hi"); + expect(withPoweredBy("# Hi", false)).toContain("openstatus.dev"); + }); +}); diff --git a/apps/status-page/src/data/actions.client.test.ts b/apps/status-page/src/data/actions.client.test.ts new file mode 100644 index 000000000..5a04c56a8 --- /dev/null +++ b/apps/status-page/src/data/actions.client.test.ts @@ -0,0 +1,63 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import * as incidents from "./incidents.client"; +import * as maintenances from "./maintenances.client"; +import * as monitors from "./monitors.client"; +import * as regionMetrics from "./region-metrics.client"; +import * as statusPages from "./status-pages.client"; +import * as statusReportUpdates from "./status-report-updates.client"; +import * as statusReports from "./status-reports.client"; + +type ActionModule = { + actions: readonly { id: string; variant: string }[]; + getActions: ( + props: Record void>, + ) => { id: string; onClick?: () => void }[]; +}; + +// safe because every module exports the same actions/getActions shape, keyed by its own ids +function asActionModule(mod: unknown): ActionModule { + return mod as ActionModule; +} + +const modules = { + incidents, + maintenances, + monitors, + regionMetrics, + statusPages, + statusReportUpdates, + statusReports, +}; + +for (const [name, raw] of Object.entries(modules)) { + const mod = asActionModule(raw); + + describe(`${name} actions`, () => { + it("has unique ids", () => { + const ids = mod.actions.map((a) => a.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("keeps destructive actions last", () => { + const variants = mod.actions.map((a) => a.variant); + const firstDestructive = variants.indexOf("destructive"); + if (firstDestructive === -1) return; + expect( + variants.slice(firstDestructive).every((v) => v === "destructive"), + ).toBe(true); + }); + + it("wires each handler to its action and leaves the rest unset", () => { + const [first, ...rest] = mod.actions; + const handler = () => {}; + const result = mod.getActions({ [first.id]: handler }); + expect(result.map((a) => a.id)).toEqual(mod.actions.map((a) => a.id)); + expect(result[0].onClick).toBe(handler); + for (const action of result.slice(1, rest.length + 1)) { + expect(action.onClick).toBeUndefined(); + } + }); + }); +} diff --git a/apps/status-page/src/data/metrics.client.test.ts b/apps/status-page/src/data/metrics.client.test.ts new file mode 100644 index 000000000..b94027964 --- /dev/null +++ b/apps/status-page/src/data/metrics.client.test.ts @@ -0,0 +1,164 @@ +import type { RouterOutputs } from "@openstatus/api"; +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + getMonitorListMetrics, + latencyByMonitorId, + mapMetrics, + mapRegionMetrics, +} from "./metrics.client"; + +type MetricsRegions = RouterOutputs["tinybird"]["metricsRegions"]; +type Monitors = RouterOutputs["monitor"]["list"]; +type PageMonitors = RouterOutputs["statusPage"]["getMonitors"]; + +// safe because each helper reads only the fields these fixtures set +function asType(value: unknown): T { + return value as T; +} + +describe("mapMetrics", () => { + test("counts degraded checks as up", () => { + const [row] = mapMetrics( + asType({ + data: [ + { + p50Latency: 1, + p75Latency: 2, + p90Latency: 3, + p95Latency: 4, + p99Latency: 5, + count: 10, + success: 7, + degraded: 1, + error: 2, + lastTimestamp: 0, + }, + ], + }), + ); + expect(row.uptime).toBe(0.8); + expect(row.total).toBe(10); + expect(row.p95).toBe(4); + }); +}); + +describe("mapRegionMetrics", () => { + test("returns zeroed, sorted regions without a timeline", () => { + expect(mapRegionMetrics(undefined, ["fra", "ams"], "p50")).toEqual([ + { region: "ams", p50: 0, p90: 0, p99: 0, trend: [] }, + { region: "fra", p50: 0, p90: 0, p99: 0, trend: [] }, + ]); + }); + + test("averages percentiles per region and keeps only requested regions", () => { + const timeline = asType({ + data: [ + { + region: "fra", + timestamp: 2, + p50Latency: 100, + p90Latency: 200, + p99Latency: 300, + p75Latency: 150, + }, + { + region: "fra", + timestamp: 1, + p50Latency: 201, + p90Latency: 400, + p99Latency: 600, + p75Latency: 250, + }, + { + region: "ams", + timestamp: 1, + p50Latency: 50, + p90Latency: null, + p99Latency: 70, + p75Latency: 60, + }, + { + region: "iad", + timestamp: 1, + p50Latency: 999, + p90Latency: 999, + p99Latency: 999, + p75Latency: 999, + }, + ], + }); + const result = mapRegionMetrics(timeline, ["ams", "fra"], "p75"); + expect(result.map((r) => r.region)).toEqual(["ams", "fra"]); + expect(result[0]).toMatchObject({ p50: 50, p90: 0, p99: 70 }); + expect(result[1]).toMatchObject({ p50: 151, p90: 300, p99: 450 }); + expect(result[1].trend).toEqual([ + { latency: 250, timestamp: 1, fra: 250 }, + { latency: 150, timestamp: 2, fra: 150 }, + ]); + }); +}); + +describe("getMonitorListMetrics", () => { + const monitors = asType([ + { status: "active", active: true }, + { status: "active", active: false }, + { status: "degraded", active: true }, + { status: "error", active: true }, + { status: "error", active: false }, + ]); + + test("counts active monitors by status and inactive ones separately", () => { + const values = Object.fromEntries( + getMonitorListMetrics(monitors).map((m) => [m.key, m.value]), + ); + expect(values).toEqual({ + active: 1, + degraded: 1, + error: 1, + inactive: 2, + p95: "N/A", + }); + }); + + test("shows the slowest p95", () => { + const p95 = getMonitorListMetrics(monitors, [ + { monitorId: "1", p95Latency: 120 }, + { monitorId: "2", p95Latency: 1500 }, + ]).find((m) => m.key === "p95"); + expect(p95?.value).toBe("1.5 sec"); + }); +}); + +describe("latencyByMonitorId", () => { + test("averages the percentile and compacts the unit", () => { + const map = latencyByMonitorId( + asType([ + { + id: 1, + data: [ + { p75Latency: 100 }, + { p75Latency: 133 }, + { p75Latency: null }, + ], + }, + { id: 2, data: [] }, + { id: 3, data: [{ p75Latency: 2500 }] }, + ]), + ); + expect(map.get("1")).toBe("117ms"); + expect(map.has("2")).toBe(false); + expect(map.get("3")).toBe("2.5sec"); + }); + + test("uses the requested percentile", () => { + const map = latencyByMonitorId( + asType([ + { id: 1, data: [{ p75Latency: 1, p99Latency: 900 }] }, + ]), + "p99", + ); + expect(map.get("1")).toBe("900ms"); + }); +}); diff --git a/apps/status-page/src/lib/base-url.test.ts b/apps/status-page/src/lib/base-url.test.ts new file mode 100644 index 000000000..157d28dd6 --- /dev/null +++ b/apps/status-page/src/lib/base-url.test.ts @@ -0,0 +1,41 @@ +import { expect } from "@std/expect"; +import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; + +import { getBaseUrl } from "./base-url"; + +let originalEnv: string | undefined; + +beforeEach(() => { + originalEnv = process.env.NODE_ENV; +}); + +afterEach(() => { + Object.assign(process.env, { NODE_ENV: originalEnv }); + if (originalEnv === undefined) + Reflect.deleteProperty(process.env, "NODE_ENV"); +}); + +function setNodeEnv(value: string) { + Object.assign(process.env, { NODE_ENV: value }); +} + +describe("getBaseUrl", () => { + it("uses the openstatus subdomain by default", () => { + setNodeEnv("production"); + expect(getBaseUrl({ slug: "acme" })).toBe("https://acme.openstatus.dev"); + }); + + it("prefers the custom domain", () => { + setNodeEnv("production"); + expect(getBaseUrl({ slug: "acme", customDomain: "status.acme.com" })).toBe( + "https://status.acme.com", + ); + }); + + it("serves from localhost in development, ignoring the custom domain", () => { + setNodeEnv("development"); + expect(getBaseUrl({ slug: "acme", customDomain: "status.acme.com" })).toBe( + "http://localhost:3000/acme", + ); + }); +}); diff --git a/apps/status-page/src/lib/chart.test.ts b/apps/status-page/src/lib/chart.test.ts new file mode 100644 index 000000000..28ec02780 --- /dev/null +++ b/apps/status-page/src/lib/chart.test.ts @@ -0,0 +1,56 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { getPayloadConfigFromPayload } from "./chart"; + +const config = { + latency: { label: "Latency" }, + ams: { label: "Amsterdam" }, +}; + +describe("getPayloadConfigFromPayload", () => { + test("returns undefined for a non-object payload", () => { + expect( + getPayloadConfigFromPayload(config, null, "latency"), + ).toBeUndefined(); + expect(getPayloadConfigFromPayload(config, "x", "latency")).toBeUndefined(); + }); + + test("uses the key directly when the payload has no string override", () => { + expect( + getPayloadConfigFromPayload(config, { latency: 12 }, "latency"), + ).toEqual({ label: "Latency" }); + }); + + test("redirects through a string value on the payload", () => { + expect( + getPayloadConfigFromPayload(config, { name: "ams" }, "name"), + ).toEqual({ label: "Amsterdam" }); + }); + + test("redirects through a string value on the nested payload", () => { + expect( + getPayloadConfigFromPayload( + config, + { payload: { region: "ams" } }, + "region", + ), + ).toEqual({ label: "Amsterdam" }); + }); + + test("prefers the top-level value over the nested one", () => { + expect( + getPayloadConfigFromPayload( + config, + { region: "latency", payload: { region: "ams" } }, + "region", + ), + ).toEqual({ label: "Latency" }); + }); + + test("falls back to the key when the redirect is not in the config", () => { + expect( + getPayloadConfigFromPayload(config, { latency: "fra" }, "latency"), + ).toEqual({ label: "Latency" }); + }); +}); diff --git a/apps/status-page/src/lib/composition.test.ts b/apps/status-page/src/lib/composition.test.ts new file mode 100644 index 000000000..a65777454 --- /dev/null +++ b/apps/status-page/src/lib/composition.test.ts @@ -0,0 +1,69 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { composeEventHandlers, composeRefs } from "./composition"; + +describe("composeEventHandlers", () => { + test("runs the original handler before ours", () => { + const calls: string[] = []; + const handler = composeEventHandlers( + () => calls.push("original"), + () => calls.push("ours"), + ); + handler({ defaultPrevented: false }); + expect(calls).toEqual(["original", "ours"]); + }); + + test("skips ours when the original prevents default", () => { + const calls: string[] = []; + const handler = composeEventHandlers<{ defaultPrevented: boolean }>( + (e) => { + e.defaultPrevented = true; + }, + () => calls.push("ours"), + ); + handler({ defaultPrevented: false }); + expect(calls).toEqual([]); + }); + + test("runs ours anyway when the check is disabled", () => { + const calls: string[] = []; + const handler = composeEventHandlers(undefined, () => calls.push("ours"), { + checkForDefaultPrevented: false, + }); + handler({ defaultPrevented: true }); + expect(calls).toEqual(["ours"]); + }); +}); + +describe("composeRefs", () => { + test("sets object refs and calls callback refs", () => { + const objectRef = { current: null as string | null }; + let callbackValue: string | null = null; + composeRefs( + objectRef, + (v) => { + callbackValue = v; + }, + undefined, + )("node"); + expect(objectRef.current).toBe("node"); + expect(callbackValue).toBe("node"); + }); + + test("returns no cleanup when no ref returns one", () => { + expect(composeRefs({ current: null })("node")).toBeUndefined(); + }); + + test("runs cleanups and nulls refs without one", () => { + const objectRef = { current: null as string | null }; + let cleaned = false; + const cleanup = composeRefs(objectRef, () => () => { + cleaned = true; + })("node"); + expect(typeof cleanup).toBe("function"); + (cleanup as () => void)(); + expect(cleaned).toBe(true); + expect(objectRef.current).toBeNull(); + }); +}); diff --git a/apps/status-page/src/lib/embed-params.test.ts b/apps/status-page/src/lib/embed-params.test.ts new file mode 100644 index 000000000..560a9ee18 --- /dev/null +++ b/apps/status-page/src/lib/embed-params.test.ts @@ -0,0 +1,88 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + ALL_EMBED_SECTIONS, + embedParser, + embedThemeParser, +} from "./embed-params"; + +const all = [...ALL_EMBED_SECTIONS]; + +describe("embedParser.parse", () => { + test("treats an empty value or 'all' as every section", () => { + expect(embedParser.parse("")).toEqual({ mode: true, sections: all }); + expect(embedParser.parse("ALL")).toEqual({ mode: true, sections: all }); + }); + + test("keeps known sections, normalized and deduplicated", () => { + expect(embedParser.parse(" Banner ,feed,banner,nope")).toEqual({ + mode: true, + sections: ["banner", "feed"], + }); + }); + + test("falls back to every section when nothing valid is given", () => { + expect(embedParser.parse(",")).toEqual({ mode: true, sections: all }); + expect(embedParser.parse("unknown")).toEqual({ mode: true, sections: all }); + }); +}); + +describe("embedParser.serialize", () => { + test("is empty outside embed mode or with every section", () => { + expect(embedParser.serialize({ mode: false, sections: ["feed"] })).toBe(""); + expect(embedParser.serialize({ mode: true, sections: all })).toBe(""); + }); + + test("joins a narrowed section list", () => { + expect( + embedParser.serialize({ mode: true, sections: ["title", "feed"] }), + ).toBe("title,feed"); + }); + + test("round-trips a narrowed list", () => { + const state = { mode: true, sections: ["components", "banner"] as const }; + expect( + embedParser.parse( + embedParser.serialize({ ...state, sections: [...state.sections] }), + ), + ).toEqual({ mode: true, sections: ["components", "banner"] }); + }); +}); + +describe("embedParser.eq", () => { + test("ignores section order", () => { + expect( + embedParser.eq( + { mode: true, sections: ["title", "feed"] }, + { mode: true, sections: ["feed", "title"] }, + ), + ).toBe(true); + }); + + test("differs on mode or sections", () => { + expect( + embedParser.eq( + { mode: true, sections: all }, + { mode: false, sections: all }, + ), + ).toBe(false); + expect( + embedParser.eq( + { mode: true, sections: ["title"] }, + { mode: true, sections: ["feed"] }, + ), + ).toBe(false); + }); + + test("defaults to non-embed mode", () => { + expect(embedParser.defaultValue).toEqual({ mode: false, sections: all }); + }); +}); + +describe("embedThemeParser", () => { + test("accepts only light and dark", () => { + expect(embedThemeParser.parse("dark")).toBe("dark"); + expect(embedThemeParser.parse("sepia")).toBeNull(); + }); +}); diff --git a/apps/status-page/src/lib/report-impacts.test.ts b/apps/status-page/src/lib/report-impacts.test.ts new file mode 100644 index 000000000..12d736683 --- /dev/null +++ b/apps/status-page/src/lib/report-impacts.test.ts @@ -0,0 +1,57 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { updatesWithImpactChanges } from "./report-impacts"; + +describe("updatesWithImpactChanges", () => { + test("resolves component names and keeps update fields", () => { + const result = updatesWithImpactChanges({ + statusReportsToPageComponents: [ + { pageComponent: { id: 1, name: "API" } }, + { pageComponent: { id: 2, name: "Dashboard" } }, + ], + statusReportUpdates: [ + { + id: 10, + message: "Investigating", + statusReportUpdateToPageComponents: [ + { pageComponentId: 2, impact: "major_outage" as const }, + { pageComponentId: 1, impact: "operational" as const }, + ], + }, + ], + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(10); + expect(result[0].message).toBe("Investigating"); + expect(result[0].impactChanges).toEqual([ + { name: "Dashboard", impact: "major_outage" }, + { name: "API", impact: "operational" }, + ]); + }); + + test("falls back to the id for an unknown component", () => { + const [update] = updatesWithImpactChanges({ + statusReportsToPageComponents: [], + statusReportUpdates: [ + { + statusReportUpdateToPageComponents: [ + { pageComponentId: 7, impact: "partial_outage" as const }, + ], + }, + ], + }); + expect(update.impactChanges).toEqual([ + { name: "#7", impact: "partial_outage" }, + ]); + }); + + test("returns an empty list without updates", () => { + expect( + updatesWithImpactChanges({ + statusReportsToPageComponents: [], + statusReportUpdates: [], + }), + ).toEqual([]); + }); +}); diff --git a/apps/status-page/src/lib/theme-draft.test.ts b/apps/status-page/src/lib/theme-draft.test.ts new file mode 100644 index 000000000..f9e37ab1e --- /dev/null +++ b/apps/status-page/src/lib/theme-draft.test.ts @@ -0,0 +1,65 @@ +import { THEMES } from "@openstatus/theme-store"; +import { expect } from "@std/expect"; +import { afterEach, describe, it } from "@std/testing/bdd"; + +import { + THEME_DRAFT_STORAGE_KEY, + clearThemeDraft, + readThemeDraft, + saveThemeDraft, +} from "./theme-draft"; + +afterEach(() => sessionStorage.clear()); + +const theme = Object.values(THEMES)[0]; + +describe("theme draft storage", () => { + it("returns null when nothing is stored", () => { + expect(readThemeDraft()).toBeNull(); + }); + + it("round-trips a saved theme", () => { + saveThemeDraft(theme); + expect(readThemeDraft()).toEqual(theme); + }); + + it("clears the draft", () => { + saveThemeDraft(theme); + clearThemeDraft(); + expect(readThemeDraft()).toBeNull(); + }); + + it("fills in id, name and author for bare CSS variables", () => { + sessionStorage.setItem( + THEME_DRAFT_STORAGE_KEY, + "--background: oklch(1 0 0);", + ); + expect(readThemeDraft()).toEqual({ + id: "draft", + name: "Draft", + author: { name: "", url: "" }, + light: { "--background": "oklch(1 0 0)" }, + dark: {}, + }); + }); + + it("rejects an entry without supported variables", () => { + sessionStorage.setItem(THEME_DRAFT_STORAGE_KEY, "garbage"); + expect(readThemeDraft()).toBeNull(); + }); + + it("does not let a hand-edited value break out of the declaration", () => { + sessionStorage.setItem( + THEME_DRAFT_STORAGE_KEY, + JSON.stringify({ + light: { "--background": "red;}" }, + dark: {}, + }), + ); + const draft = readThemeDraft(); + const values = draft + ? [...Object.values(draft.light), ...Object.values(draft.dark)] + : []; + expect(values.some((v) => /[;{}<>]/.test(String(v)))).toBe(false); + }); +}); diff --git a/apps/status-page/src/test/next-intl.mock.tsx b/apps/status-page/src/test/next-intl.mock.tsx new file mode 100644 index 000000000..e0ca76879 --- /dev/null +++ b/apps/status-page/src/test/next-intl.mock.tsx @@ -0,0 +1,33 @@ +// Test double for next-intl, swapped in via --import-map: `useExtracted` only +// works after the next-intl compiler rewrites call sites. Interpolates `{name}` +// into the English source string, which is all the app's messages use. +import type { ReactNode } from "react"; + +export const intlState = { locale: "en" }; + +function interpolate(message: string, values?: Record) { + if (!values) return message; + return message.replace(/\{(\w+)\}/g, (match, key: string) => + key in values ? String(values[key]) : match, + ); +} + +export function useExtracted() { + return (message: string, values?: Record) => + interpolate(message, values); +} + +export function useLocale() { + return intlState.locale; +} + +export function hasLocale( + locales: readonly T[], + locale: unknown, +): locale is T { + return locales.includes(locale as T); +} + +export function NextIntlClientProvider({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/status-page/src/test/next-link.mock.tsx b/apps/status-page/src/test/next-link.mock.tsx new file mode 100644 index 000000000..03dcc1836 --- /dev/null +++ b/apps/status-page/src/test/next-link.mock.tsx @@ -0,0 +1,26 @@ +// Test double for next/link, swapped in via --import-map: Deno cannot resolve +// Next's extensionless CJS entrypoints. Renders a plain anchor. +import type * as React from "react"; + +type LinkProps = Omit, "href"> & { + href: string | { pathname?: string; query?: Record }; + prefetch?: boolean | null; + replace?: boolean; + scroll?: boolean; +}; + +function toHref(href: LinkProps["href"]) { + if (typeof href === "string") return href; + const query = new URLSearchParams(href.query).toString(); + return `${href.pathname ?? ""}${query ? `?${query}` : ""}`; +} + +export default function Link({ + href, + prefetch: _prefetch, + replace: _replace, + scroll: _scroll, + ...props +}: LinkProps) { + return ; +} diff --git a/apps/status-page/src/test/next-navigation.mock.ts b/apps/status-page/src/test/next-navigation.mock.ts new file mode 100644 index 000000000..d41c6efd7 --- /dev/null +++ b/apps/status-page/src/test/next-navigation.mock.ts @@ -0,0 +1,52 @@ +// Test double for next/navigation, swapped in via --import-map. Router calls +// are recorded on `routerCalls`; reset it between tests. +export const routerCalls: { method: string; args: unknown[] }[] = []; + +function record(method: string) { + return (...args: unknown[]) => { + routerCalls.push({ method, args }); + }; +} + +const router = { + push: record("push"), + replace: record("replace"), + refresh: record("refresh"), + back: record("back"), + forward: record("forward"), + prefetch: record("prefetch"), +}; + +export const navigationState: { + pathname: string; + params: Record; + searchParams: URLSearchParams; +} = { pathname: "/", params: {}, searchParams: new URLSearchParams() }; + +export function useRouter() { + return router; +} + +export function usePathname() { + return navigationState.pathname; +} + +export function useParams() { + return navigationState.params; +} + +export function useSearchParams() { + return navigationState.searchParams; +} + +export function unauthorized(): never { + throw new Error("NEXT_UNAUTHORIZED"); +} + +export function notFound(): never { + throw new Error("NEXT_NOT_FOUND"); +} + +export function redirect(url: string): never { + throw new Error(`NEXT_REDIRECT ${url}`); +} diff --git a/apps/status-page/src/test/use-pathname-prefix.mock.ts b/apps/status-page/src/test/use-pathname-prefix.mock.ts new file mode 100644 index 000000000..1108056a2 --- /dev/null +++ b/apps/status-page/src/test/use-pathname-prefix.mock.ts @@ -0,0 +1,8 @@ +// Test double for the usePathnamePrefix hook, swapped in via --import-map: the +// real hook needs a tRPC provider. Its prefix logic is covered by +// resolve-pathname-prefix.test.ts. +export const pathnamePrefix = { value: "" }; + +export function usePathnamePrefix() { + return pathnamePrefix.value; +} diff --git a/apps/status-page/test.importmap.json b/apps/status-page/test.importmap.json new file mode 100644 index 000000000..2ec601198 --- /dev/null +++ b/apps/status-page/test.importmap.json @@ -0,0 +1,10 @@ +{ + "imports": { + "@/": "./src/", + "./src/hooks/use-pathname-prefix": "./src/test/use-pathname-prefix.mock.ts", + "./src/hooks/use-pathname-prefix.ts": "./src/test/use-pathname-prefix.mock.ts", + "next/link": "./src/test/next-link.mock.tsx", + "next/navigation": "./src/test/next-navigation.mock.ts", + "next-intl": "./src/test/next-intl.mock.tsx" + } +} diff --git a/apps/status-page/tsconfig.json b/apps/status-page/tsconfig.json index 937cf3ad9..857d87f99 100644 --- a/apps/status-page/tsconfig.json +++ b/apps/status-page/tsconfig.json @@ -7,7 +7,8 @@ "plugins": [{ "name": "next" }], "strictNullChecks": true, "strict": true, - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "jsx": "react-jsx" }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules", "env.ts"] diff --git a/apps/web/src/lib/cdn-checker/schema.test.ts b/apps/web/src/lib/cdn-checker/schema.test.ts new file mode 100644 index 000000000..ab8d72b7e --- /dev/null +++ b/apps/web/src/lib/cdn-checker/schema.test.ts @@ -0,0 +1,77 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { cdnRegionResponseSchema, cdnSummarySchema } from "./schema"; + +const success = { + region: "ams", + cacheStatus: "HIT", + cacheStatusRaw: "cf-cache-status: HIT", + edgeIp: null, + edgePop: null, + edgePopLocation: null, + ttfbMs: 10, + totalMs: 20, + statusCode: 200, + responseSize: null, + age: null, + cacheControl: null, + etag: null, + cdn: null, +}; + +describe("cdnRegionResponseSchema", () => { + test("defaults a result without a state to success", () => { + expect(cdnRegionResponseSchema.parse(success).state).toBe("success"); + }); + + test("parses a success result", () => { + expect( + cdnRegionResponseSchema.parse({ ...success, state: "success" }).state, + ).toBe("success"); + }); + + test("parses an error result", () => { + expect( + cdnRegionResponseSchema.parse({ + state: "error", + region: "syd", + message: "Timeout", + }), + ).toEqual({ state: "error", region: "syd", message: "Timeout" }); + }); + + test("rejects an unknown cache status", () => { + expect( + cdnRegionResponseSchema.safeParse({ + ...success, + state: "success", + cacheStatus: "WARM", + }).success, + ).toBe(false); + }); +}); + +describe("cdnSummarySchema", () => { + const summary = { + totalRegions: 2, + respondedRegions: 2, + cachedRegions: 1, + uncachedRegions: ["ams"], + unreachableRegions: [], + cdn: null, + mixedCdn: false, + topology: "unknown", + topologyBasis: null, + }; + + test("defaults the type to summary", () => { + expect(cdnSummarySchema.parse(summary).type).toBe("summary"); + }); + + test("rejects an unknown topology", () => { + expect( + cdnSummarySchema.safeParse({ ...summary, topology: "multicast" }).success, + ).toBe(false); + }); +}); diff --git a/apps/web/src/lib/checker/utils.test.ts b/apps/web/src/lib/checker/utils.test.ts new file mode 100644 index 000000000..8cc03eb01 --- /dev/null +++ b/apps/web/src/lib/checker/utils.test.ts @@ -0,0 +1,121 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + type Timing, + getTimingPhases, + getTimingPhasesWidth, + getTotalLatency, + is32CharHex, + isTimeoutError, + latencyFormatter, + regionCheckerSchemaResponse, + regionFormatter, + timestampFormatter, +} from "./utils"; + +const timing: Timing = { + dnsStart: 0, + dnsDone: 10, + connectStart: 10, + connectDone: 30, + tlsHandshakeStart: 30, + tlsHandshakeDone: 60, + firstByteStart: 60, + firstByteDone: 90, + transferStart: 90, + transferDone: 100, +}; + +describe("getTimingPhases", () => { + test("computes each phase duration", () => { + expect(getTimingPhases(timing)).toEqual({ + dns: 10, + connection: 20, + tls: 30, + ttfb: 30, + transfer: 10, + }); + }); + + test("sums phases into the total latency", () => { + expect(getTotalLatency(timing)).toBe(100); + }); +}); + +describe("getTimingPhasesWidth", () => { + test("lays phases end to end as percentages of the total", () => { + expect(getTimingPhasesWidth(timing)).toEqual({ + dns: { preWidth: 0, width: 10 }, + connection: { preWidth: 10, width: 20 }, + tls: { preWidth: 30, width: 30 }, + ttfb: { preWidth: 60, width: 30 }, + transfer: { preWidth: 90, width: 10 }, + }); + }); +}); + +describe("formatters", () => { + test("latencyFormatter groups thousands and appends ms", () => { + expect(latencyFormatter(1234)).toBe("1,234ms"); + }); + + test("timestampFormatter renders GMT", () => { + expect(timestampFormatter(Date.UTC(2024, 0, 2, 3, 4, 5))).toBe( + "Tue, 02 Jan 2024 03:04:05 GMT", + ); + }); + + test("regionFormatter renders short and long forms", () => { + expect(regionFormatter("ams")).toMatch(/^ams /); + expect(regionFormatter("ams", "long")).toMatch(/^Amsterdam, Netherlands /); + }); +}); + +describe("is32CharHex", () => { + test("accepts a dashless uuid", () => { + expect(is32CharHex(crypto.randomUUID().replace(/-/g, ""))).toBe(true); + expect(is32CharHex("AEC4E0EC3C4F4557B8CE46E55078FC95")).toBe(true); + }); + + test("rejects anything else", () => { + expect(is32CharHex(crypto.randomUUID())).toBe(false); + expect(is32CharHex("aec4e0ec3c4f4557b8ce46e55078fc9")).toBe(false); + expect(is32CharHex("zec4e0ec3c4f4557b8ce46e55078fc95")).toBe(false); + }); +}); + +describe("isTimeoutError", () => { + test("matches only errors named TimeoutError", () => { + expect(isTimeoutError(new DOMException("t", "TimeoutError"))).toBe(true); + expect(isTimeoutError(new DOMException("a", "AbortError"))).toBe(false); + expect(isTimeoutError({ name: "TimeoutError" })).toBe(false); + }); +}); + +describe("regionCheckerSchemaResponse", () => { + test("parses a success with defaults", () => { + const parsed = regionCheckerSchemaResponse.parse({ + region: "ams", + status: 200, + latency: 100, + headers: {}, + timestamp: 0, + timing, + }); + expect(parsed).toMatchObject({ state: "success", type: "http" }); + }); + + test("parses an error response", () => { + expect( + regionCheckerSchemaResponse.parse({ region: "ams", message: "Timeout" }), + ).toEqual({ region: "ams", message: "Timeout", state: "error" }); + }); + + test("rejects an unknown region", () => { + expect( + regionCheckerSchemaResponse.safeParse({ region: "xxx", message: "x" }) + .success, + ).toBe(false); + }); +}); diff --git a/apps/web/src/lib/checker/utils.ts b/apps/web/src/lib/checker/utils.ts index ffe605602..f649dcc80 100644 --- a/apps/web/src/lib/checker/utils.ts +++ b/apps/web/src/lib/checker/utils.ts @@ -18,7 +18,7 @@ const RAILWAY_CHECKER_URL = // ============================================================================ export function latencyFormatter(value: number) { - return `${new Intl.NumberFormat("us").format(value).toString()}ms`; + return `${new Intl.NumberFormat("en-US").format(value).toString()}ms`; } export function timestampFormatter(timestamp: number) { diff --git a/apps/web/src/lib/metadata/shared-metadata.test.ts b/apps/web/src/lib/metadata/shared-metadata.test.ts new file mode 100644 index 000000000..2c24a4dc9 --- /dev/null +++ b/apps/web/src/lib/metadata/shared-metadata.test.ts @@ -0,0 +1,138 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import type { MDXData } from "../../content/utils"; +import { + BASE_URL, + getHomeMetadata, + getPageMetadata, + getSocialMetadata, +} from "./shared-metadata"; + +type Seo = NonNullable; + +// safe because the helpers only read slug and metadata +function page(seo?: Seo, updatedAt?: Date): MDXData { + return { + slug: "my-post", + content: "", + metadata: { + title: "My Post", + description: "About my post", + category: "engineering", + publishedAt: new Date("2024-01-02T00:00:00Z"), + updatedAt, + seo, + }, + } as unknown as MDXData; +} + +function twitterImage(meta: ReturnType) { + return (meta.twitter?.images as string[] | undefined)?.[0] ?? ""; +} + +describe("getSocialMetadata", () => { + test("builds an og image url from title, description and category", () => { + const meta = getSocialMetadata({ + title: "A & B", + description: "desc", + url: "https://x.test", + category: "blog", + }); + const image = twitterImage(meta); + const params = new URL(image).searchParams; + expect(image.startsWith(`${BASE_URL}/api/og?`)).toBe(true); + expect(Object.fromEntries(params)).toEqual({ + title: "A & B", + description: "desc", + category: "blog", + }); + expect(meta.openGraph).toMatchObject({ + url: "https://x.test", + type: "website", + }); + }); + + test("omits category when absent", () => { + const image = twitterImage( + getSocialMetadata({ title: "t", description: "d", url: "u" }), + ); + expect(new URL(image).searchParams.has("category")).toBe(false); + }); + + test("uses an explicit og image as is", () => { + const { openGraph, twitter } = getSocialMetadata({ + title: "t", + description: "d", + url: "u", + ogImage: "/custom.png", + }); + expect(openGraph?.images).toEqual([{ url: "/custom.png" }]); + expect(twitter?.images).toEqual(["/custom.png"]); + }); +}); + +describe("getPageMetadata", () => { + test("derives title, canonical and article dates", () => { + const meta = getPageMetadata(page(), "blog"); + expect(meta.title).toBe("My Post"); + expect(meta.description).toBe("About my post"); + expect(meta.alternates?.canonical).toBe(`${BASE_URL}/blog/my-post`); + expect(meta.robots).toBeUndefined(); + expect(meta.openGraph).toMatchObject({ + type: "article", + publishedTime: "2024-01-02T00:00:00.000Z", + modifiedTime: "2024-01-02T00:00:00.000Z", + }); + }); + + test("uses the slug at the root without a base path", () => { + expect(getPageMetadata(page()).alternates?.canonical).toBe( + `${BASE_URL}/my-post`, + ); + }); + + test("uses updatedAt as the modified time", () => { + const meta = getPageMetadata( + page(undefined, new Date("2024-03-04T00:00:00Z")), + ); + expect(meta.openGraph).toMatchObject({ + modifiedTime: "2024-03-04T00:00:00.000Z", + }); + }); + + test("applies seo overrides", () => { + const meta = getPageMetadata( + page({ + title: "SEO Title", + description: "SEO desc", + canonical: "https://elsewhere.test/post", + noindex: true, + }), + ); + expect(meta.title).toEqual({ absolute: "SEO Title" }); + expect(meta.description).toBe("SEO desc"); + expect(meta.alternates?.canonical).toBe("https://elsewhere.test/post"); + expect(meta.robots).toEqual({ index: false }); + expect(meta.openGraph?.title).toBe("SEO Title"); + }); +}); + +describe("getHomeMetadata", () => { + test("bypasses the title template and uses the static og image", () => { + const meta = getHomeMetadata(page()); + expect(meta.title).toEqual({ absolute: "My Post" }); + expect(meta.alternates?.canonical).toBe("/"); + expect(meta.twitter?.images).toEqual([`${BASE_URL}/api/og`]); + expect(meta.openGraph?.url).toBe(BASE_URL); + }); + + test("applies seo overrides", () => { + const meta = getHomeMetadata( + page({ title: "Home SEO", canonical: "/home", noindex: true }), + ); + expect(meta.title).toEqual({ absolute: "Home SEO" }); + expect(meta.alternates?.canonical).toBe("/home"); + expect(meta.robots).toEqual({ index: false }); + }); +}); diff --git a/apps/web/src/lib/monitor/utils.test.ts b/apps/web/src/lib/monitor/utils.test.ts new file mode 100644 index 000000000..63f24b517 --- /dev/null +++ b/apps/web/src/lib/monitor/utils.test.ts @@ -0,0 +1,79 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; +import { FakeTime } from "@std/testing/time"; + +import { + getDateByPeriod, + getHoursByPeriod, + getMinutesByInterval, + periodFormatter, + periods, +} from "./utils"; + +describe("getHoursByPeriod", () => { + test("maps each period to its hour count", () => { + expect(periods.map(getHoursByPeriod)).toEqual([24, 168, 336]); + }); +}); + +describe("periodFormatter", () => { + test("labels each period", () => { + expect(periods.map(periodFormatter)).toEqual([ + "Last day", + "Last 7 days", + "Last 14 days", + ]); + }); +}); + +describe("getDateByPeriod", () => { + // mid-June, local time, so no DST transition falls inside any period + const now = new Date(2024, 5, 15, 13, 27, 45, 123); + + function at(fn: () => T) { + const time = new FakeTime(now); + try { + return fn(); + } finally { + time.restore(); + } + } + + test("ends every period at the end of today", () => { + for (const period of periods) { + expect(at(() => getDateByPeriod(period)).to).toEqual( + new Date(2024, 5, 15, 23, 59, 59, 999), + ); + } + }); + + test("starts 1d a day before the current hour", () => { + expect(at(() => getDateByPeriod("1d")).from).toEqual( + new Date(2024, 5, 14, 13, 0, 0, 0), + ); + }); + + test("starts longer periods at midnight, n days back", () => { + expect(at(() => getDateByPeriod("7d")).from).toEqual( + new Date(2024, 5, 8, 0, 0, 0, 0), + ); + expect(at(() => getDateByPeriod("14d")).from).toEqual( + new Date(2024, 5, 1, 0, 0, 0, 0), + ); + }); +}); + +describe("getMinutesByInterval", () => { + test("maps each periodicity to minutes", () => { + expect(getMinutesByInterval("1m")).toBe(1); + expect(getMinutesByInterval("5m")).toBe(5); + expect(getMinutesByInterval("10m")).toBe(10); + expect(getMinutesByInterval("30m")).toBe(30); + expect(getMinutesByInterval("1h")).toBe(60); + }); + + test("rounds sub-minute and custom intervals to supported buckets", () => { + expect(getMinutesByInterval("30s")).toBe(1); + expect(getMinutesByInterval("other")).toBe(60); + }); +}); diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts new file mode 100644 index 000000000..afc5c5ba6 --- /dev/null +++ b/apps/web/src/lib/utils.test.ts @@ -0,0 +1,153 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + cn, + formatDuration, + hashIP, + manipulateDate, + notEmpty, + numberFormatter, + slugify, + toCapitalize, +} from "./utils"; + +describe("cn", () => { + test("lets later tailwind classes win and drops falsy values", () => { + expect(cn("p-2", false, null, "p-4", "text-sm")).toBe("p-4 text-sm"); + }); +}); + +describe("formatDuration", () => { + test("lists every non-zero unit with plurals", () => { + expect( + formatDuration(2 * 86400000 + 3 * 3600000 + 4 * 60000 + 5000 + 6), + ).toBe("2 days, 3 hours, 4 mins, 5 secs, 6 ms"); + }); + + test("uses singular units for a value of one", () => { + expect(formatDuration(86400000 + 3600000 + 60000 + 1000 + 1)).toBe( + "1 day, 1 hour, 1 min, 1 sec, 1 ms", + ); + }); + + test("skips zero units", () => { + expect(formatDuration(3600000 + 500)).toBe("1 hour, 500 ms"); + }); + + test("formats a negative duration by its magnitude", () => { + expect(formatDuration(-1500)).toBe("1 sec, 500 ms"); + }); +}); + +describe("notEmpty", () => { + test("filters out only null and undefined", () => { + expect([0, "", null, false, undefined, 1].filter(notEmpty)).toEqual([ + 0, + "", + false, + 1, + ]); + }); +}); + +describe("slugify", () => { + test("lowercases, hyphenates spaces and drops symbols", () => { + expect(slugify("Hello World! v2.0")).toBe("hello-world-v20"); + }); + + test("keeps existing hyphens and underscores", () => { + expect(slugify("status_page-Setup")).toBe("status_page-setup"); + }); +}); + +describe("numberFormatter", () => { + test("uses compact notation", () => { + expect(numberFormatter(999)).toBe("999"); + expect(numberFormatter(1234)).toBe("1.2K"); + expect(numberFormatter(2_500_000)).toBe("2.5M"); + }); +}); + +describe("toCapitalize", () => { + test("joins words split on spaces and underscores in PascalCase", () => { + expect(toCapitalize("hello_world foo")).toBe("HelloWorldFoo"); + expect(toCapitalize("SHOUTING")).toBe("Shouting"); + }); +}); + +describe("manipulateDate", () => { + test("returns nulls without a range", () => { + expect(manipulateDate(null)).toEqual({ fromDate: null, toDate: null }); + expect(manipulateDate(undefined)).toEqual({ fromDate: null, toDate: null }); + }); + + test("extends a midnight end date to the last millisecond of that day", () => { + const from = new Date(2024, 0, 1); + const to = new Date(2024, 0, 2); + const { fromDate, toDate } = manipulateDate({ from, to }); + expect(fromDate).toBe(from.getTime()); + expect(toDate).toBe(new Date(2024, 0, 2, 23, 59, 59, 999).getTime()); + }); + + test("keeps a non-midnight end date as is", () => { + const to = new Date(2024, 0, 2, 13, 37, 1); + expect(manipulateDate({ from: undefined, to }).toDate).toBe(to.getTime()); + }); + + // midnight is detected by the timestamp ending in "00000", which also + // matches any time on a 100-second boundary, e.g. 10:00:00 + test.ignore("keeps a whole-hour end date as is", () => { + const to = new Date(2024, 0, 2, 10); + expect(manipulateDate({ from: undefined, to }).toDate).toBe(to.getTime()); + }); +}); + +describe("hashIP", () => { + function withSalt(salt: string | undefined, fn: () => Promise) { + return async () => { + const prev = process.env.FEEDBACK_IP_SALT; + if (salt === undefined) delete process.env.FEEDBACK_IP_SALT; + else process.env.FEEDBACK_IP_SALT = salt; + try { + await fn(); + } finally { + if (prev === undefined) delete process.env.FEEDBACK_IP_SALT; + else process.env.FEEDBACK_IP_SALT = prev; + } + }; + } + + test( + "returns a stable 32-char hex digest", + withSalt("salt", async () => { + const a = await hashIP("203.0.113.7"); + expect(a).toMatch(/^[0-9a-f]{32}$/); + expect(await hashIP("203.0.113.7")).toBe(a); + }), + ); + + test( + "differs per input and per salt", + withSalt("salt", async () => { + const a = await hashIP("203.0.113.7"); + expect(await hashIP("203.0.113.8")).not.toBe(a); + process.env.FEEDBACK_IP_SALT = "other"; + expect(await hashIP("203.0.113.7")).not.toBe(a); + }), + ); + + // the `?? ""` fallback feeds a zero-length key to importKey, which throws + test.ignore( + "hashes without a configured secret", + withSalt(undefined, async () => { + const prev = process.env.CRON_SECRET; + delete process.env.CRON_SECRET; + try { + expect(await hashIP("203.0.113.7")).toMatch(/^[0-9a-f]{32}$/); + } finally { + if (prev !== undefined) process.env.CRON_SECRET = prev; + } + }), + ); +}); diff --git a/packages/assertions/package.json b/packages/assertions/package.json index 79323a62c..55d553bca 100644 --- a/packages/assertions/package.json +++ b/packages/assertions/package.json @@ -7,7 +7,8 @@ "author": "", "main": "src/index.ts", "scripts": { - "check": "deno check --sloppy-imports ." + "check": "deno check --sloppy-imports .", + "test": "deno test --parallel -A --no-check --sloppy-imports" }, "dependencies": { "jsonpath-plus": "catalog:", @@ -15,6 +16,8 @@ }, "devDependencies": { "@openstatus/tsconfig": "workspace:*", + "@std/expect": "jsr:^1.0.19", + "@std/testing": "jsr:^1.0.19", "typescript": "catalog:" } } diff --git a/packages/assertions/src/serializing.test.ts b/packages/assertions/src/serializing.test.ts new file mode 100644 index 000000000..5ae7c405f --- /dev/null +++ b/packages/assertions/src/serializing.test.ts @@ -0,0 +1,82 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { deserialize, serialize } from "./serializing"; +import { + DnsRecordAssertion, + HeaderAssertion, + JsonBodyAssertion, + StatusAssertion, + TextBodyAssertion, +} from "./v1"; + +const raw = [ + { version: "v1", type: "status", compare: "eq", target: 200 }, + { + version: "v1", + type: "header", + compare: "contains", + key: "Content-Type", + target: "json", + }, + { version: "v1", type: "textBody", compare: "not_empty", target: "" }, + { + version: "v1", + type: "jsonBody", + path: "$.status", + compare: "contains", + target: "ok", + }, + { + version: "v1", + type: "dnsRecord", + key: "A", + compare: "eq", + target: "1.1.1.1", + }, +]; + +describe("deserialize", () => { + it("builds the matching class for each type", () => { + const assertions = deserialize(JSON.stringify(raw)); + expect(assertions.map((a) => a.constructor)).toEqual([ + StatusAssertion, + HeaderAssertion, + TextBodyAssertion, + JsonBodyAssertion, + DnsRecordAssertion, + ]); + }); + + it("fills in a missing version", () => { + const [a] = deserialize('[{"type":"status","compare":"eq","target":200}]'); + expect(a.schema.version).toBe("v1"); + }); + + it("returns nothing for an empty list", () => { + expect(deserialize("[]")).toEqual([]); + }); + + it("throws on an unknown type", () => { + expect(() => deserialize('[{"type":"latency"}]')).toThrow( + "unknown assertion type: latency", + ); + }); + + it("throws when a known type has invalid fields", () => { + expect(() => + deserialize('[{"type":"status","compare":"contains","target":200}]'), + ).toThrow(); + }); + + it("throws on malformed JSON", () => { + expect(() => deserialize("not json")).toThrow(); + }); +}); + +describe("serialize", () => { + it("round-trips through deserialize", () => { + const json = JSON.stringify(raw); + expect(JSON.parse(serialize(deserialize(json)))).toEqual(raw); + }); +}); diff --git a/packages/assertions/src/v1.test.ts b/packages/assertions/src/v1.test.ts new file mode 100644 index 000000000..f9f060c17 --- /dev/null +++ b/packages/assertions/src/v1.test.ts @@ -0,0 +1,309 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import type { HttpAssertionRequest } from "./types"; +import { + DnsRecordAssertion, + HeaderAssertion, + JsonBodyAssertion, + StatusAssertion, + TextBodyAssertion, + assertion, +} from "./v1"; + +function http(req: Partial = {}): HttpAssertionRequest { + return { status: 200, header: {}, body: "", ...req }; +} + +describe("StatusAssertion", () => { + const cases = [ + ["eq", 200, 200, true], + ["eq", 500, 200, false], + ["not_eq", 500, 200, true], + ["not_eq", 200, 200, false], + ["gt", 201, 200, true], + ["gt", 200, 200, false], + ["gte", 200, 200, true], + ["gte", 199, 200, false], + ["lt", 199, 200, true], + ["lt", 200, 200, false], + ["lte", 200, 200, true], + ["lte", 201, 200, false], + ] as const; + + for (const [compare, status, target, expected] of cases) { + it(`${status} ${compare} ${target} -> ${expected}`, () => { + const a = new StatusAssertion({ + version: "v1", + type: "status", + compare, + target, + }); + expect(a.assert(http({ status })).success).toBe(expected); + }); + } + + it("prefixes the failure message", () => { + const a = new StatusAssertion({ + version: "v1", + type: "status", + compare: "eq", + target: 200, + }); + expect(a.assert(http({ status: 404 }))).toEqual({ + success: false, + message: "Status: Expected 404 to be equal to 200", + }); + }); + + it("rejects a DNS request", () => { + const a = new StatusAssertion({ + version: "v1", + type: "status", + compare: "eq", + target: 200, + }); + expect(a.assert({ records: {} })).toEqual({ + success: false, + message: "Invalid request type for status assertion", + }); + }); +}); + +describe("HeaderAssertion", () => { + function header( + compare: "contains" | "eq" | "empty" | "not_empty", + target = "", + ) { + return new HeaderAssertion({ + version: "v1", + type: "header", + key: "Content-Type", + compare, + target, + }); + } + + it("matches the header key case-insensitively", () => { + const req = http({ header: { "content-type": "application/json" } }); + expect(header("eq", "application/json").assert(req).success).toBe(true); + }); + + it("treats a missing header as an empty string", () => { + expect(header("empty").assert(http()).success).toBe(true); + expect(header("not_empty").assert(http())).toEqual({ + success: false, + message: "Header Content-Type: Expected to not be empty", + }); + }); + + it("supports substring matching", () => { + const req = http({ + header: { "Content-Type": "text/html; charset=utf-8" }, + }); + expect(header("contains", "charset").assert(req).success).toBe(true); + expect(header("contains", "json").assert(req).success).toBe(false); + }); + + it("rejects a DNS request", () => { + expect(header("eq", "x").assert({ records: {} }).success).toBe(false); + }); +}); + +describe("TextBodyAssertion", () => { + const body = "hello world"; + const cases = [ + ["contains", "world", true], + ["contains", "mars", false], + ["not_contains", "mars", true], + ["not_contains", "world", false], + ["eq", "hello world", true], + ["eq", "hello", false], + ["not_eq", "hello", true], + ["not_eq", "hello world", false], + ["not_empty", "", true], + ["empty", "", false], + // string comparisons are lexicographic + ["gt", "hello", true], + ["lt", "z", true], + ["gte", "hello world", true], + ["lte", "a", false], + ] as const; + + for (const [compare, target, expected] of cases) { + it(`"${body}" ${compare} "${target}" -> ${expected}`, () => { + const a = new TextBodyAssertion({ + version: "v1", + type: "textBody", + compare, + target, + }); + expect(a.assert(http({ body })).success).toBe(expected); + }); + } + + it("prefixes the failure message", () => { + const a = new TextBodyAssertion({ + version: "v1", + type: "textBody", + compare: "contains", + target: "ok", + }); + expect(a.assert(http({ body: "nope" }))).toEqual({ + success: false, + message: "Body: Expected nope to contain ok", + }); + }); +}); + +describe("JsonBodyAssertion", () => { + function json( + path: string, + compare: "contains" | "not_contains" | "eq" | "not_eq" | "empty", + target: string, + ) { + return new JsonBodyAssertion({ + version: "v1", + type: "jsonBody", + path, + compare, + target, + }); + } + const body = JSON.stringify({ status: "ok", tags: ["a", "b"] }); + + it("finds a value with contains", () => { + expect( + json("$.status", "contains", "ok").assert(http({ body })).success, + ).toBe(true); + }); + + it("fails not_contains when the value is present", () => { + expect( + json("$.status", "not_contains", "ok").assert(http({ body })).success, + ).toBe(false); + }); + + it("fails on an invalid JSON body", () => { + expect( + json("$.status", "eq", "ok").assert(http({ body: "" })), + ).toEqual({ success: false, message: "Unable to parse json" }); + }); + + it("rejects a DNS request", () => { + expect(json("$.status", "eq", "ok").assert({ records: {} }).success).toBe( + false, + ); + }); + + // JSONPath returns an array, which is compared to the target as-is, so + // these fail today. Un-ignore once the matched value is unwrapped. + it.ignore("passes eq when the matched value equals the target", () => { + expect(json("$.status", "eq", "ok").assert(http({ body })).success).toBe( + true, + ); + }); + + it.ignore("fails not_eq when the matched value equals the target", () => { + expect( + json("$.status", "not_eq", "ok").assert(http({ body })).success, + ).toBe(false); + }); + + it.ignore("matches substrings with contains", () => { + expect( + json("$.status", "contains", "o").assert(http({ body })).success, + ).toBe(true); + }); +}); + +describe("DnsRecordAssertion", () => { + function dns( + compare: "contains" | "not_contains" | "eq" | "not_eq", + target: string, + ) { + return new DnsRecordAssertion({ + version: "v1", + type: "dnsRecord", + key: "A", + compare, + target, + }); + } + const req = { records: { A: ["1.1.1.1", "8.8.8.8"] } }; + + const cases = [ + ["eq", "1.1.1.1", true], + ["eq", "1.1.1", false], + ["not_eq", "9.9.9.9", true], + ["not_eq", "8.8.8.8", false], + ["contains", "8.8", true], + ["contains", "9.9", false], + ["not_contains", "9.9", true], + ["not_contains", "1.1", false], + ] as const; + + for (const [compare, target, expected] of cases) { + it(`A ${compare} ${target} -> ${expected}`, () => { + expect(dns(compare, target).assert(req).success).toBe(expected); + }); + } + + it("treats a missing record type as no records", () => { + expect(dns("not_contains", "1.1").assert({ records: {} }).success).toBe( + true, + ); + expect(dns("eq", "1.1.1.1").assert({ records: {} })).toEqual({ + success: false, + message: "DNS Record A: Expected DNS records [] to equal 1.1.1.1", + }); + }); + + it("lists all records in the failure message", () => { + expect(dns("eq", "9.9.9.9").assert(req).message).toBe( + "DNS Record A: Expected DNS records [1.1.1.1, 8.8.8.8] to equal 9.9.9.9", + ); + }); + + it("rejects an HTTP request", () => { + expect(dns("eq", "1.1.1.1").assert(http())).toEqual({ + success: false, + message: "Invalid request type for DNS record assertion", + }); + }); +}); + +describe("assertion schema", () => { + it("defaults version to v1", () => { + const parsed = assertion.parse({ + type: "status", + compare: "eq", + target: 200, + }); + expect(parsed.version).toBe("v1"); + }); + + it("rejects a non-positive status target", () => { + expect( + assertion.safeParse({ type: "status", compare: "eq", target: 0 }).success, + ).toBe(false); + }); + + it("rejects a string compare on a status assertion", () => { + expect( + assertion.safeParse({ type: "status", compare: "contains", target: 200 }) + .success, + ).toBe(false); + }); + + it("rejects an unknown DNS record type", () => { + expect( + assertion.safeParse({ + type: "dnsRecord", + key: "SRV", + compare: "eq", + target: "x", + }).success, + ).toBe(false); + }); +}); diff --git a/packages/tracker/src/utils.test.ts b/packages/tracker/src/utils.test.ts new file mode 100644 index 000000000..6615cc76f --- /dev/null +++ b/packages/tracker/src/utils.test.ts @@ -0,0 +1,68 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { isInBlacklist } from "./blacklist"; +import { endOfDay, isSameDay, startOfDay } from "./utils"; + +describe("startOfDay", () => { + test("truncates to UTC midnight", () => { + expect(startOfDay(new Date("2024-03-10T17:45:12.345Z")).toISOString()).toBe( + "2024-03-10T00:00:00.000Z", + ); + }); + + test("does not mutate its input", () => { + const input = new Date("2024-03-10T17:45:12.345Z"); + startOfDay(input); + expect(input.toISOString()).toBe("2024-03-10T17:45:12.345Z"); + }); +}); + +describe("endOfDay", () => { + test("extends to the last UTC millisecond", () => { + expect(endOfDay(new Date("2024-03-10T00:00:00.000Z")).toISOString()).toBe( + "2024-03-10T23:59:59.999Z", + ); + }); + + test("handles a leap day", () => { + expect(endOfDay(new Date("2024-02-29T12:00:00Z")).toISOString()).toBe( + "2024-02-29T23:59:59.999Z", + ); + }); +}); + +describe("isSameDay", () => { + test("matches the first and last millisecond of a UTC day", () => { + expect( + isSameDay( + new Date("2024-03-10T00:00:00.000Z"), + new Date("2024-03-10T23:59:59.999Z"), + ), + ).toBe(true); + }); + + test("splits across UTC midnight", () => { + expect( + isSameDay( + new Date("2024-03-10T23:59:59.999Z"), + new Date("2024-03-11T00:00:00.000Z"), + ), + ).toBe(false); + }); + + test("compares in UTC regardless of the input offset", () => { + expect( + isSameDay( + new Date("2024-03-10T20:00:00-05:00"), + new Date("2024-03-11T23:30:00Z"), + ), + ).toBe(true); + }); +}); + +describe("isInBlacklist", () => { + test("returns undefined for a normal day", () => { + expect(isInBlacklist(new Date("2024-01-01T12:00:00Z"))).toBeUndefined(); + }); +}); diff --git a/packages/utils/src/payloads.test.ts b/packages/utils/src/payloads.test.ts new file mode 100644 index 000000000..13a5fb124 --- /dev/null +++ b/packages/utils/src/payloads.test.ts @@ -0,0 +1,103 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { + DNSPayloadSchema, + grpcPayloadSchema, + httpPayloadSchema, + icmpPayloadSchema, + tpcPayloadSchema, +} from "./payloads"; + +const common = { + workspaceId: "1", + monitorId: "2", + status: "active", + cronTimestamp: 1_700_000_000_000, + degradedAfter: null, +}; + +describe("httpPayloadSchema", () => { + const minimal = { + ...common, + method: "GET", + url: "https://openstatus.dev", + assertions: null, + }; + + it("applies defaults", () => { + const parsed = httpPayloadSchema.parse(minimal); + expect(parsed.timeout).toBe(45000); + expect(parsed.retry).toBe(3); + expect(parsed.trigger).toBe("cron"); + expect(parsed.followRedirects).toBe(true); + }); + + it("keeps explicit values over defaults", () => { + const parsed = httpPayloadSchema.parse({ + ...minimal, + timeout: 1000, + retry: 0, + trigger: "api", + followRedirects: false, + }); + expect(parsed).toMatchObject({ + timeout: 1000, + retry: 0, + trigger: "api", + followRedirects: false, + }); + }); + + it("rejects an unknown method", () => { + expect( + httpPayloadSchema.safeParse({ ...minimal, method: "FETCH" }).success, + ).toBe(false); + }); + + it("rejects an unknown status", () => { + expect( + httpPayloadSchema.safeParse({ ...minimal, status: "paused" }).success, + ).toBe(false); + }); + + it("keeps unknown assertion fields so later schemas can parse them", () => { + const parsed = httpPayloadSchema.parse({ + ...minimal, + assertions: [{ type: "status", compare: "eq", target: 200 }], + }); + expect(parsed.assertions).toEqual([ + { version: "v1", type: "status", compare: "eq", target: 200 }, + ]); + }); +}); + +describe("non-HTTP payload schemas", () => { + const withUri = { ...common, uri: "openstatus.dev:443" }; + + for (const [name, schema, extra] of [ + ["tcp", tpcPayloadSchema, { assertions: null }], + ["dns", DNSPayloadSchema, { assertions: null }], + ["icmp", icmpPayloadSchema, {}], + ["grpc", grpcPayloadSchema, {}], + ] as const) { + it(`${name} applies timeout, retry and trigger defaults`, () => { + const parsed = schema.parse({ ...withUri, ...extra }); + expect(parsed).toMatchObject({ + timeout: 45000, + retry: 3, + trigger: "cron", + }); + }); + } + + it("grpc defaults to tls", () => { + expect(grpcPayloadSchema.parse(withUri).tls).toBe("tls"); + }); + + it("grpc rejects an unknown tls mode", () => { + expect( + grpcPayloadSchema.safeParse({ ...withUri, tls: "mtls" }).success, + ).toBe(false); + }); +}); diff --git a/packages/utils/src/person-name.test.ts b/packages/utils/src/person-name.test.ts new file mode 100644 index 000000000..fed7a4402 --- /dev/null +++ b/packages/utils/src/person-name.test.ts @@ -0,0 +1,53 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { personName } from "./person-name"; + +const empty = { name: null, firstName: null, lastName: null, email: null }; + +describe("personName", () => { + it("returns null for a missing person", () => { + expect(personName(null)).toBeNull(); + }); + + it("prefers name over everything else", () => { + expect( + personName({ + name: "Max", + firstName: "Maximilian", + lastName: "Kaske", + email: "max@openstatus.dev", + }), + ).toBe("Max"); + }); + + it("joins first and last name when name is missing", () => { + expect( + personName({ ...empty, firstName: "Ada", lastName: "Lovelace" }), + ).toBe("Ada Lovelace"); + }); + + it("uses a lone first or last name without stray spaces", () => { + expect(personName({ ...empty, firstName: "Ada" })).toBe("Ada"); + expect(personName({ ...empty, lastName: "Lovelace" })).toBe("Lovelace"); + }); + + it("falls back to email", () => { + expect(personName({ ...empty, email: "ada@example.com" })).toBe( + "ada@example.com", + ); + }); + + it("treats empty strings as missing", () => { + expect( + personName({ name: "", firstName: "", lastName: "", email: "a@b.co" }), + ).toBe("a@b.co"); + }); + + it("returns null when every field is empty", () => { + expect(personName(empty)).toBeNull(); + expect( + personName({ name: "", firstName: "", lastName: "", email: "" }), + ).toBeNull(); + }); +}); diff --git a/packages/utils/src/stream.test.ts b/packages/utils/src/stream.test.ts new file mode 100644 index 000000000..687964064 --- /dev/null +++ b/packages/utils/src/stream.test.ts @@ -0,0 +1,90 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { iteratorToStream, yieldMany } from "./stream"; + +function delay(ms: number, value: T) { + return new Promise((resolve) => setTimeout(() => resolve(value), ms)); +} + +async function collect(gen: AsyncGenerator) { + const out: T[] = []; + for await (const v of gen) out.push(v); + return out; +} + +describe("yieldMany", () => { + it("yields values in settle order, not input order", async () => { + const out = await collect( + yieldMany([delay(30, "slow"), delay(0, "fast"), delay(15, "mid")]), + ); + expect(out).toEqual(["fast", "mid", "slow"]); + }); + + it("keeps every value that settles in the same microtask batch", async () => { + const out = await collect( + yieldMany([1, 2, 3, 4, 5].map((n) => Promise.resolve(n))), + ); + expect(out.sort()).toEqual([1, 2, 3, 4, 5]); + }); + + it("completes immediately for an empty list", async () => { + expect(await collect(yieldMany([]))).toEqual([]); + }); + + it("returns 'done' when exhausted", async () => { + const gen = yieldMany([Promise.resolve(1)]); + expect(await gen.next()).toEqual({ value: 1, done: false }); + expect(await gen.next()).toEqual({ value: "done", done: true }); + }); + + it("yields values settled before a rejection, then throws it", async () => { + const out: string[] = []; + const run = async () => { + for await (const v of yieldMany([ + delay(0, "a"), + delay(10, "unused").then(() => Promise.reject(new Error("boom"))), + ])) { + out.push(v); + } + }; + await expect(run()).rejects.toThrow("boom"); + expect(out).toEqual(["a"]); + }); + + it("surfaces only the first of simultaneous rejections", async () => { + const gen = yieldMany([ + Promise.reject(new Error("first")), + Promise.reject(new Error("second")), + ]); + await expect(gen.next()).rejects.toThrow("first"); + }); +}); + +describe("iteratorToStream", () => { + it("enqueues every yielded value then closes", async () => { + async function* gen() { + yield "a"; + yield "b"; + } + const chunks: unknown[] = []; + for await (const chunk of iteratorToStream(gen())) chunks.push(chunk); + expect(chunks).toEqual(["a", "b"]); + }); + + it("errors the stream when the iterator throws", async () => { + async function* gen() { + yield "a"; + throw new Error("broken"); + } + const reader = iteratorToStream(gen()).getReader(); + const originalError = console.error; + console.error = () => {}; + try { + expect(await reader.read()).toEqual({ value: "a", done: false }); + await expect(reader.read()).rejects.toThrow("broken"); + } finally { + console.error = originalError; + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f370ecea5..659d33364 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2159,6 +2159,12 @@ importers: '@openstatus/tsconfig': specifier: workspace:* version: link:../tsconfig + '@std/expect': + specifier: jsr:^1.0.19 + version: '@jsr/std__expect@1.0.20' + '@std/testing': + specifier: jsr:^1.0.19 + version: '@jsr/std__testing@1.0.20' typescript: specifier: 'catalog:' version: 7.0.2