From 4c2935fb81f4667ec3dd577f9294e16c5e80c77f Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 29 Apr 2026 19:48:14 +0000 Subject: [PATCH] feat: add uptime integration scenarios --- .gitignore | 3 + package.json | 17 ++ readme.md | 21 +++ scenarios/uptimerunner-basic/readme.md | 9 + scenarios/uptimerunner-basic/scenario.ts | 203 +++++++++++++++++++++++ tsconfig.json | 13 ++ 6 files changed, 266 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 readme.md create mode 100644 scenarios/uptimerunner-basic/readme.md create mode 100644 scenarios/uptimerunner-basic/scenario.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..708510a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.nogit/ +node_modules/ +pnpm-lock.yaml diff --git a/package.json b/package.json new file mode 100644 index 0000000..6e388ed --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "@uptime.link/testing", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Whole-system integration scenarios for uptime.link components.", + "scripts": { + "bootstrap:components": "deno cache --config ../uptimerunner/deno.json ../uptimerunner/mod.ts && pnpm install", + "test": "pnpm scenario:uptimerunner-basic", + "scenario:uptimerunner-basic": "deno run --allow-all --sloppy-imports --config ../uptimerunner/deno.json scenarios/uptimerunner-basic/scenario.ts" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3" + }, + "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34" +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..c120e68 --- /dev/null +++ b/readme.md @@ -0,0 +1,21 @@ +# uptime.link Testing + +Whole-system integration scenarios for uptime.link components. + +Fast unit tests stay in each component repo. This repo is for stateful +cross-component scenarios that verify package boundaries and real interaction +flows. + +## Scenarios + +- `uptimerunner-basic`: starts a fake uptime.link runner coordinator plus a + target HTTP service, runs `uptimerunner` once, verifies the runner fetches + assigned checks, executes them from the local machine, and reports results + back. + +## Run + +```bash +pnpm bootstrap:components +pnpm test +``` diff --git a/scenarios/uptimerunner-basic/readme.md b/scenarios/uptimerunner-basic/readme.md new file mode 100644 index 0000000..fe88918 --- /dev/null +++ b/scenarios/uptimerunner-basic/readme.md @@ -0,0 +1,9 @@ +# uptimerunner-basic + +This scenario verifies the first concrete uptime.link runner flow: + +1. A target HTTP service responds with `healthy`. +2. A fake uptime.link coordinator exposes the runner protocol. +3. The runner polls `/api/runner/v1/checks`. +4. The runner executes the assigned HTTP check locally. +5. The runner posts the result to `/api/runner/v1/results`. diff --git a/scenarios/uptimerunner-basic/scenario.ts b/scenarios/uptimerunner-basic/scenario.ts new file mode 100644 index 0000000..663fe59 --- /dev/null +++ b/scenarios/uptimerunner-basic/scenario.ts @@ -0,0 +1,203 @@ +import { assert, assertEquals, assertRejects } from "jsr:@std/assert@^1.0.0"; + +import { RunnerCoordinator } from "../../../uptime.link/ts_api/classes/runner-coordinator.ts"; +import { RunnerFileStore } from "../../../uptime.link/ts_api/classes/runner-file-store.ts"; +import { RunnerHttpHandler } from "../../../uptime.link/ts_api/classes/runner-http-handler.ts"; +import { UptimeRunner } from "../../../uptimerunner/ts/runner.ts"; +import type { + IResultSubmitRequest, + TCheckJob, +} from "../../../uptimerunner/ts/interfaces.ts"; + +const scenarioName = "uptimerunner-basic"; +const runnerId = `scenario-runner-${Date.now().toString(36)}`; +const runnerToken = "scenario-token"; + +const main = async () => { + const targetServer = await startServer(() => new Response("healthy")); + const tcpServer = startTcpServer(); + const resultSubmissions: IResultSubmitRequest[] = []; + const checkQueue: TCheckJob[] = [ + { + id: "local-http-health", + type: "http", + url: targetServer.url, + expectedStatusCodes: [200], + expectedBodyIncludes: "healthy", + metadata: { + scenario: scenarioName, + }, + }, + { + id: "local-tcp-health", + type: "tcp", + host: "127.0.0.1", + port: tcpServer.port, + }, + { + id: "manual-assumption", + type: "assumption", + assumedStatus: "ok", + message: "manual assumption is healthy", + }, + ]; + + const coordinator = new RunnerCoordinator({ + runners: [{ runnerId, token: runnerToken, labels: ["scenario:basic"] }], + }); + for (const check of checkQueue) { + coordinator.enqueueCheck(check); + } + const runnerHttpHandler = new RunnerHttpHandler({ coordinator }); + + const coordinatorServer = await startServer(async (request) => { + if ( + request.method === "POST" && + new URL(request.url).pathname === "/api/runner/v1/results" + ) { + resultSubmissions.push(await request.clone().json()); + } + return await runnerHttpHandler.handleRequest(request); + }); + + try { + const runner = new UptimeRunner({ + instanceUrl: coordinatorServer.url, + runnerId, + token: runnerToken, + pollIntervalMs: 1000, + }); + + console.log(`[${scenarioName}] Running single runner iteration`); + const result = await runner.runOnce(); + + assertEquals(result.checks.length, 3); + assertEquals(result.results.length, 3); + assertEquals(result.results[0].checkId, "local-http-health"); + assertEquals(result.results[0].status, "ok"); + assertEquals(result.results[1].checkId, "local-tcp-health"); + assertEquals(result.results[1].status, "ok"); + assertEquals(result.results[2].checkId, "manual-assumption"); + assertEquals(result.results[2].status, "ok"); + assertEquals(resultSubmissions.length, 1); + assertEquals(resultSubmissions[0].runnerId, runnerId); + assertEquals(resultSubmissions[0].results[0].status, "ok"); + assert(resultSubmissions[0].results[0].responseTime !== undefined); + assertEquals(coordinator.listResults().length, 3); + assertEquals(coordinator.getQueueLength(), 0); + await assertSnapshotPersistence(coordinator); + + const emptyResult = await runner.runOnce(); + assertEquals(emptyResult.checks.length, 0); + assertEquals(emptyResult.results.length, 0); + assertEquals(resultSubmissions.length, 1); + + const unauthorizedRunner = new UptimeRunner({ + instanceUrl: coordinatorServer.url, + runnerId, + token: "wrong-token", + pollIntervalMs: 1000, + }); + await assertRejects(() => unauthorizedRunner.runOnce(), Error, "401"); + + console.log(`[${scenarioName}] Passed`); + } finally { + await coordinatorServer.server.shutdown(); + await targetServer.server.shutdown(); + tcpServer.close(); + } +}; + +async function startServer( + handlerArg: Deno.ServeHandler, +): Promise<{ server: Deno.HttpServer; url: string }> { + let resolveListening: (addr: Deno.NetAddr) => void; + const listening = new Promise((resolve) => { + resolveListening = resolve; + }); + + const server = Deno.serve({ + hostname: "127.0.0.1", + port: 0, + onListen: (addr) => resolveListening(addr), + }, handlerArg); + const addr = await listening; + return { + server, + url: `http://${addr.hostname}:${addr.port}`, + }; +} + +function startTcpServer(): { port: number; close: () => void } { + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + let closed = false; + + const acceptLoop = async () => { + while (!closed) { + try { + const connection = await listener.accept(); + connection.close(); + } catch (error) { + if (!closed) { + throw error; + } + } + } + }; + + acceptLoop(); + + return { + port, + close: () => { + closed = true; + listener.close(); + }, + }; +} + +async function assertSnapshotPersistence( + coordinatorArg: RunnerCoordinator, +): Promise { + const snapshotPath = await Deno.makeTempFile(); + const store = new RunnerFileStore({ + readTextFile: async () => { + try { + return await Deno.readTextFile(snapshotPath); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return undefined; + } + throw error; + } + }, + writeTextFile: async (contentArg) => { + await Deno.writeTextFile(snapshotPath, contentArg); + }, + }); + + try { + await store.save(coordinatorArg.getSnapshot()); + const snapshot = await store.load(); + assert(snapshot); + const restoredCoordinator = new RunnerCoordinator({ snapshot }); + assertEquals(restoredCoordinator.listResults().length, 3); + assertEquals(restoredCoordinator.listRunners().length, 1); + } finally { + await Deno.remove(snapshotPath).catch(() => null); + } +} + +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error( + `[${scenarioName}] Failed: ${ + error instanceof Error ? error.stack : String(error) + }`, + ); + Deno.exit(1); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0e69e9b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": ["node"], + "noEmit": true + }, + "include": ["scenarios/**/*.ts"] +}