diff options
Diffstat (limited to 'test')
| -rw-r--r-- | test/cdp/endpoint-test.ts | 60 | ||||
| -rw-r--r-- | test/cdp/target-store-test.ts | 110 |
2 files changed, 169 insertions, 1 deletions
diff --git a/test/cdp/endpoint-test.ts b/test/cdp/endpoint-test.ts index d4b696f..57d6088 100644 --- a/test/cdp/endpoint-test.ts +++ b/test/cdp/endpoint-test.ts @@ -9,7 +9,12 @@ * * Run: npx tsx test/cdp/endpoint-test.ts */ -import { DEFAULT_REMOTE_CDP, describeCdpTarget, resolveCdpTarget } from "../../src/cdp/endpoint"; +import { + DEFAULT_REMOTE_CDP, + describeCdpTarget, + isValidTargetValue, + resolveCdpTarget, +} from "../../src/cdp/endpoint"; let passed = 0; let failed = 0; @@ -92,6 +97,59 @@ function main(): void { check(describeCdpTarget(resolveCdpTarget(env("local"))).includes("own browser"), "E8: local is described plainly"); } + // E9: the stored choice (/browser-target) sits between env and the default. + { + const t = resolveCdpTarget({}, "local"); + check(t.kind === "local" && t.source === "stored", "E9: a stored value is used when the env is unset"); + + const r = resolveCdpTarget({}, "10.2.3.4:9224"); + check(r.kind === "remote" && r.source === "stored", "E9: a stored host:port resolves as a remote target"); + } + + // E10: the environment wins, so a one-off `BU_CDP_HTTP=… pi` is never + // silently overridden by a setting made days ago. + { + const t = resolveCdpTarget(env("local"), "10.2.3.4:9224"); + check(t.kind === "local" && t.source === "env", "E10: env beats the stored value"); + + const r = resolveCdpTarget(env("10.9.9.9:1234"), "local"); + check(r.kind === "remote" && r.host === "10.9.9.9" && r.source === "env", "E10: an env endpoint beats stored local"); + } + + // E11: clearing the stored value falls back to the built-in default rather + // than to local — clearing is "use the usual browser", not "go local". + { + const t = resolveCdpTarget({}, null); + check(t.kind === "remote" && t.source === "default", "E11: no stored value falls back to the default"); + const blank = resolveCdpTarget({}, " "); + check(blank.kind === "remote" && blank.source === "default", "E11: a blank stored value is treated as cleared"); + } + + // E12: a corrupt stored value must be reported against the right source, or + // the operator goes hunting through their shell config for an env var that + // was never set. + { + const t = resolveCdpTarget({}, "garbage"); + check(t.kind === "invalid" && t.source === "stored", "E12: a bad stored value is blamed on /browser-target"); + if (t.kind === "invalid") { + check(describeCdpTarget(t).includes("/browser-target"), "E12: the description names the stored source"); + } + const e = resolveCdpTarget(env("garbage"), null); + check(e.kind === "invalid" && e.source === "env", "E12: a bad env value is blamed on BU_CDP_HTTP"); + } + + // E13: the values /browser-target accepts must match what resolution can + // actually use, or the command would happily store something unusable. + { + for (const good of ["local", "off", "1.2.3.4:9222", "castle.local:9223"]) { + check(isValidTargetValue(good), `E13: "${good}" is accepted by the command`); + } + for (const bad of ["", " ", "garbage", "host:", "host:99999"]) { + check(!isValidTargetValue(bad), `E13: "${bad}" is rejected by the command`); + } + check(!isValidTargetValue("default"), "E13: \"default\" is handled as clear, not stored verbatim"); + } + console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); } diff --git a/test/cdp/target-store-test.ts b/test/cdp/target-store-test.ts new file mode 100644 index 0000000..56def62 --- /dev/null +++ b/test/cdp/target-store-test.ts @@ -0,0 +1,110 @@ +/** + * Unit tests for the persisted CDP target — no browser required. + * + * The store is what makes /browser-target outlive the pi session that ran it, + * because the browser daemon is machine-wide. Its central obligation is that a + * damaged file degrades to "nothing stored" — falling back to the built-in + * default — rather than throwing and taking browser control down with it. + * + * Run: npx tsx test/cdp/target-store-test.ts + */ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let passed = 0; +let failed = 0; +const check = (cond: boolean, label: string): void => { + if (cond) { + passed++; + console.log(` ✓ ${label}`); + } else { + failed++; + console.error(` ✗ ${label}`); + } +}; + +async function main(): Promise<void> { + // The store resolves its path from pi's agent dir, which paths.ts derives + // from the environment — so point that at a scratch dir before importing. + const root = mkdtempSync(join(tmpdir(), "pi-target-")); + process.env["PI_CODING_AGENT_DIR"] = root; + + const { readTarget, writeTarget, clearTarget } = await import("../../src/cdp/target-store"); + const { targetFilePath } = await import("../../src/profile/paths"); + const file = targetFilePath(); + + try { + // T1: nothing stored yet. + check((await readTarget()) === null, "T1: a missing file reads as nothing stored"); + + // T2: round-trip. + { + const w = await writeTarget("local"); + check(w.success, "T2: writing a target succeeds"); + check((await readTarget()) === "local", "T2: it reads back verbatim"); + } + + // T3: overwrite, not append — a switch must fully replace the last choice. + { + await writeTarget("10.4.5.6:9299"); + check((await readTarget()) === "10.4.5.6:9299", "T3: a second write replaces the first"); + } + + // T4: clearing restores "nothing stored", which resolution reads as the + // built-in default rather than as local. + { + const c = await clearTarget(); + check(c.success, "T4: clearing succeeds"); + check((await readTarget()) === null, "T4: a cleared target reads as nothing stored"); + } + + // T5: whitespace is normalised on read, so a hand-edited file with a + // trailing newline inside the JSON string still resolves. + { + writeFileSync(file, JSON.stringify({ version: 1, target: " local ", savedAt: "" })); + check((await readTarget()) === "local", "T5: surrounding whitespace is trimmed"); + } + + // T6: damage must degrade to null, never throw. Each of these is a real + // failure mode: truncated write, hand-edit, or a file from a future version. + { + const damaged: ReadonlyArray<readonly [string, string]> = [ + ["not json at all", "T6: unparseable JSON"], + ['{"version":1,"target":', "T6: truncated JSON"], + ['{"version":2,"target":"local"}', "T6: a newer file version"], + ['{"version":1,"target":42}', "T6: a non-string target"], + ['{"version":1,"target":null}', "T6: an explicitly null target"], + ['{"version":1}', "T6: a missing target field"], + ["[]", "T6: a JSON array instead of an object"], + ['{"version":1,"target":""}', "T6: an empty target string"], + ]; + for (const [contents, label] of damaged) { + writeFileSync(file, contents); + let threw = false; + let value: string | null = "unset"; + try { + value = await readTarget(); + } catch { + threw = true; + } + check(!threw && value === null, `${label} degrades to nothing stored`); + } + } + + // T7: recovery — a damaged file must not poison later writes. + { + writeFileSync(file, "garbage"); + const w = await writeTarget("local"); + check(w.success, "T7: writing over a damaged file succeeds"); + check((await readTarget()) === "local", "T7: the store recovers"); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + + console.log(`\n${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); +} + +void main(); |
