feat: add uptime integration scenarios
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
.nogit/
|
||||||
|
node_modules/
|
||||||
|
pnpm-lock.yaml
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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`.
|
||||||
@@ -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<Deno.NetAddr>((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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["scenarios/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user