aboutsummaryrefslogtreecommitdiff
path: root/test/profile/store-test.ts
diff options
context:
space:
mode:
authorAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-07-25 00:23:05 +0530
committerGitHub <noreply@github.com>2026-07-25 00:23:05 +0530
commit6dd79f7c3bafacba54acb2840f8655fcb05318bf (patch)
tree1570768785ae836717d3dbb2ff42fe0a13f19585 /test/profile/store-test.ts
parent0fc1ece6fa4a2f24e87eb4a66eb828a9928e30f7 (diff)
feat: pin the agent to a chosen browser profile (#16)
* feat: pin the agent to a chosen browser profile (#15) Harness tabs were created with a bare Target.createTarget, which places them in Chrome's defaultBrowserContextId — and that value follows window focus. Which profile the agent acted as therefore depended on which browser window the user last clicked, so the same task could run as a work account on one run and a personal account on the next. /browser-profile lists the connected browser's profiles as "Name (email)" and pins one to ~/.pi/agent/browser-harness.json, so the choice outlives the session. The same picker appears on first browser_setup when nothing is pinned; with no interactive UI, or on cancel, setup continues with a note and the previous behavior. Chrome offers no direct route to another profile: Target.createTarget rejects a foreign browserContextId, and openerId does not inherit the opener's context. The window is opened through the browser's own command line (--profile-directory, handed to the running browser by Chromium's ProcessSingleton) and identified by a unique file:// sentinel page, since a bare about:blank#token argument is discarded. Further tabs come from window.open evaluated with userGesture: true — the only CDP-reachable way to place a tab in a non-default browser context — correlated by a unique token so concurrent isolated tabs stay distinct. A failed handshake is reported, never silently redirected to another profile. All four tab-creation sites now funnel through cdp/target-factory, so open_urls and the isolated tabs behind web_search / read_page cannot run with a different profile's cookies than the visible tabs. Also fixed along the way: snap Chromium is discovered, Windows uses %LOCALAPPDATA%, browsers launched with an explicit --user-data-dir are found, the running browser is chosen by ranking candidates against the connected user-data-dir rather than taking the first process found, and an installed-but-closed browser no longer reads as running. CI runs the profile suites and a real-browser end-to-end test on ubuntu, macOS, and Windows — profile discovery is the one part of the harness whose behavior genuinely differs per OS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: single-flight harness window creation Isolated tabs run concurrently (browser_read_page is registered unserialized), so several could each find "no harness window" and create one — two blank windows before this feature, two LAUNCHED profile windows after it. ensureHarnessWindow now shares one in-flight creation per client. Sharing introduces a second hazard: freshlyCreated is a claim on the seed tab, and if every waiter received it, they would all drive the same tab. Only the caller that started the creation keeps the claim; the rest open their own tab beside it. Covered by test/manual/profile-concurrency-test.ts (five concurrent isolated tabs, pinned and unpinned: distinct targets, one window, correct profile), wired into CI on Linux only — the coordination is pure JS and identical across platforms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: tilde expansion differs by purpose on Windows CI on windows-latest caught this: join("~", "x") yields "~\x" there, and expandTilde only handled "~/". The two uses are not the same, so they no longer share a helper. The agent dir stays byte-compatible with pi's own expandTildePath ("~/" only, every platform) — being more lenient would put the pin file somewhere pi never looks. $CHROME_USER_DATA_DIR is ours to interpret, so it accepts "~\" as well, which is what a Windows user would type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'test/profile/store-test.ts')
-rw-r--r--test/profile/store-test.ts116
1 files changed, 116 insertions, 0 deletions
diff --git a/test/profile/store-test.ts b/test/profile/store-test.ts
new file mode 100644
index 0000000..c881372
--- /dev/null
+++ b/test/profile/store-test.ts
@@ -0,0 +1,116 @@
+/**
+ * Unit tests for the profile pin store — no browser required.
+ *
+ * Redirects pi's agent dir via $PI_CODING_AGENT_DIR so the real
+ * ~/.pi/agent/browser-harness.json is never touched.
+ *
+ * Run: npx tsx test/profile/store-test.ts
+ */
+import { mkdtempSync, mkdirSync, readdirSync, 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}`);
+ }
+};
+
+const pin = {
+ userDataDir: "/home/u/.config/google-chrome",
+ profileDir: "Profile 1",
+ label: "Aman (aman@example.com)",
+ savedAt: new Date(0).toISOString(),
+};
+
+async function main(): Promise<void> {
+ const sandbox = mkdtempSync(join(tmpdir(), "pi-pin-test-"));
+ process.env["PI_CODING_AGENT_DIR"] = sandbox;
+
+ // Imported after the env override so paths.ts resolves into the sandbox.
+ const { pinFilePath } = await import("../../src/profile/paths");
+ const { readPin, writePin, clearPin } = await import("../../src/profile/store");
+
+ // P1: agent dir honours the env override
+ check(pinFilePath() === join(sandbox, "browser-harness.json"), "P1: pin path follows $PI_CODING_AGENT_DIR");
+
+ // P2: nothing written yet
+ check((await readPin()) === null, "P2: absent file reads as no pin");
+
+ // P3: round trip
+ {
+ const written = await writePin(pin);
+ check(written.success, "P3: write succeeds");
+ const read = await readPin();
+ check(read?.profileDir === "Profile 1", "P3: profileDir round-trips");
+ check(read?.userDataDir === pin.userDataDir, "P3: userDataDir round-trips");
+ check(read?.label === pin.label, "P3: label round-trips");
+ }
+
+ // P4: no temp files survive an atomic write
+ check(readdirSync(sandbox).filter((f) => f.endsWith(".tmp")).length === 0, "P4: write leaves no .tmp files");
+
+ // P5: overwrite replaces rather than appends
+ {
+ await writePin({ ...pin, profileDir: "Default", label: "Work (work@example.com)" });
+ const read = await readPin();
+ check(read?.profileDir === "Default", "P5: second write replaces the first");
+ check(readdirSync(sandbox).filter((f) => f === "browser-harness.json").length === 1, "P5: one pin file on disk");
+ }
+
+ // P6: clear
+ {
+ const cleared = await clearPin();
+ check(cleared.success, "P6: clear succeeds");
+ check((await readPin()) === null, "P6: cleared pin reads as none");
+ }
+
+ // P7: corrupt file degrades to no pin
+ writeFileSync(pinFilePath(), "{ not json at all", "utf8");
+ check((await readPin()) === null, "P7: malformed JSON reads as no pin");
+
+ // P8: unknown schema version is not guessed at
+ writeFileSync(pinFilePath(), JSON.stringify({ version: 99, profile: pin }), "utf8");
+ check((await readPin()) === null, "P8: future version reads as no pin");
+
+ // P9: structurally invalid pin is rejected
+ writeFileSync(pinFilePath(), JSON.stringify({ version: 1, profile: { profileDir: 42 } }), "utf8");
+ check((await readPin()) === null, "P9: wrong field types read as no pin");
+
+ // P10: a missing agent dir is created on write
+ {
+ rmSync(sandbox, { recursive: true, force: true });
+ const written = await writePin(pin);
+ check(written.success, "P10: write recreates a missing agent dir");
+ check((await readPin())?.profileDir === "Profile 1", "P10: pin readable after dir recreation");
+ }
+
+ // P11: an unwritable location surfaces as an error result, not a throw
+ {
+ const blocked = join(sandbox, "blocked");
+ mkdirSync(blocked, { recursive: true });
+ writeFileSync(join(blocked, "browser-harness.json"), "{}", "utf8");
+ // Point the agent dir at a FILE — mkdir/write must fail cleanly.
+ process.env["PI_CODING_AGENT_DIR"] = join(blocked, "browser-harness.json");
+ const { writePin: writeAgain } = await import(`../../src/profile/store?nocache=${Date.now()}`);
+ const written = await writeAgain(pin);
+ check(!written.success, "P11: write into an invalid dir returns an error result");
+ process.env["PI_CODING_AGENT_DIR"] = sandbox;
+ }
+
+ rmSync(sandbox, { recursive: true, force: true });
+
+ console.log(`\n${passed} passed, ${failed} failed`);
+ process.exit(failed === 0 ? 0 : 1);
+}
+
+main().catch((e) => {
+ console.error("profile store test failed:", e);
+ process.exit(1);
+});