|
| 1 | +// Tests for the daemon-stamped `lastOutputAtMs` session metadata field: the |
| 2 | +// daemon stamps every PTY output chunk in-memory and persists it debounced |
| 3 | +// (≤1 write/second) so downstream consumers (st2 observed harness state) can |
| 4 | +// derive session activity without observing the output stream themselves. |
| 5 | +// |
| 6 | +// These are integration tests against a real daemon process: the debounce |
| 7 | +// timer lives in the daemon, not in this process, so fake timers cannot drive |
| 8 | +// it — the real platform clock is the system under test. |
| 9 | + |
| 10 | +import { describe, it, expect, afterEach, afterAll } from "vitest"; |
| 11 | +import { terminateAndWait } from "./setup/processes.ts"; |
| 12 | +import * as fs from "node:fs"; |
| 13 | +import * as os from "node:os"; |
| 14 | +import * as path from "node:path"; |
| 15 | +import { fileURLToPath } from "node:url"; |
| 16 | +import { spawn, spawnSync } from "node:child_process"; |
| 17 | + |
| 18 | +const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 19 | +const nodeBin = process.execPath; |
| 20 | +const cliPath = path.join(__dirname, "..", "dist", "cli.js"); |
| 21 | +const serverModule = path.join(__dirname, "..", "dist", "server.js"); |
| 22 | + |
| 23 | +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-activity-")); |
| 24 | + |
| 25 | +const bgPids: number[] = []; |
| 26 | +const sessionDirs: string[] = []; |
| 27 | +const runningDaemons: { sessionDir: string; name: string }[] = []; |
| 28 | + |
| 29 | +function makeSessionDir(): string { |
| 30 | + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); |
| 31 | + sessionDirs.push(dir); |
| 32 | + return dir; |
| 33 | +} |
| 34 | + |
| 35 | +let nameCounter = 0; |
| 36 | +function uniqueName(): string { |
| 37 | + return `act${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; |
| 38 | +} |
| 39 | + |
| 40 | +function sleep(ms: number): Promise<void> { |
| 41 | + // Executor form: the repo's tsconfig lib predates Promise.withResolvers. |
| 42 | + return new Promise<void>((resolve) => setTimeout(resolve, ms)); |
| 43 | +} |
| 44 | + |
| 45 | +async function startDaemon( |
| 46 | + sessionDir: string, |
| 47 | + name: string, |
| 48 | + command = "cat", |
| 49 | + args: string[] = [], |
| 50 | +): Promise<void> { |
| 51 | + const config = JSON.stringify({ |
| 52 | + name, command, args, displayCommand: [command, ...args].join(" "), |
| 53 | + cwd: os.tmpdir(), rows: 24, cols: 80, |
| 54 | + }); |
| 55 | + const child = spawn(nodeBin, [serverModule], { |
| 56 | + detached: true, |
| 57 | + stdio: ["ignore", "ignore", "pipe"], |
| 58 | + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, |
| 59 | + }); |
| 60 | + let stderr = ""; |
| 61 | + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); |
| 62 | + let exitCode: number | null = null; |
| 63 | + child.on("exit", (code) => { exitCode = code; }); |
| 64 | + child.unref(); |
| 65 | + |
| 66 | + const socketPath = path.join(sessionDir, `${name}.sock`); |
| 67 | + const start = Date.now(); |
| 68 | + while (Date.now() - start < 5000) { |
| 69 | + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); |
| 70 | + if (fs.existsSync(socketPath)) { |
| 71 | + await sleep(100); |
| 72 | + bgPids.push(child.pid!); |
| 73 | + return; |
| 74 | + } |
| 75 | + await sleep(50); |
| 76 | + } |
| 77 | + throw new Error("Timeout waiting for daemon"); |
| 78 | +} |
| 79 | + |
| 80 | +function runCli(sessionDir: string, ...args: string[]) { |
| 81 | + return spawnSync(nodeBin, [cliPath, ...args], { |
| 82 | + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, |
| 83 | + encoding: "utf-8", |
| 84 | + timeout: 10_000, |
| 85 | + }); |
| 86 | +} |
| 87 | + |
| 88 | +function readMetadata(sessionDir: string, name: string): Record<string, unknown> { |
| 89 | + const raw: unknown = JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf8")); |
| 90 | + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { |
| 91 | + throw new Error("Session metadata must be a JSON object"); |
| 92 | + } |
| 93 | + return raw as Record<string, unknown>; |
| 94 | +} |
| 95 | + |
| 96 | +function readLastOutputAtMs(sessionDir: string, name: string): number | undefined { |
| 97 | + const value = readMetadata(sessionDir, name).lastOutputAtMs; |
| 98 | + return typeof value === "number" ? value : undefined; |
| 99 | +} |
| 100 | + |
| 101 | +async function waitFor( |
| 102 | + poll: () => boolean, |
| 103 | + timeoutMs = 5000, |
| 104 | + stepMs = 100, |
| 105 | +): Promise<void> { |
| 106 | + const start = Date.now(); |
| 107 | + while (Date.now() - start < timeoutMs) { |
| 108 | + if (poll()) return; |
| 109 | + await sleep(stepMs); |
| 110 | + } |
| 111 | + throw new Error("Condition not met within timeout"); |
| 112 | +} |
| 113 | + |
| 114 | +afterEach(async () => { |
| 115 | + const pids: number[] = []; |
| 116 | + while (runningDaemons.length > 0) { |
| 117 | + const { sessionDir, name } = runningDaemons.pop()!; |
| 118 | + try { |
| 119 | + pids.push(parseInt(fs.readFileSync(path.join(sessionDir, `${name}.pid`), "utf8"), 10)); |
| 120 | + } catch {} |
| 121 | + } |
| 122 | + // Await before the afterAll rmtree: a daemon that is still writing would |
| 123 | + // race the directory removal with ENOTEMPTY. |
| 124 | + if (pids.length > 0) await terminateAndWait(pids); |
| 125 | +}); |
| 126 | + |
| 127 | +afterAll(() => { |
| 128 | + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); |
| 129 | +}); |
| 130 | + |
| 131 | +describe("lastOutputAtMs session activity stamp", () => { |
| 132 | + it("is absent before the session produces any output", async () => { |
| 133 | + const sessionDir = makeSessionDir(); |
| 134 | + const name = uniqueName(); |
| 135 | + await startDaemon(sessionDir, name); |
| 136 | + runningDaemons.push({ sessionDir, name }); |
| 137 | + |
| 138 | + expect(readLastOutputAtMs(sessionDir, name)).toBeUndefined(); |
| 139 | + }); |
| 140 | + |
| 141 | + it("appears after output and carries a recent unix-millisecond timestamp", async () => { |
| 142 | + const sessionDir = makeSessionDir(); |
| 143 | + const name = uniqueName(); |
| 144 | + await startDaemon(sessionDir, name); |
| 145 | + runningDaemons.push({ sessionDir, name }); |
| 146 | + |
| 147 | + const before = Date.now(); |
| 148 | + // cat echoes stdin back — one line in, one chunk of PTY output out. |
| 149 | + const sent = runCli(sessionDir, "send", name, "--seq", "activity-probe", "--seq", "key:return"); |
| 150 | + expect(sent.status).toBe(0); |
| 151 | + |
| 152 | + await waitFor(() => readLastOutputAtMs(sessionDir, name) !== undefined); |
| 153 | + |
| 154 | + const stampedAt = readLastOutputAtMs(sessionDir, name)!; |
| 155 | + expect(stampedAt).toBeGreaterThanOrEqual(before - 1000); |
| 156 | + expect(stampedAt).toBeLessThanOrEqual(Date.now() + 1000); |
| 157 | + }); |
| 158 | + |
| 159 | + it("updates the stamp on subsequent output bursts", async () => { |
| 160 | + const sessionDir = makeSessionDir(); |
| 161 | + const name = uniqueName(); |
| 162 | + await startDaemon(sessionDir, name); |
| 163 | + runningDaemons.push({ sessionDir, name }); |
| 164 | + |
| 165 | + runCli(sessionDir, "send", name, "--seq", "first", "--seq", "key:return"); |
| 166 | + await waitFor(() => readLastOutputAtMs(sessionDir, name) !== undefined); |
| 167 | + const first = readLastOutputAtMs(sessionDir, name)!; |
| 168 | + |
| 169 | + // Wait out the 1s debounce window so the second burst cannot coalesce |
| 170 | + // into the first persist, then require the stamp to move forward. |
| 171 | + await sleep(1600); |
| 172 | + runCli(sessionDir, "send", name, "--seq", "second", "--seq", "key:return"); |
| 173 | + await waitFor(() => { |
| 174 | + const stamp = readLastOutputAtMs(sessionDir, name); |
| 175 | + return stamp !== undefined && stamp > first; |
| 176 | + }, 5000); |
| 177 | + }); |
| 178 | + |
| 179 | + it("carries the final output stamp into exit metadata before debounce", async () => { |
| 180 | + const sessionDir = makeSessionDir(); |
| 181 | + const name = uniqueName(); |
| 182 | + await startDaemon(sessionDir, name, "sh", ["-c", "printf final-output"]); |
| 183 | + runningDaemons.push({ sessionDir, name }); |
| 184 | + |
| 185 | + // The pending activity timer skips once exited, so observing both fields |
| 186 | + // after exit proves saveExitMetadata carried the in-memory final stamp. |
| 187 | + await waitFor(() => typeof readMetadata(sessionDir, name).exitCode === "number"); |
| 188 | + expect(readLastOutputAtMs(sessionDir, name)).toEqual(expect.any(Number)); |
| 189 | + }); |
| 190 | +}); |
0 commit comments