From 060d4993d38b7eabe980ba8486d5323641b4fa1b Mon Sep 17 00:00:00 2001 From: Nicholas Adamou <10106289+nicholasadamou@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:31:27 -0400 Subject: [PATCH] test: web component tests, opt-in e2e, OpenAPI drift guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quality & DX batch: - web: tests for App (auth gate, launch, pull), SessionList (open/extend/ copy/terminate/countdown), and SessionViewer (stream, password, reload); CopyLinkButton now clears its reset timer on unmount - api: opt-in real-engine e2e (e2e.docker.test.ts, gated on AIRLOCK_E2E=1 so CI skips it) covering ping → create → list → extend → stop - api: openapi-sync.test.ts guards docs/openapi.yaml against drift in the browser catalog, TTL bounds, and route table - docs: development.md describes the e2e and drift guard --- apps/api/package.json | 3 +- apps/api/src/__tests__/e2e.docker.test.ts | 65 ++++++++++++ apps/api/src/__tests__/openapi-sync.test.ts | 61 ++++++++++++ apps/web/src/App.test.tsx | 98 +++++++++++++++++++ apps/web/src/components/SessionList.test.tsx | 98 +++++++++++++++++++ apps/web/src/components/SessionList.tsx | 8 +- .../web/src/components/SessionViewer.test.tsx | 44 +++++++++ bun.lock | 3 + docs/development.md | 30 ++++-- 9 files changed, 400 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/__tests__/e2e.docker.test.ts create mode 100644 apps/api/src/__tests__/openapi-sync.test.ts create mode 100644 apps/web/src/App.test.tsx create mode 100644 apps/web/src/components/SessionList.test.tsx create mode 100644 apps/web/src/components/SessionViewer.test.tsx diff --git a/apps/api/package.json b/apps/api/package.json index bc15ec8..b4179dc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -29,6 +29,7 @@ "@types/supertest": "^6.0.2", "supertest": "^7.0.0", "typescript": "^5.6.3", - "vitest": "^2.1.8" + "vitest": "^2.1.8", + "yaml": "^2.6.1" } } diff --git a/apps/api/src/__tests__/e2e.docker.test.ts b/apps/api/src/__tests__/e2e.docker.test.ts new file mode 100644 index 0000000..5f689a8 --- /dev/null +++ b/apps/api/src/__tests__/e2e.docker.test.ts @@ -0,0 +1,65 @@ +import { afterAll, describe, expect, it } from "vitest"; +import { loadConfig } from "../config"; +import { DockerSessionRuntime } from "../docker-session-runtime"; + +// Opt-in end-to-end test against a REAL Docker engine. Skipped unless +// AIRLOCK_E2E=1, so normal CI never runs it. It drives the full lifecycle: +// ping -> pull -> create -> list -> extend -> stop +// +// Run it with a reachable engine and (optionally) a pre-pulled image: +// AIRLOCK_E2E=1 AIRLOCK_IMAGE_CHROMIUM=kasmweb/chromium:1.18.0 \ +// bun run --filter @airlock/api test +// +// The first run pulls the (large) Kasm image, so the create test gets a +// generous timeout. Point AIRLOCK_IMAGE_CHROMIUM at a pre-pulled tag to skip +// the download. +const e2eEnabled = process.env.AIRLOCK_E2E === "1"; + +describe.runIf(e2eEnabled)("DockerSessionRuntime e2e (real engine)", () => { + const config = loadConfig({ + ...process.env, + // Keep sessions short; the test stops them explicitly anyway. + AIRLOCK_DEFAULT_TTL_SECONDS: "60", + // Isolation network adds engine state; leave it on to exercise the path. + AIRLOCK_NETWORK_ISOLATION: process.env.AIRLOCK_NETWORK_ISOLATION ?? "true" + } as NodeJS.ProcessEnv); + const runtime = new DockerSessionRuntime({ config }); + let createdSessionId: string | null = null; + + afterAll(async () => { + if (createdSessionId) { + await runtime.stopSession(createdSessionId).catch(() => undefined); + } + }); + + it("reaches the engine", async () => { + expect(await runtime.ping()).toBe(true); + }); + + it("creates, lists, extends, and stops a session", async () => { + const session = await runtime.createSession({ + browser: "chromium", + targetUrl: "https://example.com", + ttlSeconds: 60 + }); + createdSessionId = session.sessionId; + + expect(session.sessionId).toMatch(/[0-9a-f-]{36}/); + expect(session.browserUrl).toMatch(/^https:\/\//); + expect(session.vncPassword.length).toBeGreaterThan(0); + + const listed = await runtime.listSessions(); + expect(listed.some((s) => s.sessionId === session.sessionId)).toBe(true); + + const extended = await runtime.extendSession(session.sessionId, 120); + expect(extended).not.toBeNull(); + expect(new Date(extended!.expiresAt).getTime()).toBeGreaterThan( + new Date(session.expiresAt).getTime() + ); + + expect(await runtime.stopSession(session.sessionId)).toBe(true); + createdSessionId = null; + const afterStop = await runtime.getSession(session.sessionId); + expect(afterStop).toBeNull(); + }, 300_000); +}); diff --git a/apps/api/src/__tests__/openapi-sync.test.ts b/apps/api/src/__tests__/openapi-sync.test.ts new file mode 100644 index 0000000..09040d7 --- /dev/null +++ b/apps/api/src/__tests__/openapi-sync.test.ts @@ -0,0 +1,61 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { BROWSER_KINDS, TTL_MAX_SECONDS, TTL_MIN_SECONDS } from "@airlock/shared"; +import { parse } from "yaml"; +import { describe, expect, it } from "vitest"; + +// Guards docs/openapi.yaml against silent drift from the code's actual +// contracts. It is not a full generator, but it pins the dynamic surfaces +// (browser catalog, TTL bounds, route table) that are easiest to forget. +const specPath = path.resolve(process.cwd(), "../../docs/openapi.yaml"); +const spec = parse(readFileSync(specPath, "utf8")) as { + paths: Record>; + components: { + schemas: { + BrowserKind: { enum: string[] }; + CreateSessionRequest: { properties: { ttlSeconds: { minimum: number; maximum: number } } }; + ExtendSessionRequest: { properties: { ttlSeconds: { minimum: number; maximum: number } } }; + }; + }; +}; + +describe("openapi.yaml stays in sync with the code", () => { + it("lists exactly the supported browser kinds", () => { + expect([...spec.components.schemas.BrowserKind.enum].sort()).toEqual([...BROWSER_KINDS].sort()); + }); + + it("uses the shared TTL bounds on the create and extend bodies", () => { + for (const schema of ["CreateSessionRequest", "ExtendSessionRequest"] as const) { + const ttl = spec.components.schemas[schema].properties.ttlSeconds; + expect(ttl.minimum).toBe(TTL_MIN_SECONDS); + expect(ttl.maximum).toBe(TTL_MAX_SECONDS); + } + }); + + it("documents every implemented route", () => { + const expectedPaths = [ + "/healthz", + "/health", + "/readyz", + "/metrics", + "/api/meta", + "/api/sessions", + "/api/sessions/{sessionId}", + "/api/images/pull", + "/api/internal/prune", + "/s/{sessionId}" + ]; + for (const route of expectedPaths) { + expect(spec.paths).toHaveProperty([route]); + } + }); + + it("documents the methods on the session collection and item paths", () => { + expect(Object.keys(spec.paths["/api/sessions"])).toEqual( + expect.arrayContaining(["get", "post"]) + ); + expect(Object.keys(spec.paths["/api/sessions/{sessionId}"])).toEqual( + expect.arrayContaining(["get", "patch", "delete"]) + ); + }); +}); diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx new file mode 100644 index 0000000..719490b --- /dev/null +++ b/apps/web/src/App.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the client factory but keep the real AirlockApiError for instanceof. +vi.mock("./lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createAirlockClient: vi.fn() }; +}); + +import { App } from "./App"; +import { AirlockApiError, AirlockClient, createAirlockClient } from "./lib/api"; + +const meta = { + browsers: ["chromium", "firefox"], + defaultBrowser: "chromium", + defaultTtlSeconds: 1800, + ttlMinSeconds: 60, + ttlMaxSeconds: 86400 +}; + +const makeClient = (overrides: Partial = {}): AirlockClient => ({ + getMeta: vi.fn().mockResolvedValue(meta), + listSessions: vi.fn().mockResolvedValue([]), + createSession: vi.fn(), + extendSession: vi.fn(), + stopSession: vi.fn(), + pullImages: vi.fn().mockResolvedValue([]), + ...overrides +}); + +const useClient = (client: AirlockClient): void => { + vi.mocked(createAirlockClient).mockReturnValue(client); +}; + +beforeEach(() => { + // jsdom in this config may not expose localStorage; token-storage tolerates + // that, so the test just guards the cleanup. + try { + window.localStorage?.clear(); + } catch { + // ignore + } + vi.mocked(createAirlockClient).mockReset(); +}); + +describe("App", () => { + it("shows the dashboard when the token is accepted", async () => { + useClient(makeClient()); + render(); + expect(await screen.findByText("Launch a disposable browser")).toBeTruthy(); + expect(screen.getByText("Active sessions")).toBeTruthy(); + }); + + it("shows the login screen on a 401", async () => { + useClient( + makeClient({ + getMeta: vi.fn().mockRejectedValue(new AirlockApiError("Unauthorized.", 401)) + }) + ); + render(); + expect(await screen.findByRole("button", { name: "Connect" })).toBeTruthy(); + }); + + it("launches a session through the client", async () => { + const createSession = vi.fn().mockResolvedValue({ + sessionId: "new-1", + browser: "chromium", + targetUrl: "https://example.com", + browserUrl: "https://localhost:32792", + sessionUrl: "http://localhost:8787/s/new-1", + vncPassword: "pw", + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 600_000).toISOString() + }); + useClient(makeClient({ createSession })); + render(); + + fireEvent.change(await screen.findByLabelText("URL to open"), { + target: { value: "https://example.com" } + }); + fireEvent.click(screen.getByRole("button", { name: "Launch session" })); + + await waitFor(() => + expect(createSession).toHaveBeenCalledWith( + expect.objectContaining({ targetUrl: "https://example.com" }) + ) + ); + }); + + it("pulls images from the warm action", async () => { + const pullImages = vi.fn().mockResolvedValue([{ image: "kasmweb/chromium:1.18.0", ok: true }]); + useClient(makeClient({ pullImages })); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Pre-pull browser images" })); + await waitFor(() => expect(pullImages).toHaveBeenCalled()); + }); +}); diff --git a/apps/web/src/components/SessionList.test.tsx b/apps/web/src/components/SessionList.test.tsx new file mode 100644 index 0000000..b48f906 --- /dev/null +++ b/apps/web/src/components/SessionList.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SessionResponse } from "../lib/api"; +import { SessionList } from "./SessionList"; + +const session = (overrides: Partial = {}): SessionResponse => ({ + sessionId: "s-1", + browser: "chromium", + targetUrl: "https://example.com", + browserUrl: "https://localhost:32792", + sessionUrl: "http://localhost:8787/s/s-1", + vncPassword: "pw", + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 600_000).toISOString(), + ...overrides +}); + +const noop = () => undefined; + +describe("SessionList", () => { + it("shows the empty state with no sessions", () => { + render( + + ); + expect(screen.getByText(/No active sessions/i)).toBeTruthy(); + }); + + it("fires open, extend, and terminate handlers with the session id", () => { + const onOpen = vi.fn(); + const onExtend = vi.fn(); + const onTerminate = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open" })); + fireEvent.click(screen.getByRole("button", { name: "+15m" })); + fireEvent.click(screen.getByRole("button", { name: "Terminate" })); + + expect(onOpen).toHaveBeenCalledWith("s-1"); + expect(onExtend).toHaveBeenCalledWith("s-1", 900); + expect(onTerminate).toHaveBeenCalledWith("s-1"); + }); + + it("reflects in-progress states on the buttons", () => { + render( + + ); + expect((screen.getByRole("button", { name: "Ending…" }) as HTMLButtonElement).disabled).toBe( + true + ); + expect((screen.getByRole("button", { name: "Extending…" }) as HTMLButtonElement).disabled).toBe( + true + ); + }); + + it("copies the share link to the clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Copy link" })); + expect(writeText).toHaveBeenCalledWith("http://localhost:8787/s/s-1"); + // Await the async re-render to "Copied" so the state update is wrapped; the + // pending reset timer is cleared on unmount. + await screen.findByRole("button", { name: "Copied" }); + }); +}); diff --git a/apps/web/src/components/SessionList.tsx b/apps/web/src/components/SessionList.tsx index 287ff4c..76f4a15 100644 --- a/apps/web/src/components/SessionList.tsx +++ b/apps/web/src/components/SessionList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { SessionResponse } from "../lib/api"; import { formatTimeRemaining } from "../lib/time"; @@ -73,12 +73,16 @@ export const SessionList = ({ const CopyLinkButton = ({ url }: { url: string }): JSX.Element => { const [copied, setCopied] = useState(false); + const resetTimer = useRef(undefined); + + useEffect(() => () => window.clearTimeout(resetTimer.current), []); const copy = async (): Promise => { try { await navigator.clipboard.writeText(url); setCopied(true); - window.setTimeout(() => setCopied(false), 1500); + window.clearTimeout(resetTimer.current); + resetTimer.current = window.setTimeout(() => setCopied(false), 1500); } catch { // Clipboard access can be denied; leave the label unchanged. } diff --git a/apps/web/src/components/SessionViewer.test.tsx b/apps/web/src/components/SessionViewer.test.tsx new file mode 100644 index 0000000..083ceae --- /dev/null +++ b/apps/web/src/components/SessionViewer.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SessionResponse } from "../lib/api"; +import { SessionViewer } from "./SessionViewer"; + +const session: SessionResponse = { + sessionId: "s-1", + browser: "firefox", + targetUrl: "https://example.com", + browserUrl: "https://localhost:32792", + sessionUrl: "http://localhost:8787/s/s-1", + vncPassword: "hunter2", + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 600_000).toISOString() +}; + +describe("SessionViewer", () => { + it("embeds the stream and surfaces the per-session password", () => { + render( undefined} />); + + const frame = screen.getByTitle("Airlock session s-1") as HTMLIFrameElement; + expect(frame.getAttribute("src")).toBe("https://localhost:32792"); + expect(screen.getByText("hunter2")).toBeTruthy(); + expect(screen.getByRole("link", { name: /Open in new tab/ }).getAttribute("href")).toBe( + "https://localhost:32792" + ); + }); + + it("calls onClose from the Back button", () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /Back/ })); + expect(onClose).toHaveBeenCalled(); + }); + + it("remounts the iframe when Reload stream is clicked", () => { + render( undefined} />); + const before = screen.getByTitle("Airlock session s-1"); + fireEvent.click(screen.getByRole("button", { name: /Reload stream/ })); + const after = screen.getByTitle("Airlock session s-1"); + // A remount produces a new element instance for the same title. + expect(before).not.toBe(after); + }); +}); diff --git a/bun.lock b/bun.lock index c096f95..01c0133 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,7 @@ "supertest": "^7.0.0", "typescript": "^5.6.3", "vitest": "^2.1.8", + "yaml": "^2.6.1", }, }, "apps/web": { @@ -809,6 +810,8 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], diff --git a/docs/development.md b/docs/development.md index bedd7a5..8a2518c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -128,19 +128,35 @@ touches Docker directly. Tests run with Vitest. Two workspaces have real suites: -- **`apps/api`** — 56 tests across 11 files exercising the app and auth, the - Docker runtime through a `FakeDocker`, schemas, session policy, session - labels, container profile, the internal API, and session resolution. -- **`apps/web`** — 14 tests across `lib/api`, `lib/time`, and the - `LaunchForm` component, using `@testing-library/react` + `jsdom`. +- **`apps/api`** — the app and auth, the Docker runtime through a `FakeDocker` + (including resource limits, network isolation, per-session passwords, TTL + extension, and image pulls), the rate limiter, config defaults, schemas, + session policy/labels, container profile, the internal API, and an + `openapi.yaml` drift guard. +- **`apps/web`** — `lib/api`, `lib/time`, and the `App`, `LaunchForm`, + `SessionList`, and `SessionViewer` components, using + `@testing-library/react` + `jsdom`. `packages/shared` and `apps/worker` have no real test suites (their `test` script is a no-op). The key test seam is `SessionRuntime`: the API depends on the interface, and two adapters keep it honest — `DockerSessionRuntime` in production and -`FakeSessionRuntime` in tests — so the suite creates no real containers. See -[architecture.md](architecture.md#module-map). +`FakeSessionRuntime` in tests — so the default suite creates no real +containers. See [architecture.md](architecture.md#module-map). + +**Opt-in end-to-end.** `src/__tests__/e2e.docker.test.ts` drives a real Docker +engine through the full lifecycle (ping → pull → create → list → extend → +stop). It is skipped unless `AIRLOCK_E2E=1`, so normal CI never runs it: + +```bash +AIRLOCK_E2E=1 AIRLOCK_IMAGE_CHROMIUM=kasmweb/chromium:1.18.0 \ + bun run --filter @airlock/api test +``` + +**OpenAPI drift guard.** `openapi-sync.test.ts` asserts `docs/openapi.yaml` +stays aligned with the code's browser catalog, TTL bounds, and route table, so +the spec can't silently fall out of date. ## Building