diff --git a/.github/workflows/status-smoke.yml b/.github/workflows/status-smoke.yml new file mode 100644 index 00000000..d87380a0 --- /dev/null +++ b/.github/workflows/status-smoke.yml @@ -0,0 +1,19 @@ +name: Status Smoke Test +on: + schedule: + - cron: "0 9 * * 1" + workflow_dispatch: +permissions: + contents: read +jobs: + status-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:status-smoke diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1eb2d411..320c2130 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,11 +33,12 @@ Fine-grained PATs need Actions (read), Contents (read), Issues (read), and Pull ## Running checks ```bash -pnpm test # unit tests (Vitest — root + mcp/) -pnpm test:e2e # Playwright E2E tests (chromium) -pnpm run typecheck # TypeScript validation (root + mcp/) -pnpm run screenshot # Capture dashboard screenshot (saves to docs/) -pnpm mcp:serve # Start the MCP server (requires GITHUB_TOKEN) +pnpm test # unit tests (Vitest — root + mcp/) +pnpm test:e2e # Playwright E2E tests (chromium) +pnpm test:status-smoke # Live-network check that githubstatus.com still reports the tracked component names (also runs on a weekly schedule in CI) +pnpm run typecheck # TypeScript validation (root + mcp/) +pnpm run screenshot # Capture dashboard screenshot (saves to docs/) +pnpm mcp:serve # Start the MCP server (requires GITHUB_TOKEN) ``` To test MCP tools interactively, use the MCP Inspector: @@ -98,7 +99,7 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org/): type(scope): description ``` -Scope is optional. Use imperative mood: "add feature", not "adds feature" or "added feature". +Scope is optional. Use present-indicative mood: "adds feature", not "add feature" or "added feature". ## Releasing the MCP server diff --git a/README.md b/README.md index 5388c389..f10df639 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ A second, faster poll loop (default 30s, configurable 10–120s) targets only in Browser notifications for new issues, PRs, and failed runs. Per-type toggles in settings. Notification permission requested on first enable. New items are detected via the Events API polling loop and full refresh cycles. +### GitHub Status Badge + +A status dot in the header reflects GitHub's own reported status for the services this dashboard depends on (Actions, API Requests, Git Operations, Issues, Pull Requests). Click it for incident details and a link to githubstatus.com. Toast and drawer notifications fire on incident start and resolution. + ### Repo Pinning and Reordering Lock repos to the top of each tab's list so they don't shift around as activity changes. Drag-to-reorder within the locked set. Lock controls appear on hover on desktop, always visible on mobile. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 697ccd97..21d14ee5 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -15,6 +15,7 @@ GitHub Tracker is a dashboard that aggregates open issues, pull requests, and Gi - [Personal Summary Strip](#personal-summary-strip) - [Repo Grouping and Expand/Collapse](#repo-grouping-and-expandcollapse) - [Scope Filter](#scope-filter) + - [GitHub Status Badge](#github-status-badge) - [Issues Tab](#issues-tab) - [Filters](#issues-filters) - [Dependency Dashboard Toggle](#dependency-dashboard-toggle) @@ -148,6 +149,12 @@ The **Scope** filter chip appears on the Issues and Pull Requests tabs when you The scope filter is hidden (and always set to "Involves me") when you have no tracked users and no monitor-all repos, because in that configuration all fetched data already involves you. +### GitHub Status Badge + +A small status dot appears in the header, next to your avatar. It reflects GitHub's own reported status for the services this dashboard depends on — Actions, API Requests, Git Operations, Issues, and Pull Requests. Green means all tracked services are operational; yellow, orange, or red indicate minor, major, or critical disruption respectively. + +Click the badge to see details on any active incident, including affected components and the latest status update, plus a link to [githubstatus.com](https://www.githubstatus.com). Other GitHub services (Copilot, Codespaces, Pages, Packages, etc.) are not tracked by this badge. + --- ## Issues Tab @@ -369,6 +376,10 @@ Setting the interval to **Off** disables automatic polling; manual refresh still A ±30 second jitter is applied to the refresh interval to avoid synchronized API spikes from multiple browser tabs. +### GitHub Status Checks + +Each full refresh also checks GitHub's own status page for outages affecting Actions, API Requests, Git Operations, Issues, or Pull Requests. This is a single lightweight, unauthenticated request and does not count against your GitHub API rate limit. It follows the same schedule as the full refresh — including the same visibility-based pausing — rather than running on its own timer. + ### Hot Poll A second, faster poll loop runs alongside the full refresh specifically for in-flight items. It targets: @@ -410,6 +421,10 @@ When you return to a tab that has been hidden for more than 2 minutes, a catch-u The bell icon in the header opens the notification drawer, which shows API errors, rate limit warnings, and other system messages. Notifications are dismissed automatically when the underlying condition clears on the next poll cycle. +### GitHub Outage Notifications + +When a GitHub outage affecting a tracked service starts, a toast appears and an entry is added to the notification drawer. A second notification confirms it once the outage resolves. These always appear via the in-app toast and drawer — they are not part of the **Browser Push Notifications** toggles below and cannot be disabled. + ### Browser Push Notifications Browser push notifications are disabled by default. To enable them: diff --git a/package.json b/package.json index a9b8308e..c0e99dcd 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "typecheck": "tsc --build src/shared/tsconfig.json && tsc --noEmit && tsc --build mcp/tsconfig.json", "test:e2e": "E2E_PORT=$(node -e \"const s=require('net').createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})\") playwright test", "test:waf": "bash scripts/waf-smoke-test.sh", + "test:status-smoke": "vitest run --config vitest.smoke.config.ts", "screenshot": "pnpm exec playwright test --config playwright.config.screenshot.ts", "mcp:serve": "pnpm --filter github-tracker-mcp dev", "validate:deploy": "bash scripts/validate-deploy.sh" diff --git a/public/_headers b/public/_headers index e0ecdf6e..3dff81e6 100644 --- a/public/_headers +++ b/public/_headers @@ -1,5 +1,5 @@ /* - Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y=' https://challenges.cloudflare.com; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com https://api.atlassian.com ws://127.0.0.1:*; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests; report-uri /api/csp-report; report-to csp-endpoint + Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y=' https://challenges.cloudflare.com; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com https://api.atlassian.com https://www.githubstatus.com ws://127.0.0.1:*; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests; report-uri /api/csp-report; report-to csp-endpoint Reporting-Endpoints: csp-endpoint="/api/csp-report" X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin diff --git a/src/app/components/layout/Header.tsx b/src/app/components/layout/Header.tsx index 87ce5888..3b6865ca 100644 --- a/src/app/components/layout/Header.tsx +++ b/src/app/components/layout/Header.tsx @@ -5,6 +5,7 @@ import { getUnreadCount, markAllAsRead } from "../../lib/errors"; import NotificationDrawer from "../shared/NotificationDrawer"; import ToastContainer from "../shared/ToastContainer"; import { Tooltip } from "../shared/Tooltip"; +import GitHubStatusBadge from "../shared/GitHubStatusBadge"; export default function Header() { const navigate = useNavigate(); @@ -56,6 +57,8 @@ export default function Header() { )} + + = { + none: { bg: "bg-success", label: "All systems operational", pulse: false }, + minor: { bg: "bg-warning", label: "Minor GitHub service disruption", pulse: false }, + major: { bg: "bg-orange-500", label: "Major GitHub service outage", pulse: true }, + critical: { bg: "bg-red-500", label: "Critical GitHub service outage", pulse: true }, +}; + +export default function GitHubStatusBadge() { + const status = createMemo(() => getGitHubStatus()); + const cfg = createMemo(() => { + const s = status(); + return s !== null + ? SEVERITY_CONFIG[s.severity] + : { bg: "bg-base-content/20", label: "Checking GitHub status…", pulse: false }; + }); + const statusSummaryRow = () => ( +
+ + {cfg().label} +
+ ); + const incidentList = createMemo(() => { + const s = status(); + return s && s.incidents.length > 0 ? s.incidents : null; + }); + const [popoverOpen, setPopoverOpen] = createSignal(false); + + return ( + + + + + + + + + + + + + + + {(list) => ( +
    + + {(incident) => ( +
  • +
    {incident.name}
    +
    Affects: {incident.affectedComponents.join(", ")}
    + +

    {incident.latestUpdateBody}

    +
    +
  • + )} +
    +
+ )} +
+
+ View githubstatus.com + + + + + ); +} diff --git a/src/app/components/shared/Tooltip.tsx b/src/app/components/shared/Tooltip.tsx index 65762093..5d0d65d7 100644 --- a/src/app/components/shared/Tooltip.tsx +++ b/src/app/components/shared/Tooltip.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, onCleanup } from "solid-js"; +import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"; import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip"; import type { JSX } from "solid-js"; @@ -12,13 +12,14 @@ interface TooltipProps { focusable?: boolean; class?: string; contentClass?: string; + forceClosed?: boolean; children: JSX.Element; } export function Tooltip(props: TooltipProps) { const [isHovered, setIsHovered] = createSignal(false); const [isFocused, setIsFocused] = createSignal(false); - const open = createMemo(() => isHovered() || isFocused()); + const open = createMemo(() => !props.forceClosed && (isHovered() || isFocused())); // openDelay is ignored in controlled mode; implement the delay manually let hoverTimer: ReturnType | undefined; @@ -28,6 +29,23 @@ export function Tooltip(props: TooltipProps) { clearTimeout(closeTimer); }); + // A consumer (e.g. GitHubStatusBadge) can force this tooltip closed while something else, + // like a Popover, is open. Dismissing that Popover via Escape or an outside click never + // fires a pointerleave/blur on this tooltip's own trigger, so isHovered/isFocused can be + // left stale as `true`. Reset them when forceClosed transitions back to false so the + // tooltip doesn't flash back open on its own — a fresh hover/focus is required to reopen it. + createEffect(on( + () => !!props.forceClosed, + (forceClosed, prevForceClosed) => { + if (prevForceClosed && !forceClosed) { + clearTimeout(hoverTimer); + clearTimeout(closeTimer); + setIsHovered(false); + setIsFocused(false); + } + } + )); + return ( ; +type RawSummaryResponse = z.infer; + +// Exact component names as returned by the live API, verified 2026-08-07 via +// `curl https://www.githubstatus.com/api/v2/summary.json`. Component names are +// Statuspage-admin-configurable free text with no stability guarantee from +// GitHub/Atlassian — if GitHub renames a tracked component (e.g. "Actions" → +// "GitHub Actions"), TRACKED_COMPONENT_NAMES.has(c.name) silently stops matching +// it with no error, no test failure, and no user-visible signal beyond an +// incorrectly-green badge during a real outage. Guarded by +// tests/services/github-status.smoke.test.ts (live network, not part of `pnpm test`). +export const TRACKED_COMPONENT_NAMES = new Set([ + "Actions", + "API Requests", + "Git Operations", + "Issues", + "Pull Requests", +]); + +// Note: unlike poll.ts's resetPollState()/events.ts's resetEventsState(), this +// module deliberately does not hook into onAuthCleared. GitHub's own status is a +// global fact, not scoped to the authenticated user — it should persist across a +// logout/login (or a switch between users on the same browser) exactly as-is. + +// ── Severity mapping and blending ──────────────────────────────────────────── + +const COMPONENT_STATUS_SEVERITY: Record = { + operational: "none", + degraded_performance: "minor", + partial_outage: "major", + major_outage: "critical", +}; +const SEVERITY_RANK: Record = { none: 0, minor: 1, major: 2, critical: 3 }; + +// [ASSUMPTION: unrecognized component status strings default to "none" severity +// rather than throwing — treats unknown future Statuspage status values as +// non-blocking rather than failing the whole badge] +function blendSeverity(components: RawComponent[]): GitHubStatusSeverity { + return components.reduce((worst, c) => { + const s = COMPONENT_STATUS_SEVERITY[c.status] ?? "none"; + return SEVERITY_RANK[s] > SEVERITY_RANK[worst] ? s : worst; + }, "none"); +} + +// ── Notification state and transition tracking ─────────────────────────────── + +const NOTIFICATION_SOURCE = "github-status"; +const NOTIFICATION_SOURCE_RESOLVED = "github-status-resolved"; +let _previousIncidents = new Map(); // id -> name, across cycles + +// ── parseSummary / notifyTransitions ───────────────────────────────────────── + +function severityToNotificationLevel(s: GitHubStatusSeverity): "warning" | "error" { + return s === "critical" || s === "major" ? "error" : "warning"; +} + +// Incident update bodies are HTML per Statuspage's schema (confirmed live: contains +// literal `
` tags). Strip to plain text here so the badge component can render +// via plain JSX text interpolation (Security Flags item 2) without leaking literal +// tags in the UI. +function stripHtml(html: string): string { + return html + .replace(//gi, "\n") + .replace(/<\/p>/gi, "\n") + .replace(/<[^>]+>/g, "") + .trim(); +} + +// Pure: parses the raw API response and blends severity. No side effects, not +// exported — matches this codebase's established convention of never exporting +// internal parse/transform functions purely for direct unit testing (see +// src/app/services/api.ts's processIssueNode/mapCheckStatus/buildRepoQualifiers). +// Tested indirectly via fetchGitHubStatus. +function parseSummary(raw: RawSummaryResponse): GitHubStatusSummary { + const trackedComponents = raw.components.filter((c) => TRACKED_COMPONENT_NAMES.has(c.name)); + let severity = blendSeverity(trackedComponents); + + const relevantIncidents: GitHubStatusIncident[] = raw.incidents.flatMap((inc) => { + const affected = inc.components.filter((c) => TRACKED_COMPONENT_NAMES.has(c.name)); + if (affected.length === 0) return []; + return [{ + id: inc.id, + name: inc.name, + latestUpdateBody: stripHtml(inc.incident_updates[0]?.body ?? ""), + affectedComponents: affected.map((c) => c.name), + }]; + }); + + // Statuspage commonly resets a component's status back to "operational" during + // an incident's "Monitoring" phase while the incident itself stays open, which + // would otherwise blend to "none" here even though a relevant incident is still + // active. Floor severity at "minor" in that case so the badge/notification can + // never claim "all systems operational" while an open incident is still being + // surfaced (CR-001) — avoids a green badge whose own popover lists an incident. + if (severity === "none" && relevantIncidents.length > 0) { + severity = "minor"; + } + + return { severity, incidents: relevantIncidents, fetchedAt: new Date() }; +} + +// Side-effecting: consumes a parsed summary and dispatches notification +// transitions. Mirrors the change-detector (detectNewItems()) / dispatcher +// (dispatchNotifications()) split already established in +// src/app/lib/notifications.ts. +// +// Both pushNotification calls pass retryable=false — an outage announcement is +// not a failed/retryable operation. Message text is just the incident name(s), +// not prefixed with "GitHub status: " — NotificationDrawer.tsx/ToastContainer.tsx +// already render `{source}: {message}`, so a message-level prefix would duplicate +// the "github-status" source label already shown. +// +// [ASSUMPTION: concurrent distinct incidents are blended into one "github-status" +// notification/badge state rather than tracked individually — matches the +// single-blended-badge UI decision, avoids a list of independent toasts for a +// rare edge case] +function notifyTransitions(summary: GitHubStatusSummary): void { + const currentIncidents = new Map(summary.incidents.map((i) => [i.id, i.name])); + + if (summary.incidents.length > 0) { + const names = summary.incidents.map((i) => i.name).join(", "); + pushNotification(NOTIFICATION_SOURCE, names, severityToNotificationLevel(summary.severity), false); + } else { + dismissNotificationBySource(NOTIFICATION_SOURCE); + } + + const resolvedNames = [..._previousIncidents.entries()] + .filter(([id]) => !currentIncidents.has(id)) + .map(([, name]) => name); + if (resolvedNames.length > 0) { + pushNotification(NOTIFICATION_SOURCE_RESOLVED, resolvedNames.join(", "), "info", false); + } + + _previousIncidents = currentIncidents; +} + +// ── fetchGitHubStatus — network call via cachedFetch, signal ──────────────── + +const STATUS_API_URL = "https://www.githubstatus.com/api/v2/summary.json"; +const CACHE_KEY = "github-status:summary"; + +const [_githubStatus, _setGitHubStatus] = createSignal(null); +export function getGitHubStatus(): GitHubStatusSummary | null { + return _githubStatus(); +} + +let _fetchInProgress = false; + +// Consecutive-failure gate for the catch block below (CR-002) — see comment there. +const CONSECUTIVE_FAILURE_THRESHOLD = 3; +let _consecutiveFailures = 0; + +export async function fetchGitHubStatus(): Promise { + if (_fetchInProgress) return; // in-flight guard — avoid pile-up if refreshInterval is very short/0 or the endpoint is slow to respond + _fetchInProgress = true; + try { + const { data } = await cachedFetch(CACHE_KEY, async (headers) => { + const reqHeaders: Record = {}; + if (headers.etag) reqHeaders["If-None-Match"] = headers.etag; + const res = await fetch(STATUS_API_URL, { + headers: reqHeaders, + credentials: "omit", + cache: "no-store", + signal: AbortSignal.timeout(10_000), + }); + if (res.status === 304) { + return { data: null, etag: headers.etag, lastModified: headers.lastModified, status: 304 }; + } + const json = await res.json(); + return { data: json, etag: res.headers.get("ETag"), lastModified: res.headers.get("Last-Modified"), status: res.status }; + }); + + // Validate the raw shape before parsing — catches API drift (e.g. a field + // renamed/removed upstream) as a distinct, loud log message instead of an + // unexplained TypeError deep inside parseSummary. Treated the same as any + // other fetch failure (see recordFetchFailure()). + const validated = RawSummaryResponseSchema.safeParse(data); + if (!validated.success) { + console.warn("[github-status] response schema drift — validation failed:", validated.error); + recordFetchFailure(); + return; + } + + const summary = parseSummary(validated.data); + notifyTransitions(summary); + _setGitHubStatus(summary); + _consecutiveFailures = 0; + } catch (err) { + console.warn("[github-status] fetch failed:", err instanceof Error ? err.message : String(err)); + recordFetchFailure(); + } finally { + _fetchInProgress = false; + } +} + +// Best-effort, ancillary external signal — deliberately no pushError, don't +// pollute the notification center with transient network blips or schema drift +// for a non-critical feature the user can't act on anyway. Still DO dismiss any +// active "github-status" incident notification once failures persist: +// notifyTransitions() owns this notification's entire lifecycle end-to-end, and +// poll.ts deliberately excludes github-status from poll-level reconciliation +// (POLL_MANAGED_SOURCES), so if this fetch keeps failing while an incident +// notification is showing, nothing else would ever clear it. A single blip +// doesn't mean the incident resolved, though — dismissing on every failure made +// the next successful poll re-push an unchanged incident as if newly announced +// (CR-002). Gate dismissal on CONSECUTIVE_FAILURE_THRESHOLD consecutive +// failures instead of any single one. Shared by both failure modes (network/ +// parse errors in the catch block, and schema-validation failures above) so +// neither can bypass the gate. +function recordFetchFailure(): void { + _consecutiveFailures++; + if (_consecutiveFailures >= CONSECUTIVE_FAILURE_THRESHOLD) { + dismissNotificationBySource(NOTIFICATION_SOURCE); + } +} + +// Test-only reset — mirrors resetPollState() (poll.ts), resetNotificationState() +// (lib/errors.ts), resetEventsState() (services/events.ts). Deliberately NOT +// wired to onAuthCleared (see note above: GitHub status is global, not +// user-scoped, and must survive logout/login). +export function resetGitHubStatusState(): void { + _previousIncidents = new Map(); + _setGitHubStatus(null); + _fetchInProgress = false; + _consecutiveFailures = 0; +} diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index b177e540..b622db5a 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -1,6 +1,7 @@ import { createSignal, createEffect, createRoot, untrack, onCleanup } from "solid-js"; import * as Sentry from "@sentry/solid"; import { getClient, fetchRateLimitDetails } from "./github"; +import { fetchGitHubStatus } from "./github-status"; import { config } from "../stores/config"; import { user, onAuthCleared, expireToken } from "../stores/auth"; import { checkAndResetIfExpired } from "./api-usage"; @@ -310,6 +311,11 @@ export function createPollCoordinator( // Fire-and-forget: seeds footer signals concurrently with fetchAll. If GET /rate_limit // resolves after a GraphQL response, the footer briefly shows pre-query remaining (cosmetic). void fetchRateLimitDetails(); + // Fire-and-forget, same pattern as above: checks GitHub's own status page for + // outages affecting tracked components. Fully self-contained (own try/catch, + // own notification push/dismiss) — no interaction with this cycle's error + // handling or the fetchAll() try/catch below. + void fetchGitHubStatus(); // Snapshot sources of notifications from previous cycle (for reconciliation) const previousSources = new Set( diff --git a/tests/components/layout/Header.test.tsx b/tests/components/layout/Header.test.tsx index d3bfb788..8fd47c2d 100644 --- a/tests/components/layout/Header.test.tsx +++ b/tests/components/layout/Header.test.tsx @@ -43,9 +43,15 @@ vi.mock("../../../src/app/lib/errors", () => ({ clearMutedSources: vi.fn(), })); +// Mock github-status module so Header's GitHubStatusBadge import works +vi.mock("../../../src/app/services/github-status", () => ({ + getGitHubStatus: vi.fn(() => null), +})); + import Header from "../../../src/app/components/layout/Header"; import * as authStore from "../../../src/app/stores/auth"; import * as errorsModule from "../../../src/app/lib/errors"; +import * as githubStatusModule from "../../../src/app/services/github-status"; import { render } from "@solidjs/testing-library"; beforeEach(() => { @@ -53,6 +59,7 @@ beforeEach(() => { vi.mocked(authStore.clearAuth).mockClear(); vi.mocked(errorsModule.getUnreadCount).mockReturnValue(0); vi.mocked(errorsModule.markAllAsRead).mockClear(); + vi.mocked(githubStatusModule.getGitHubStatus).mockReturnValue(null); }); describe("Header", () => { @@ -151,4 +158,29 @@ describe("Header", () => { expect(bellBtn.getAttribute("aria-expanded")).toBe("false"); expect(errorsModule.markAllAsRead).toHaveBeenCalledTimes(1); }); + + it("renders GitHub status badge with checking state before first fetch", () => { + render(() =>
); + expect(screen.getByLabelText("Checking GitHub status…")).toBeDefined(); + }); + + it("renders GitHub status badge reflecting operational status", () => { + vi.mocked(githubStatusModule.getGitHubStatus).mockReturnValue({ + severity: "none", + incidents: [], + fetchedAt: new Date(), + }); + render(() =>
); + expect(screen.getByLabelText("All systems operational")).toBeDefined(); + }); + + it("renders GitHub status badge reflecting an active critical incident", () => { + vi.mocked(githubStatusModule.getGitHubStatus).mockReturnValue({ + severity: "critical", + incidents: [{ id: "1", name: "Actions Outage", latestUpdateBody: "Investigating", affectedComponents: ["Actions"] }], + fetchedAt: new Date(), + }); + render(() =>
); + expect(screen.getByLabelText("Critical GitHub service outage")).toBeDefined(); + }); }); diff --git a/tests/components/shared/GitHubStatusBadge.test.tsx b/tests/components/shared/GitHubStatusBadge.test.tsx new file mode 100644 index 00000000..98f6dbdd --- /dev/null +++ b/tests/components/shared/GitHubStatusBadge.test.tsx @@ -0,0 +1,228 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import type { GitHubStatusSummary } from "../../../src/app/services/github-status"; + +const mockGetGitHubStatus = vi.fn<() => GitHubStatusSummary | null>(() => null); + +vi.mock("../../../src/app/services/github-status", () => ({ + getGitHubStatus: () => mockGetGitHubStatus(), +})); + +import GitHubStatusBadge from "../../../src/app/components/shared/GitHubStatusBadge"; + +beforeEach(() => { + vi.useFakeTimers(); + mockGetGitHubStatus.mockReset(); + mockGetGitHubStatus.mockReturnValue(null); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("GitHubStatusBadge", () => { + it("renders neutral checking state before first fetch resolves", () => { + mockGetGitHubStatus.mockReturnValue(null); + const { container } = render(() => ); + const button = screen.getByRole("button", { name: "Checking GitHub status…" }); + expect(button).toBeTruthy(); + const dot = container.querySelector("span.rounded-full.w-2.h-2"); + expect(dot?.classList.contains("bg-base-content/20")).toBe(true); + + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(button.getAttribute("aria-expanded")).toBe("true"); + expect(document.body.textContent).toContain("Checking GitHub status…"); + }); + + it("severity 'none' shows success dot without pulse and popover shows operational message", () => { + mockGetGitHubStatus.mockReturnValue({ severity: "none", incidents: [], fetchedAt: new Date() }); + const { container } = render(() => ); + const dot = container.querySelector("span.rounded-full.w-2.h-2")!; + expect(dot.classList.contains("bg-success")).toBe(true); + expect(container.querySelector(".animate-slow-pulse")).toBeNull(); + + const button = screen.getByRole("button", { name: "All systems operational" }); + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(document.body.textContent).toContain("All systems operational"); + const link = screen.getByRole("link", { name: /View githubstatus\.com/i }); + expect(link.getAttribute("href")).toBe("https://www.githubstatus.com"); + }); + + it("severity 'minor' shows warning dot without pulse class and popover shows incident details", () => { + mockGetGitHubStatus.mockReturnValue({ + severity: "minor", + incidents: [{ id: "1", name: "Degraded search", latestUpdateBody: "", affectedComponents: ["Search"] }], + fetchedAt: new Date(), + }); + const { container } = render(() => ); + const dot = container.querySelector("span.rounded-full.w-2.h-2")!; + expect(dot.classList.contains("bg-warning")).toBe(true); + expect(container.querySelector(".animate-slow-pulse")).toBeNull(); + + const button = screen.getByRole("button", { name: "Minor GitHub service disruption" }); + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(screen.getByText("Degraded search")).toBeTruthy(); + expect(document.body.textContent).toContain("Affects: Search"); + }); + + it("severity 'major' shows orange dot with pulse class", () => { + mockGetGitHubStatus.mockReturnValue({ + severity: "major", + incidents: [{ id: "1", name: "API outage", latestUpdateBody: "", affectedComponents: ["API Requests"] }], + fetchedAt: new Date(), + }); + const { container } = render(() => ); + const dot = container.querySelector("span.rounded-full.w-2.h-2")!; + expect(dot.classList.contains("bg-orange-500")).toBe(true); + expect(container.querySelector(".animate-slow-pulse")).not.toBeNull(); + }); + + it("severity 'critical' with one incident shows critical color, pulse, and popover incident details", () => { + mockGetGitHubStatus.mockReturnValue({ + severity: "critical", + incidents: [ + { + id: "1", + name: "API outage", + latestUpdateBody: "We are investigating the issue.", + affectedComponents: ["API Requests", "Webhooks"], + }, + ], + fetchedAt: new Date(), + }); + const { container } = render(() => ); + const dot = container.querySelector("span.rounded-full.w-2.h-2")!; + expect(dot.classList.contains("bg-red-500")).toBe(true); + expect(container.querySelector(".animate-slow-pulse")).not.toBeNull(); + + const button = screen.getByRole("button", { name: "Critical GitHub service outage" }); + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(screen.getByText("API outage")).toBeTruthy(); + expect(document.body.textContent).toContain("Affects: API Requests, Webhooks"); + expect(document.body.textContent).toContain("We are investigating the issue."); + }); + + it("does not render incident name as HTML (XSS regression)", () => { + const malicious = ""; + mockGetGitHubStatus.mockReturnValue({ + severity: "critical", + incidents: [{ id: "1", name: malicious, latestUpdateBody: "", affectedComponents: ["Actions"] }], + fetchedAt: new Date(), + }); + render(() => ); + const button = screen.getByRole("button", { name: "Critical GitHub service outage" }); + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(screen.getByText(malicious)).toBeTruthy(); + // document-scoped: Popover.Content teleports to document.body via Popover.Portal, + // so a container-scoped query would trivially pass even if the vulnerability were real. + expect(document.querySelector("img[onerror]")).toBeNull(); + }); + + it("clicking the trigger while hovered suppresses the tooltip and shows the popover", () => { + mockGetGitHubStatus.mockReturnValue({ + severity: "major", + incidents: [{ id: "1", name: "Some outage", latestUpdateBody: "We are investigating", affectedComponents: ["Actions"] }], + fetchedAt: new Date(), + }); + const { container } = render(() => ); + const tooltipTrigger = container.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(tooltipTrigger); + vi.advanceTimersByTime(300); + // Kobalte keeps the tooltip's content node mounted (for exit transitions) even once + // closed, marking it data-closed rather than removing it — so once opened, textContent + // checks can't distinguish open/closed. Check the data-expanded state instead, matching + // this file's existing convention for post-interaction assertions. + let tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(true); + + const button = screen.getByRole("button", { name: "Major GitHub service outage" }); + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(button.getAttribute("aria-expanded")).toBe("true"); + // Tooltip is suppressed (forceClosed) once the Popover opens... + tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(false); + // ...while the Popover's own content is present. + expect(document.body.textContent).toContain("Some outage"); + }); + + it("popover lists all incidents when multiple are present simultaneously", () => { + mockGetGitHubStatus.mockReturnValue({ + severity: "major", + incidents: [ + { id: "1", name: "API outage", latestUpdateBody: "", affectedComponents: ["API Requests"] }, + { id: "2", name: "Actions delays", latestUpdateBody: "", affectedComponents: ["Actions"] }, + ], + fetchedAt: new Date(), + }); + render(() => ); + const button = screen.getByRole("button", { name: "Major GitHub service outage" }); + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(screen.getByText("API outage")).toBeTruthy(); + expect(screen.getByText("Actions delays")).toBeTruthy(); + expect(document.body.textContent).toContain("Affects: API Requests"); + expect(document.body.textContent).toContain("Affects: Actions"); + }); + + it("focusing the trigger shows the tooltip via keyboard access, and opening the popover suppresses it", () => { + mockGetGitHubStatus.mockReturnValue({ + severity: "major", + incidents: [{ id: "1", name: "Some outage", latestUpdateBody: "We are investigating", affectedComponents: ["Actions"] }], + fetchedAt: new Date(), + }); + const { container } = render(() => ); + const tooltipTrigger = container.querySelector("span.inline-flex")!; + fireEvent.focusIn(tooltipTrigger); + // focusIn opens the Tooltip immediately (no hover delay) — see Tooltip.tsx onFocusIn. + let tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(true); + + const button = screen.getByRole("button", { name: "Major GitHub service outage" }); + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(button.getAttribute("aria-expanded")).toBe("true"); + // Tooltip is suppressed (forceClosed) once the Popover opens, even though focus never left... + tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(false); + // ...while the Popover's own content is present. + expect(document.body.textContent).toContain("Some outage"); + }); + + it("does not let a stale hover reopen the tooltip after Escape dismisses the popover", () => { + mockGetGitHubStatus.mockReturnValue({ + severity: "major", + incidents: [{ id: "1", name: "Some outage", latestUpdateBody: "We are investigating", affectedComponents: ["Actions"] }], + fetchedAt: new Date(), + }); + const { container } = render(() => ); + const tooltipTrigger = container.querySelector("span.inline-flex")!; + const button = screen.getByRole("button", { name: "Major GitHub service outage" }); + + // Open the popover first (e.g. via click). + fireEvent.click(button); + vi.advanceTimersByTime(0); + expect(button.getAttribute("aria-expanded")).toBe("true"); + + // The pointer rests on the trigger *while the popover is open* — a real pointerenter + // event, independent of whatever happened before the popover opened. + fireEvent.pointerEnter(tooltipTrigger); + vi.advanceTimersByTime(300); + + // Dismiss the popover via Escape. The pointer never leaves the trigger, so no + // pointerleave/blur fires on it — Kobalte's DismissableLayer closes the popover directly, + // bypassing the Tooltip trigger's own hover/click handlers entirely. + fireEvent.keyDown(document, { key: "Escape" }); + vi.advanceTimersByTime(0); + expect(button.getAttribute("aria-expanded")).toBe("false"); + + // The tooltip must not flash back open from the now-stale hover state. + const tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).not.toBe(true); + }); +}); diff --git a/tests/components/shared/Tooltip.test.tsx b/tests/components/shared/Tooltip.test.tsx index 331db213..be3f97ca 100644 --- a/tests/components/shared/Tooltip.test.tsx +++ b/tests/components/shared/Tooltip.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createSignal } from "solid-js"; import { render, screen, fireEvent } from "@solidjs/testing-library"; import { Tooltip, InfoTooltip } from "../../../src/app/components/shared/Tooltip"; @@ -177,6 +178,84 @@ describe("Tooltip", () => { // Advance past Kobalte's globalSkipDelayTimeout (300ms) so global state resets vi.advanceTimersByTime(500); }); + + it("forceClosed suppresses tooltip even after hover delay", () => { + const { container } = render(() => ( + + Trigger + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(trigger); + vi.advanceTimersByTime(300); + expect(document.body.textContent).not.toContain("X"); + }); + + it("forceClosed={false} or omitted does not change existing hover behavior", () => { + const { container: containerFalse } = render(() => ( + + Trigger + + )); + const triggerFalse = containerFalse.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(triggerFalse); + vi.advanceTimersByTime(300); + expect(document.body.textContent).toContain("X"); + fireEvent.pointerLeave(triggerFalse); + vi.advanceTimersByTime(500); + + const { container: containerOmitted } = render(() => ( + + Trigger + + )); + const triggerOmitted = containerOmitted.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(triggerOmitted); + vi.advanceTimersByTime(300); + expect(document.body.textContent).toContain("Y"); + // Clean up: close the tooltip and let Kobalte's global skip-delay warm state + // reset, so later tests (e.g. InfoTooltip's real openDelay) aren't affected. + fireEvent.pointerLeave(triggerOmitted); + vi.advanceTimersByTime(500); + }); + + it("resets stale hover state when forceClosed transitions back to false", () => { + const [forceClosed, setForceClosed] = createSignal(false); + const { container } = render(() => ( + + Trigger + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(trigger); + vi.advanceTimersByTime(300); + // Kobalte keeps the tooltip's content node mounted (for exit transitions) even once + // closed, marking it data-closed rather than removing it — so once opened, textContent + // checks can't distinguish open/closed. Check the data-expanded state instead, matching + // this file's existing convention (see "forceClosed suppresses tooltip..." above). + let tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(true); + + // Force-close while the pointer never leaves the trigger — isHovered stays stale as true. + setForceClosed(true); + tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(false); + + // Un-force-closing must not let the stale hover reopen the tooltip on its own. + setForceClosed(false); + tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(false); + + // A fresh hover still works normally afterward. + fireEvent.pointerLeave(trigger); + vi.advanceTimersByTime(500); + fireEvent.pointerEnter(trigger); + vi.advanceTimersByTime(300); + tooltipContent = document.querySelector('[role="tooltip"]'); + expect(tooltipContent?.hasAttribute("data-expanded")).toBe(true); + fireEvent.pointerLeave(trigger); + vi.advanceTimersByTime(500); + }); }); describe("InfoTooltip", () => { diff --git a/tests/security/headers.test.ts b/tests/security/headers.test.ts index 6667d834..f097752b 100644 --- a/tests/security/headers.test.ts +++ b/tests/security/headers.test.ts @@ -93,6 +93,22 @@ describe("public/_headers CSP validation", () => { expect(connectSrc).toContain("https://api.github.com"); }); + it("connect-src includes https://www.githubstatus.com", () => { + expect(csp).not.toBeNull(); + const connectSrc = csp!.get("connect-src") ?? ""; + expect(connectSrc).toContain("https://www.githubstatus.com"); + }); + + it("connect-src does NOT include regional GitHub status subdomains", () => { + // Regional Enterprise Cloud status pages are out of scope for this feature + expect(csp).not.toBeNull(); + const connectSrc = csp!.get("connect-src") ?? ""; + expect(connectSrc).not.toContain("au.githubstatus.com"); + expect(connectSrc).not.toContain("eu.githubstatus.com"); + expect(connectSrc).not.toContain("jp.githubstatus.com"); + expect(connectSrc).not.toContain("us.githubstatus.com"); + }); + it("connect-src includes 'self' (same-origin Worker calls)", () => { expect(csp).not.toBeNull(); const connectSrc = csp!.get("connect-src") ?? ""; diff --git a/tests/services/github-status.smoke.test.ts b/tests/services/github-status.smoke.test.ts new file mode 100644 index 00000000..11e1835d --- /dev/null +++ b/tests/services/github-status.smoke.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { TRACKED_COMPONENT_NAMES } from "../../src/app/services/github-status"; + +// Live network smoke test — NOT part of `pnpm test` (excluded in vitest.workspace.ts). +// Run explicitly via `pnpm test:status-smoke`. Converts silent Statuspage +// component-name drift (see TRACKED_COMPONENT_NAMES's doc comment) into a loud, +// actionable CI failure instead of an undetected missed-outage bug. +describe("github-status live API shape (smoke)", () => { + it("all TRACKED_COMPONENT_NAMES are present in the live summary.json component list", async () => { + const res = await fetch("https://www.githubstatus.com/api/v2/summary.json"); + expect(res.ok).toBe(true); + + const json = (await res.json()) as { components: Array<{ name: string }> }; + const liveNames = new Set(json.components.map((c) => c.name)); + + // Assert on the missing-names array (not one expect() per name) so a failure names + // exactly which tracked component drifted — e.g. `Expected: [] / Received: ["Actions"]` + // — instead of an undifferentiated `Expected: true / Received: false`. + const missing = [...TRACKED_COMPONENT_NAMES].filter((name) => !liveNames.has(name)); + expect(missing).toEqual([]); + }); +}); diff --git a/tests/services/github-status.test.ts b/tests/services/github-status.test.ts new file mode 100644 index 00000000..514ebf6d --- /dev/null +++ b/tests/services/github-status.test.ts @@ -0,0 +1,417 @@ +import "fake-indexeddb/auto"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { clearCache } from "../../src/app/stores/cache"; + +const mockPushNotification = vi.fn(); +const mockDismissNotificationBySource = vi.fn(); +vi.mock("../../src/app/lib/errors", () => ({ + pushNotification: (...args: unknown[]) => mockPushNotification(...args), + dismissNotificationBySource: (source: string) => mockDismissNotificationBySource(source), +})); + +import { + fetchGitHubStatus, + getGitHubStatus, + resetGitHubStatusState, + TRACKED_COMPONENT_NAMES, +} from "../../src/app/services/github-status"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +function makeComponent(name: string, status = "operational") { + return { id: `id-${name.replace(/\s+/g, "-").toLowerCase()}`, name, status }; +} + +function makeIncident(overrides: { + id: string; + name: string; + body: string; + componentNames: string[]; + componentStatus?: string; +}) { + return { + id: overrides.id, + name: overrides.name, + incident_updates: [{ body: overrides.body }], + components: overrides.componentNames.map((n) => makeComponent(n, overrides.componentStatus ?? "major_outage")), + }; +} + +function makeSummary(overrides?: { components?: unknown[]; incidents?: unknown[] }) { + return { + page: { id: "test-page", name: "GitHub", url: "https://www.githubstatus.com" }, + status: { indicator: "none", description: "All Systems Operational" }, + components: overrides?.components ?? [...TRACKED_COMPONENT_NAMES].map((n) => makeComponent(n)), + incidents: overrides?.incidents ?? [], + }; +} + +function jsonResponse(body: unknown, init?: { status?: number; etag?: string }) { + const headers: Record = { "content-type": "application/json" }; + if (init?.etag) headers["ETag"] = init.etag; + return new Response(JSON.stringify(body), { status: init?.status ?? 200, headers }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("fetchGitHubStatus", () => { + beforeEach(async () => { + await clearCache(); + resetGitHubStatusState(); + mockPushNotification.mockClear(); + mockDismissNotificationBySource.mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("parses a successful 200 response into a GitHubStatusSummary", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary()))); + + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result).not.toBeNull(); + expect(result!.severity).toBe("none"); + expect(result!.incidents).toEqual([]); + expect(result!.fetchedAt).toBeInstanceOf(Date); + }); + + it("blends to critical severity when Actions has a major_outage and other tracked components are operational, and notifies", async () => { + const components = [...TRACKED_COMPONENT_NAMES].map((n) => + makeComponent(n, n === "Actions" ? "major_outage" : "operational") + ); + const incidents = [makeIncident({ id: "inc-1", name: "Actions Outage", body: "Investigating", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ components, incidents })))); + + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result!.severity).toBe("critical"); + expect(result!.incidents).toHaveLength(1); + expect(mockPushNotification).toHaveBeenCalledWith("github-status", "Actions Outage", "error", false); + }); + + it("returns severity none with no incidents when all tracked components are operational", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary()))); + + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result!.severity).toBe("none"); + expect(result!.incidents).toEqual([]); + }); + + it("excludes an incident whose only affected component is untracked (Copilot)", async () => { + const incidents = [makeIncident({ id: "inc-copilot", name: "Copilot Degraded", body: "Investigating", componentNames: ["Copilot"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result!.incidents).toEqual([]); + expect(result!.severity).toBe("none"); + expect(mockPushNotification).not.toHaveBeenCalledWith("github-status", expect.anything(), expect.anything(), expect.anything()); + }); + + it("severity isolation: an untracked component's major_outage does not blend into severity", async () => { + const components = [ + ...[...TRACKED_COMPONENT_NAMES].map((n) => makeComponent(n, "operational")), + makeComponent("Pages", "major_outage"), + ]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ components, incidents: [] })))); + + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result!.severity).toBe("none"); + expect(result!.incidents).toEqual([]); + }); + + it("treats an unrecognized component status as none severity without throwing", async () => { + const components = [...TRACKED_COMPONENT_NAMES].map((n) => + makeComponent(n, n === "Actions" ? "under_maintenance" : "operational") + ); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ components })))); + + await expect(fetchGitHubStatus()).resolves.toBeUndefined(); + + const result = getGitHubStatus(); + expect(result!.severity).toBe("none"); + }); + + it("floors severity at minor when an incident is still open but its tracked components blend to operational (CR-001)", async () => { + // Statuspage's "Monitoring" phase: component status resets to operational + // while the incident itself remains open and still affects a tracked + // component — severity must not fall back to "none" in that window. + const incidents = [makeIncident({ + id: "monitoring-1", + name: "Actions Degradation", + body: "We are continuing to monitor.", + componentNames: ["Actions"], + componentStatus: "operational", + })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result!.severity).toBe("minor"); + expect(result!.incidents).toHaveLength(1); + }); + + it("dismisses the github-status notification and pushes a resolved notification when an incident clears", async () => { + const incidents = [makeIncident({ id: "abc", name: "Actions Outage", body: "Investigating", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + await fetchGitHubStatus(); + + mockPushNotification.mockClear(); + mockDismissNotificationBySource.mockClear(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents: [] })))); + await fetchGitHubStatus(); + + expect(mockDismissNotificationBySource).toHaveBeenCalledWith("github-status"); + expect(mockPushNotification).toHaveBeenCalledWith("github-status-resolved", "Actions Outage", "info", false); + }); + + it("two consecutive resolutions with different incident names each produce a distinct notification", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ + incidents: [makeIncident({ id: "a", name: "Incident A", body: "x", componentNames: ["Actions"] })], + })))); + await fetchGitHubStatus(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ + incidents: [makeIncident({ id: "b", name: "Incident B", body: "y", componentNames: ["Issues"] })], + })))); + mockPushNotification.mockClear(); + await fetchGitHubStatus(); + expect(mockPushNotification).toHaveBeenCalledWith("github-status-resolved", "Incident A", "info", false); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents: [] })))); + mockPushNotification.mockClear(); + await fetchGitHubStatus(); + expect(mockPushNotification).toHaveBeenCalledWith("github-status-resolved", "Incident B", "info", false); + }); + + it("strips HTML tags from the latest update body", async () => { + const incidents = [makeIncident({ + id: "abc", + name: "Actions Outage", + body: "Update - We are continuing to investigate.

", + componentNames: ["Actions"], + })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result!.incidents[0].latestUpdateBody).not.toContain(" { + const incident = { + ...makeIncident({ id: "empty-1", name: "No Updates Yet", body: "unused", componentNames: ["Actions"] }), + incident_updates: [] as { body: string }[], + }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents: [incident] })))); + + await expect(fetchGitHubStatus()).resolves.toBeUndefined(); + + const result = getGitHubStatus(); + expect(result!.incidents).toHaveLength(1); + expect(result!.incidents[0].latestUpdateBody).toBe(""); + }); + + it("pins the raw, unescaped incident name through to pushNotification (toast/drawer escaping boundary)", async () => { + const xssName = ""; + const incidents = [makeIncident({ id: "xss-1", name: xssName, body: "Investigating", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + + await fetchGitHubStatus(); + + expect(mockPushNotification).toHaveBeenCalledWith("github-status", xssName, expect.any(String), false); + }); + + it("sends credentials: omit and cache: no-store on the outgoing fetch", async () => { + const mockFetch = vi.fn().mockResolvedValue(jsonResponse(makeSummary())); + vi.stubGlobal("fetch", mockFetch); + + await fetchGitHubStatus(); + + expect(mockFetch).toHaveBeenCalledWith( + "https://www.githubstatus.com/api/v2/summary.json", + expect.objectContaining({ credentials: "omit", cache: "no-store" }) + ); + }); + + it("includes an AbortSignal (from AbortSignal.timeout) on the outgoing fetch call", async () => { + const mockFetch = vi.fn().mockResolvedValue(jsonResponse(makeSummary())); + vi.stubGlobal("fetch", mockFetch); + + await fetchGitHubStatus(); + + const options = mockFetch.mock.calls[0]?.[1] as RequestInit; + expect(options.signal).toBeInstanceOf(AbortSignal); + }); + + it("is a no-op when called again while a prior call's fetch is still in flight", async () => { + let resolveFetch!: (value: Response) => void; + const pending = new Promise((resolve) => { + resolveFetch = resolve; + }); + const mockFetch = vi.fn().mockReturnValue(pending); + vi.stubGlobal("fetch", mockFetch); + + const first = fetchGitHubStatus(); + const second = fetchGitHubStatus(); + + resolveFetch(jsonResponse(makeSummary())); + await Promise.all([first, second]); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("304 cache-hit path: reflects parseSummary run against the cached JSON, not empty/null", async () => { + const incidents = [makeIncident({ id: "cached-1", name: "Cached Incident", body: "Investigating", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents }), { etag: "etag-1" }))); + await fetchGitHubStatus(); + expect(getGitHubStatus()!.incidents).toHaveLength(1); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 304 }))); + await fetchGitHubStatus(); + + const result = getGitHubStatus(); + expect(result!.incidents).toHaveLength(1); + expect(result!.incidents[0].name).toBe("Cached Incident"); + }); + + it("keeps the prior value on a single fetch failure and does not dismiss the notification (CR-002)", async () => { + const incidents = [makeIncident({ id: "x", name: "X", body: "y", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + await fetchGitHubStatus(); + const before = getGitHubStatus(); + + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + mockDismissNotificationBySource.mockClear(); + mockPushNotification.mockClear(); + await expect(fetchGitHubStatus()).resolves.toBeUndefined(); + + expect(getGitHubStatus()).toEqual(before); + expect(mockDismissNotificationBySource).not.toHaveBeenCalled(); + + // A subsequent successful poll with the same unchanged incident must not + // look like a fresh announcement — it should be pushed with the exact same + // source+message as before, which the real errors.ts dedup treats as a no-op. + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + await fetchGitHubStatus(); + expect(mockPushNotification).toHaveBeenCalledTimes(1); + expect(mockPushNotification).toHaveBeenCalledWith("github-status", "X", "warning", false); + }); + + it("dismisses the notification only after 3 consecutive fetch failures (CR-002)", async () => { + const incidents = [makeIncident({ id: "x", name: "X", body: "y", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + await fetchGitHubStatus(); + + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + mockDismissNotificationBySource.mockClear(); + + await fetchGitHubStatus(); // failure 1 + expect(mockDismissNotificationBySource).not.toHaveBeenCalled(); + await fetchGitHubStatus(); // failure 2 + expect(mockDismissNotificationBySource).not.toHaveBeenCalled(); + await fetchGitHubStatus(); // failure 3 + expect(mockDismissNotificationBySource).toHaveBeenCalledWith("github-status"); + }); + + it("resets the consecutive-failure counter after a successful fetch (CR-002)", async () => { + const incidents = [makeIncident({ id: "x", name: "X", body: "y", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + await fetchGitHubStatus(); + + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + await fetchGitHubStatus(); // failure 1 + await fetchGitHubStatus(); // failure 2 + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + await fetchGitHubStatus(); // success — must reset the counter + + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + mockDismissNotificationBySource.mockClear(); + await fetchGitHubStatus(); // failure 1 (post-reset) + await fetchGitHubStatus(); // failure 2 (post-reset) + + // If the counter hadn't reset, this would be the 4th consecutive failure + // overall and would already have crossed the threshold. + expect(mockDismissNotificationBySource).not.toHaveBeenCalled(); + }); + + it("first call with no successful fetch yet leaves getGitHubStatus() at null on a network error", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + + await fetchGitHubStatus(); + + expect(getGitHubStatus()).toBeNull(); + }); + + it("logs a schema-drift warning without throwing, and does not dismiss the notification on a single failure", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ components: "not-an-array", incidents: [] }))); + + await expect(fetchGitHubStatus()).resolves.toBeUndefined(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("schema drift"), expect.anything()); + expect(mockDismissNotificationBySource).not.toHaveBeenCalled(); + expect(getGitHubStatus()).toBeNull(); + }); + + it("keeps the prior value on a single schema-drift failure and does not dismiss the notification", async () => { + const incidents = [makeIncident({ id: "x", name: "X", body: "y", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + await fetchGitHubStatus(); + const before = getGitHubStatus(); + + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ components: "not-an-array", incidents: [] }))); + mockDismissNotificationBySource.mockClear(); + + await expect(fetchGitHubStatus()).resolves.toBeUndefined(); + + expect(getGitHubStatus()).toEqual(before); + expect(mockDismissNotificationBySource).not.toHaveBeenCalled(); + }); + + it("dismisses the notification after 3 consecutive schema-drift failures, sharing the counter with network failures", async () => { + const incidents = [makeIncident({ id: "x", name: "X", body: "y", componentNames: ["Actions"] })]; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary({ incidents })))); + await fetchGitHubStatus(); + + vi.spyOn(console, "warn").mockImplementation(() => {}); + const badResponse = () => jsonResponse({ components: "not-an-array", incidents: [] }); + mockDismissNotificationBySource.mockClear(); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(badResponse())); + await fetchGitHubStatus(); // schema failure 1 + expect(mockDismissNotificationBySource).not.toHaveBeenCalled(); + + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + await fetchGitHubStatus(); // network failure 2 — shares the same counter + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(badResponse())); + await fetchGitHubStatus(); // schema failure 3 + expect(mockDismissNotificationBySource).toHaveBeenCalledWith("github-status"); + }); + + it("resetGitHubStatusState clears the current status back to null", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(makeSummary()))); + await fetchGitHubStatus(); + expect(getGitHubStatus()).not.toBeNull(); + + resetGitHubStatusState(); + + expect(getGitHubStatus()).toBeNull(); + }); +}); diff --git a/tests/services/poll.test.ts b/tests/services/poll.test.ts index 199e38cb..3c5779cb 100644 --- a/tests/services/poll.test.ts +++ b/tests/services/poll.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createRoot, createSignal } from "solid-js"; import { createPollCoordinator, type DashboardData } from "../../src/app/services/poll"; import * as githubMod from "../../src/app/services/github"; +import * as githubStatusMod from "../../src/app/services/github-status"; // Mock pushError so we can spy on it const mockPushError = vi.fn(); @@ -41,6 +42,12 @@ vi.mock("../../src/app/services/github", () => ({ initClientWatcher: vi.fn(), })); +// Mock github-status module — fetchGitHubStatus runs concurrently (fire-and-forget) +// in doFetch, same pattern as fetchRateLimitDetails above. +vi.mock("../../src/app/services/github-status", () => ({ + fetchGitHubStatus: vi.fn(() => Promise.resolve()), +})); + // Mock config so doFetch doesn't fail when accessing config.selectedRepos vi.mock("../../src/app/stores/config", () => ({ config: { @@ -601,4 +608,38 @@ describe("createPollCoordinator", () => { dispose(); }); }); + + it("fetchGitHubStatus is called once on initial mount", async () => { + const fetchGitHubStatusSpy = vi.mocked(githubStatusMod.fetchGitHubStatus); + fetchGitHubStatusSpy.mockClear(); + + const fetchAll = makeFetchAll(); + + await createRoot(async (dispose) => { + createPollCoordinator(makeGetInterval(0), fetchAll); + await flushPromises(); + + expect(fetchGitHubStatusSpy).toHaveBeenCalledTimes(1); + dispose(); + }); + }); + + it("manualRefresh() triggers another fetchGitHubStatus call", async () => { + const fetchGitHubStatusSpy = vi.mocked(githubStatusMod.fetchGitHubStatus); + fetchGitHubStatusSpy.mockClear(); + + const fetchAll = makeFetchAll(); + + await createRoot(async (dispose) => { + const coordinator = createPollCoordinator(makeGetInterval(0), fetchAll); + await flushPromises(); + expect(fetchGitHubStatusSpy).toHaveBeenCalledTimes(1); + + coordinator.manualRefresh(); + await flushPromises(); + + expect(fetchGitHubStatusSpy).toHaveBeenCalledTimes(2); + dispose(); + }); + }); }); diff --git a/vitest.smoke.config.ts b/vitest.smoke.config.ts new file mode 100644 index 00000000..48dd24af --- /dev/null +++ b/vitest.smoke.config.ts @@ -0,0 +1,28 @@ +import { defineConfig, mergeConfig, type UserConfig } from "vitest/config"; +import baseConfig from "./vitest.config"; + +// Standalone config for live-network smoke tests (e.g. github-status.smoke.test.ts). +// These are deliberately excluded from vitest.workspace.ts's "browser" project +// (see its `exclude: [..., "tests/**/*.smoke.test.ts"]`) so `pnpm test` never hits +// the network — vitest.workspace.ts's exclude takes precedence over a CLI file +// filter, so re-including the same file via `vitest run ` against that +// config is not possible. Run smoke tests explicitly via `pnpm test:status-smoke`. +// +// Extends the root vitest.config.ts to inherit plugins/environment/globals/ +// setupFiles instead of duplicating them. `include` is assigned directly on +// the merged result rather than passed into mergeConfig: Vite's mergeConfig +// concatenates array values instead of replacing them, so merging an +// `include` override here would append to vitest.config.ts's patterns +// instead of overriding them, and this config would start running the +// regular unit test suite too. +const merged = mergeConfig( + baseConfig, + defineConfig({ + test: { + name: "status-smoke", + }, + }), +) as UserConfig; +merged.test = { ...merged.test, include: ["tests/**/*.smoke.test.ts"] }; + +export default merged; diff --git a/vitest.workspace.ts b/vitest.workspace.ts index b809d1ba..e5a9e365 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -18,7 +18,7 @@ export default defineConfig({ hookTimeout: 30_000, setupFiles: ["tests/setup.ts"], include: ["tests/**/*.test.ts", "tests/**/*.test.tsx", "tests/**/*.steps.tsx"], - exclude: ["tests/worker/**"], + exclude: ["tests/worker/**", "tests/**/*.smoke.test.ts"], }, }), // Cloudflare Worker tests