2026-04-29 19:48:14 +00:00
|
|
|
import { assert, assertEquals, assertRejects } from "jsr:@std/assert@^1.0.0";
|
|
|
|
|
|
2026-04-29 22:00:50 +00:00
|
|
|
import { RunnerAdmin } from "../../../uptime.link/ts_api/classes/runner-admin.ts";
|
2026-04-29 19:48:14 +00:00
|
|
|
import { RunnerCoordinator } from "../../../uptime.link/ts_api/classes/runner-coordinator.ts";
|
|
|
|
|
import { RunnerFileStore } from "../../../uptime.link/ts_api/classes/runner-file-store.ts";
|
2026-04-29 21:35:21 +00:00
|
|
|
import {
|
|
|
|
|
createRunnerSchedulesFromMonitors,
|
|
|
|
|
type IRunnerMonitorDefinition,
|
|
|
|
|
} from "../../../uptime.link/ts_api/classes/runner-monitor-mapper.ts";
|
2026-04-29 21:32:36 +00:00
|
|
|
import { createRunnerRequestHandler } from "../../../uptime.link/ts_api/classes/runner-request-handler.ts";
|
2026-04-29 21:35:21 +00:00
|
|
|
import { RunnerResultIngestor } from "../../../uptime.link/ts_api/classes/runner-result-ingestor.ts";
|
2026-04-29 21:32:36 +00:00
|
|
|
import { RunnerScheduler } from "../../../uptime.link/ts_api/classes/runner-scheduler.ts";
|
2026-04-29 19:48:14 +00:00
|
|
|
import { UptimeRunner } from "../../../uptimerunner/ts/runner.ts";
|
2026-04-29 21:35:21 +00:00
|
|
|
import type { IResultSubmitRequest } from "../../../uptimerunner/ts/interfaces.ts";
|
2026-04-29 19:48:14 +00:00
|
|
|
|
|
|
|
|
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[] = [];
|
2026-04-29 21:35:21 +00:00
|
|
|
const monitors: IRunnerMonitorDefinition[] = [
|
2026-04-29 19:48:14 +00:00
|
|
|
{
|
|
|
|
|
id: "local-http-health",
|
2026-04-29 21:35:21 +00:00
|
|
|
name: "Local HTTP Health",
|
|
|
|
|
intervalMs: 60000,
|
|
|
|
|
check: {
|
|
|
|
|
type: "http",
|
|
|
|
|
url: targetServer.url,
|
|
|
|
|
expectedStatusCodes: [200],
|
|
|
|
|
expectedBodyIncludes: "healthy",
|
2026-04-29 19:48:14 +00:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "local-tcp-health",
|
2026-04-29 21:35:21 +00:00
|
|
|
name: "Local TCP Health",
|
|
|
|
|
intervalMs: 60000,
|
|
|
|
|
check: {
|
|
|
|
|
type: "tcp",
|
|
|
|
|
host: "127.0.0.1",
|
|
|
|
|
port: tcpServer.port,
|
|
|
|
|
},
|
2026-04-29 19:48:14 +00:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "manual-assumption",
|
2026-04-29 21:35:21 +00:00
|
|
|
name: "Manual Assumption",
|
|
|
|
|
intervalMs: 60000,
|
|
|
|
|
check: {
|
|
|
|
|
type: "assumption",
|
|
|
|
|
assumedStatus: "ok",
|
|
|
|
|
message: "manual assumption is healthy",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "manual-degraded",
|
|
|
|
|
name: "Manual Degraded",
|
|
|
|
|
intervalMs: 60000,
|
|
|
|
|
check: {
|
|
|
|
|
type: "assumption",
|
|
|
|
|
assumedStatus: "not ok",
|
|
|
|
|
message: "manual assumption is degraded",
|
|
|
|
|
},
|
2026-04-29 19:48:14 +00:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
2026-04-29 22:00:50 +00:00
|
|
|
const coordinator = new RunnerCoordinator();
|
|
|
|
|
const admin = new RunnerAdmin(coordinator);
|
|
|
|
|
const registration = admin.registerRunner({
|
|
|
|
|
runnerId,
|
|
|
|
|
token: runnerToken,
|
|
|
|
|
labels: ["scenario:basic"],
|
2026-04-29 19:48:14 +00:00
|
|
|
});
|
2026-04-29 22:00:50 +00:00
|
|
|
assertEquals(registration.runner.runnerId, runnerId);
|
|
|
|
|
assertEquals(registration.token, runnerToken);
|
2026-04-29 21:32:36 +00:00
|
|
|
const scheduler = new RunnerScheduler(coordinator, { now: () => 1000 });
|
|
|
|
|
const scheduleResult = scheduler.scheduleDueChecks(
|
2026-04-29 21:35:21 +00:00
|
|
|
createRunnerSchedulesFromMonitors(monitors),
|
2026-04-29 21:32:36 +00:00
|
|
|
);
|
2026-04-29 21:35:21 +00:00
|
|
|
assertEquals(scheduleResult.scheduledChecks.length, 4);
|
|
|
|
|
assertEquals(coordinator.getQueueLength(), 4);
|
2026-04-29 21:32:36 +00:00
|
|
|
assertEquals(
|
|
|
|
|
scheduler.scheduleDueChecks(scheduleResult.schedules).scheduledChecks
|
|
|
|
|
.length,
|
|
|
|
|
0,
|
|
|
|
|
);
|
|
|
|
|
const runnerRequestHandler = createRunnerRequestHandler(coordinator);
|
2026-04-29 19:48:14 +00:00
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
}
|
2026-04-29 21:32:36 +00:00
|
|
|
return await runnerRequestHandler(request);
|
2026-04-29 19:48:14 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
2026-04-29 21:35:21 +00:00
|
|
|
assertEquals(result.checks.length, 4);
|
|
|
|
|
assertEquals(result.results.length, 4);
|
|
|
|
|
assertEquals(result.results[0].checkId, "monitor-local-http-health");
|
2026-04-29 19:48:14 +00:00
|
|
|
assertEquals(result.results[0].status, "ok");
|
2026-04-29 21:35:21 +00:00
|
|
|
assertEquals(result.results[1].checkId, "monitor-local-tcp-health");
|
2026-04-29 19:48:14 +00:00
|
|
|
assertEquals(result.results[1].status, "ok");
|
2026-04-29 21:35:21 +00:00
|
|
|
assertEquals(result.results[2].checkId, "monitor-manual-assumption");
|
2026-04-29 19:48:14 +00:00
|
|
|
assertEquals(result.results[2].status, "ok");
|
2026-04-29 21:35:21 +00:00
|
|
|
assertEquals(result.results[3].checkId, "monitor-manual-degraded");
|
|
|
|
|
assertEquals(result.results[3].status, "not ok");
|
2026-04-29 19:48:14 +00:00
|
|
|
assertEquals(resultSubmissions.length, 1);
|
|
|
|
|
assertEquals(resultSubmissions[0].runnerId, runnerId);
|
|
|
|
|
assertEquals(resultSubmissions[0].results[0].status, "ok");
|
|
|
|
|
assert(resultSubmissions[0].results[0].responseTime !== undefined);
|
2026-04-29 21:35:21 +00:00
|
|
|
assertEquals(coordinator.listResults().length, 4);
|
2026-04-29 19:48:14 +00:00
|
|
|
assertEquals(coordinator.getQueueLength(), 0);
|
2026-04-29 21:35:21 +00:00
|
|
|
assertMonitorStatusDerivation(coordinator.listResults());
|
2026-04-29 21:32:36 +00:00
|
|
|
await assertSnapshotPersistence(coordinator, targetServer.url);
|
2026-04-29 19:48:14 +00:00
|
|
|
|
|
|
|
|
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");
|
2026-04-29 22:00:50 +00:00
|
|
|
await assertRunnerTokenLifecycle(
|
|
|
|
|
admin,
|
|
|
|
|
coordinator,
|
|
|
|
|
coordinatorServer.url,
|
|
|
|
|
runner,
|
|
|
|
|
);
|
2026-04-29 19:48:14 +00:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 21:35:21 +00:00
|
|
|
function assertMonitorStatusDerivation(
|
|
|
|
|
results: IResultSubmitRequest["results"],
|
|
|
|
|
): void {
|
|
|
|
|
const ingestor = new RunnerResultIngestor();
|
|
|
|
|
const ingestion = ingestor.ingest(results);
|
|
|
|
|
assertEquals(ingestion.monitorStates.length, 4);
|
|
|
|
|
assertEquals(ingestion.ignoredResults.length, 0);
|
|
|
|
|
assertEquals(
|
|
|
|
|
ingestor.getMonitorState("local-http-health")?.status,
|
|
|
|
|
"operational",
|
|
|
|
|
);
|
|
|
|
|
assertEquals(
|
|
|
|
|
ingestor.getMonitorState("local-tcp-health")?.status,
|
|
|
|
|
"operational",
|
|
|
|
|
);
|
|
|
|
|
assertEquals(
|
|
|
|
|
ingestor.getMonitorState("manual-assumption")?.status,
|
|
|
|
|
"operational",
|
|
|
|
|
);
|
|
|
|
|
assertEquals(ingestor.getMonitorState("manual-degraded")?.status, "degraded");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 22:00:50 +00:00
|
|
|
async function assertRunnerTokenLifecycle(
|
|
|
|
|
adminArg: RunnerAdmin,
|
|
|
|
|
coordinatorArg: RunnerCoordinator,
|
|
|
|
|
instanceUrlArg: string,
|
|
|
|
|
staleRunnerArg: UptimeRunner,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const rotated = adminArg.rotateRunnerToken(runnerId, "rotated-token");
|
|
|
|
|
assertEquals(rotated.token, "rotated-token");
|
|
|
|
|
assertEquals(adminArg.listRunners()[0].tokenPreview, "rotate...oken");
|
|
|
|
|
|
|
|
|
|
coordinatorArg.enqueueCheck({
|
|
|
|
|
id: "post-rotate-assumption",
|
|
|
|
|
type: "assumption",
|
|
|
|
|
assumedStatus: "ok",
|
|
|
|
|
metadata: {
|
|
|
|
|
monitorId: "post-rotate",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await assertRejects(() => staleRunnerArg.runOnce(), Error, "401");
|
|
|
|
|
|
|
|
|
|
const rotatedRunner = new UptimeRunner({
|
|
|
|
|
instanceUrl: instanceUrlArg,
|
|
|
|
|
runnerId,
|
|
|
|
|
token: rotated.token,
|
|
|
|
|
});
|
|
|
|
|
const rotatedResult = await rotatedRunner.runOnce();
|
|
|
|
|
assertEquals(rotatedResult.results.length, 1);
|
|
|
|
|
assertEquals(rotatedResult.results[0].status, "ok");
|
|
|
|
|
|
|
|
|
|
adminArg.disableRunner(runnerId);
|
|
|
|
|
assertEquals(adminArg.listRunners()[0].enabled, false);
|
|
|
|
|
await assertRejects(() => rotatedRunner.runOnce(), Error, "401");
|
|
|
|
|
|
|
|
|
|
adminArg.enableRunner(runnerId);
|
|
|
|
|
adminArg.setRunnerLabels(runnerId, ["scenario:basic", "role:external"]);
|
|
|
|
|
assertEquals(
|
|
|
|
|
adminArg.listRunners()[0].labels.includes("role:external"),
|
|
|
|
|
true,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 19:48:14 +00:00
|
|
|
async function assertSnapshotPersistence(
|
|
|
|
|
coordinatorArg: RunnerCoordinator,
|
2026-04-29 21:32:36 +00:00
|
|
|
targetUrlArg: string,
|
2026-04-29 19:48:14 +00:00
|
|
|
): Promise<void> {
|
|
|
|
|
const snapshotPath = await Deno.makeTempFile();
|
2026-04-29 22:00:50 +00:00
|
|
|
const store = RunnerFileStore.fromDenoPath(snapshotPath);
|
2026-04-29 19:48:14 +00:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await store.save(coordinatorArg.getSnapshot());
|
|
|
|
|
const snapshot = await store.load();
|
|
|
|
|
assert(snapshot);
|
|
|
|
|
const restoredCoordinator = new RunnerCoordinator({ snapshot });
|
2026-04-29 21:35:21 +00:00
|
|
|
assertEquals(restoredCoordinator.listResults().length, 4);
|
2026-04-29 19:48:14 +00:00
|
|
|
assertEquals(restoredCoordinator.listRunners().length, 1);
|
2026-04-29 21:32:36 +00:00
|
|
|
restoredCoordinator.enqueueCheck({
|
|
|
|
|
id: "post-restart-http-health",
|
|
|
|
|
type: "http",
|
|
|
|
|
url: targetUrlArg,
|
|
|
|
|
expectedStatusCodes: [200],
|
|
|
|
|
expectedBodyIncludes: "healthy",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const restoredServer = await startServer(
|
|
|
|
|
createRunnerRequestHandler(restoredCoordinator),
|
|
|
|
|
);
|
|
|
|
|
try {
|
|
|
|
|
const restoredRunner = new UptimeRunner({
|
|
|
|
|
instanceUrl: restoredServer.url,
|
|
|
|
|
runnerId,
|
|
|
|
|
token: runnerToken,
|
|
|
|
|
});
|
|
|
|
|
const restoredResult = await restoredRunner.runOnce();
|
|
|
|
|
assertEquals(restoredResult.results.length, 1);
|
|
|
|
|
assertEquals(restoredResult.results[0].status, "ok");
|
2026-04-29 21:35:21 +00:00
|
|
|
assertEquals(restoredCoordinator.listResults().length, 5);
|
2026-04-29 21:32:36 +00:00
|
|
|
} finally {
|
|
|
|
|
await restoredServer.server.shutdown();
|
|
|
|
|
}
|
2026-04-29 19:48:14 +00:00
|
|
|
} 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);
|
|
|
|
|
}
|
|
|
|
|
}
|