specpi 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { Type, type Static } from "typebox";
3
+ import type { Locator, Page } from "playwright";
4
+ import { boundedInteger } from "./diagnostics.ts";
5
+ import { normalizeBrowserUrl } from "./core.mjs";
6
+
7
+ const Target = Type.String({
8
+ minLength: 1,
9
+ maxLength: 2000,
10
+ description: "CSS selector, exact text= locator, or current snapshot ref. First match is used.",
11
+ });
12
+ const Timeout = Type.Optional(
13
+ Type.Integer({
14
+ minimum: 1,
15
+ maximum: 30000,
16
+ description: "Whole-operation deadline in milliseconds; default 5000.",
17
+ }),
18
+ );
19
+ export const PressParams = Type.Object(
20
+ { target: Type.Optional(Target), key: Type.String({ minLength: 1, maxLength: 80 }), timeoutMs: Timeout },
21
+ { additionalProperties: false },
22
+ );
23
+ export const SelectionParams = Type.Object(
24
+ {
25
+ target: Target,
26
+ options: Type.Array(
27
+ Type.Object(
28
+ {
29
+ value: Type.Optional(Type.String({ maxLength: 1000 })),
30
+ label: Type.Optional(Type.String({ maxLength: 1000 })),
31
+ index: Type.Optional(Type.Integer({ minimum: 0, maximum: 10000 })),
32
+ },
33
+ { additionalProperties: false },
34
+ ),
35
+ { minItems: 1, maxItems: 50 },
36
+ ),
37
+ timeoutMs: Timeout,
38
+ },
39
+ { additionalProperties: false },
40
+ );
41
+ export const WaitParams = Type.Object(
42
+ {
43
+ condition: StringEnum(["attached", "detached", "visible", "hidden", "text", "url"] as const),
44
+ target: Type.Optional(Target),
45
+ text: Type.Optional(Type.String({ maxLength: 2000 })),
46
+ url: Type.Optional(Type.String({ minLength: 1, maxLength: 2000 })),
47
+ timeoutMs: Timeout,
48
+ },
49
+ { additionalProperties: false },
50
+ );
51
+
52
+ export function interactionTimeout(value?: number): number {
53
+ return boundedInteger(value, 5000, 30000);
54
+ }
55
+
56
+ export function validateKey(key: string): void {
57
+ const parts = key.split("+");
58
+ // A literal plus key is also supported; chords use Playwright key names.
59
+ const final = key === "+" ? "+" : parts.pop()!;
60
+ if (key === "+") {
61
+ parts.length = 0;
62
+ }
63
+
64
+ if (
65
+ parts.some((part) => !["Control", "ControlOrMeta", "Alt", "Shift", "Meta"].includes(part)) ||
66
+ new Set(parts).size !== parts.length ||
67
+ !(
68
+ Array.from(final).length === 1 ||
69
+ /^(?:Tab|Enter|Escape|Backspace|Delete|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Home|End|PageUp|PageDown|Space|Insert|F(?:[1-9]|1[0-2]))$/u.test(
70
+ final,
71
+ )
72
+ ) ||
73
+ /[\u0000-\u001f\u007f]/u.test(key)
74
+ ) {
75
+ throw new Error("Unsupported browser key or chord.");
76
+ }
77
+ }
78
+
79
+ export function selectionOptions(params: Static<typeof SelectionParams>) {
80
+ for (const option of params.options) {
81
+ if (Object.keys(option).length !== 1) {
82
+ throw new Error("Each option must specify exactly one of value, label, or index.");
83
+ }
84
+ }
85
+
86
+ return params.options;
87
+ }
88
+
89
+ export function validateWait(params: Static<typeof WaitParams>): void {
90
+ if (params.condition === "url") {
91
+ if (params.url === undefined || params.target !== undefined || params.text !== undefined) {
92
+ throw new Error("URL waits require only url, not target or text.");
93
+ }
94
+ } else if (
95
+ !params.target?.trim() ||
96
+ params.url !== undefined ||
97
+ (params.condition === "text") !== (params.text !== undefined)
98
+ ) {
99
+ throw new Error("Element waits require target; only text waits require text. Do not supply url.");
100
+ }
101
+ }
102
+
103
+ export async function waitForCondition(
104
+ page: Page,
105
+ locator: Locator | undefined,
106
+ params: Static<typeof WaitParams>,
107
+ timeout: number,
108
+ ): Promise<void> {
109
+ if (params.condition === "url") {
110
+ const expected = normalizeBrowserUrl(params.url!);
111
+ await page.waitForURL((url) => url.href === expected, { timeout, waitUntil: "commit" });
112
+ } else if (params.condition === "text") {
113
+ const literal = params.text!.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
114
+ await locator!.filter({ hasText: new RegExp(`^${literal}$`, "u") }).waitFor({ state: "attached", timeout });
115
+ } else {
116
+ await locator!.waitFor({ state: params.condition, timeout });
117
+ }
118
+ }
@@ -0,0 +1,28 @@
1
+ export const CLEANUP_TIMEOUT_MS = 1000;
2
+
3
+ export class BrowserCleanupError extends Error {
4
+ constructor() {
5
+ super(
6
+ "Browser cleanup failed or did not settle within 1000ms. The context was detached and diagnostics discarded, but a browser process may remain; inspect local browser processes before continuing.",
7
+ );
8
+ this.name = "BrowserCleanupError";
9
+ }
10
+ }
11
+
12
+ /** Bound settlement without claiming that an unresponsive external process was terminated. */
13
+ export async function settleBrowserCleanup(operations: Promise<unknown>[]): Promise<void> {
14
+ let timer: ReturnType<typeof setTimeout> | undefined;
15
+ try {
16
+ const result = await Promise.race([
17
+ Promise.allSettled(operations),
18
+ new Promise<never>((_resolve, reject) => {
19
+ timer = setTimeout(() => reject(new BrowserCleanupError()), CLEANUP_TIMEOUT_MS);
20
+ }),
21
+ ]);
22
+ if (result.some((entry) => entry.status === "rejected")) {
23
+ throw new BrowserCleanupError();
24
+ }
25
+ } finally {
26
+ clearTimeout(timer);
27
+ }
28
+ }
@@ -44,6 +44,7 @@ export const SOURCE_ROOT_FILES = Object.freeze([
44
44
  "eslint.config.js",
45
45
  "package.json",
46
46
  "prettier.config.mjs",
47
+ "tsconfig.browser.json",
47
48
  "specpi",
48
49
  "specpi.cmd",
49
50
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specpi",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "An explicit, privacy-conscious, self-improving harness for the Pi coding agent",
5
5
  "author": "Tanner Middleton",
6
6
  "repository": {
@@ -33,6 +33,7 @@
33
33
  "shell",
34
34
  "README.md",
35
35
  "docs/delegation",
36
+ "docs/browser-testing.md",
36
37
  "site/logo.svg",
37
38
  "site/self-improvement-loop-v2.svg",
38
39
  "SECURITY.md",
@@ -82,15 +83,25 @@
82
83
  },
83
84
  "scripts": {
84
85
  "test": "node --test \"tests/*.test.mjs\"",
85
- "format": "prettier --config prettier.config.mjs --write \"**/*.{js,mjs,ts,tsx}\" && eslint --config eslint.config.js . --fix --rule \"@stylistic/max-statements-per-line: off\" && prettier --config prettier.config.mjs --write \"**/*.{js,mjs,ts,tsx}\" && eslint --config eslint.config.js . --fix",
86
- "format:check": "prettier --config prettier.config.mjs --check \"**/*.{js,mjs,ts,tsx}\" && eslint --config eslint.config.js .",
86
+ "format": "prettier --config prettier.config.mjs --write \"**/*.{js,mjs,ts,mts,tsx}\" && eslint --config eslint.config.js . --fix --rule \"@stylistic/max-statements-per-line: off\" && prettier --config prettier.config.mjs --write \"**/*.{js,mjs,ts,mts,tsx}\" && eslint --config eslint.config.js . --fix",
87
+ "format:check": "prettier --config prettier.config.mjs --check \"**/*.{js,mjs,ts,mts,tsx}\" && eslint --config eslint.config.js .",
87
88
  "check:syntax": "node scripts/check-syntax.mjs",
89
+ "check:types": "tsc -p tsconfig.browser.json",
90
+ "setup:browser": "node scripts/setup-browser-tests.mjs",
91
+ "test:browser": "node scripts/run-browser-tests.mjs extension",
92
+ "test:site:browser": "node scripts/run-browser-tests.mjs site",
88
93
  "check:package": "node scripts/check-package.mjs",
89
94
  "check:pi-package": "node scripts/check-pi-package.mjs",
90
95
  "prepublishOnly": "npm run check",
91
- "check": "npm run format:check && npm run check:syntax && npm test && npm run check:package"
96
+ "check": "npm run format:check && npm run check:syntax && npm run check:types && npm test && npm run check:package"
92
97
  },
93
98
  "devDependencies": {
99
+ "@earendil-works/pi-coding-agent": "0.84.4",
100
+ "@earendil-works/pi-ai": "0.84.4",
101
+ "@earendil-works/pi-tui": "0.84.4",
102
+ "@types/node": "22.20.1",
103
+ "playwright": "1.62.1",
104
+ "typebox": "1.3.7",
94
105
  "@stylistic/eslint-plugin": "5.10.0",
95
106
  "@typescript-eslint/parser": "8.68.0",
96
107
  "eslint": "10.9.1",
@@ -41,6 +41,7 @@ const requiredFiles = [
41
41
  "THIRD_PARTY.md",
42
42
  "browser-runtime/package-lock.json",
43
43
  "browser-runtime/package.json",
44
+ "docs/browser-testing.md",
44
45
  "docs/delegation/README.md",
45
46
  "docs/delegation/design.md",
46
47
  "docs/delegation/design-protocol.md",
@@ -48,6 +49,10 @@ const requiredFiles = [
48
49
  "docs/delegation/protocol.md",
49
50
  "docs/delegation/research.md",
50
51
  "extensions/browser/core.mjs",
52
+ "extensions/browser/core.d.mts",
53
+ "extensions/browser/diagnostics.ts",
54
+ "extensions/browser/interactions.ts",
55
+ "extensions/browser/lifecycle.ts",
51
56
  "extensions/browser/index.ts",
52
57
  "extensions/browser/smoke.mjs",
53
58
  "extensions/command-guard/bash.mjs",
@@ -87,6 +92,9 @@ const requiredFiles = [
87
92
  "scripts/lib.mjs",
88
93
  "scripts/lock.mjs",
89
94
  "scripts/pi-test-harness.mjs",
95
+ "scripts/run-browser-tests.mjs",
96
+ "scripts/setup-browser-tests.mjs",
97
+ "scripts/site-browser.mjs",
90
98
  "scripts/specpi.mjs",
91
99
  "scripts/verify-artifact.mjs",
92
100
  "shell/pi-profiles.sh",
@@ -443,10 +451,10 @@ try {
443
451
  assert.equal(packResult.name, sourcePackage.name);
444
452
  assert.equal(packResult.version, sourcePackage.version);
445
453
  assert.deepEqual(packResult.bundled || [], [], "packed artifact unexpectedly bundles dependencies");
446
- assert.ok(packResult.size < 310_000, `packed artifact unexpectedly exceeds 310 KB: ${packResult.size}`);
454
+ assert.ok(packResult.size < 340_000, `packed artifact unexpectedly exceeds 340 KB: ${packResult.size}`);
447
455
  assert.ok(
448
- packResult.unpackedSize < 1_300_000,
449
- `unpacked artifact unexpectedly exceeds 1.3 MB (including delegation runtime and protocol docs): ${packResult.unpackedSize}`,
456
+ packResult.unpackedSize < 1_400_000,
457
+ `unpacked artifact unexpectedly exceeds 1.4 MB (including browser tools and testing documentation): ${packResult.unpackedSize}`,
450
458
  );
451
459
 
452
460
  assert.ok(fs.existsSync(tarball), "the reported npm tarball does not exist");
@@ -251,6 +251,7 @@ try {
251
251
  [
252
252
  "--test",
253
253
  "--test-reporter=tap",
254
+ path.join(repoRoot, "tests", "browser-registration.test.mjs"),
254
255
  path.join(repoRoot, "tests", "workflow-controls-extension.test.mjs"),
255
256
  path.join(repoRoot, "tests", "spec-task-extension.test.mjs"),
256
257
  path.join(repoRoot, "tests", "wishlist-verification-extension.test.mjs"),
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { spawnSync } from "node:child_process";
5
+ import { loadBrowserRuntime } from "../extensions/browser/core.mjs";
6
+
7
+ const root = fileURLToPath(new URL("../", import.meta.url));
8
+
9
+ export function assertBrowserCoverage(result) {
10
+ const count = (name) => Number(result.stdout?.match(new RegExp(`^# ${name} (\\d+)$`, "mu"))?.[1] ?? NaN);
11
+ if (
12
+ result.error ||
13
+ result.status !== 0 ||
14
+ !(count("tests") > 0) ||
15
+ count("pass") !== count("tests") ||
16
+ ["fail", "cancelled", "skipped", "todo"].some((name) => count(name) !== 0)
17
+ ) {
18
+ throw new Error("Required browser coverage failed, was missing, or contained skipped/TODO/cancelled tests.");
19
+ }
20
+ }
21
+
22
+ async function main() {
23
+ const mode = process.argv[2];
24
+ if (!["extension", "site"].includes(mode) || process.argv.length !== 3) {
25
+ throw new Error("Usage: run-browser-tests.mjs <extension|site>");
26
+ }
27
+
28
+ const runtime = path.join(root, ".specpi-test", "browser-runtime");
29
+ try {
30
+ const { playwright } = await loadBrowserRuntime(runtime);
31
+ const browser = await playwright.chromium.launch({ headless: true });
32
+ await browser.close();
33
+ } catch {
34
+ throw new Error(
35
+ "Browser tests require the locked runtime and Chromium. Run npm run setup:browser (Linux CI: -- --with-deps).",
36
+ );
37
+ }
38
+
39
+ const result = spawnSync(
40
+ process.execPath,
41
+ [
42
+ "--test",
43
+ "--test-reporter=tap",
44
+ path.join(root, "tests", mode === "site" ? "site-browser.test.mjs" : "browser-extension.test.mjs"),
45
+ ],
46
+ {
47
+ cwd: root,
48
+ env: { ...process.env, SPECPI_BROWSER_TESTS: "1", SPECPI_BROWSER_RUNTIME: runtime },
49
+ encoding: "utf8",
50
+ timeout: 300000,
51
+ maxBuffer: 5 * 1024 * 1024,
52
+ },
53
+ );
54
+ process.stdout.write(result.stdout ?? "");
55
+ process.stderr.write(result.stderr ?? "");
56
+ assertBrowserCoverage(result);
57
+ }
58
+
59
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
60
+ await main();
61
+ }
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { spawnSync } from "node:child_process";
6
+
7
+ const root = fileURLToPath(new URL("../", import.meta.url));
8
+ const runtime = path.join(root, ".specpi-test", "browser-runtime");
9
+ fs.mkdirSync(runtime, { recursive: true });
10
+ for (const file of ["package.json", "package-lock.json"]) {
11
+ fs.copyFileSync(path.join(root, "browser-runtime", file), path.join(runtime, file));
12
+ }
13
+
14
+ if (!process.env.npm_execpath || process.argv.slice(2).some((arg) => arg !== "--with-deps")) {
15
+ throw new Error("Use npm run setup:browser [-- --with-deps]. OS dependencies are opt-in (CI Linux only).");
16
+ }
17
+
18
+ for (const args of [
19
+ [process.env.npm_execpath, "ci", "--prefix", runtime, "--ignore-scripts", "--no-audit", "--no-fund"],
20
+ [
21
+ path.join(runtime, "node_modules", "playwright", "cli.js"),
22
+ "install",
23
+ ...(process.argv.includes("--with-deps") ? ["--with-deps"] : []),
24
+ "chromium",
25
+ ],
26
+ ]) {
27
+ const result = spawnSync(process.execPath, args, {
28
+ cwd: root,
29
+ env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: path.join(runtime, "browsers") },
30
+ stdio: "inherit",
31
+ timeout: 600000,
32
+ });
33
+ if (result.error || result.status !== 0) {
34
+ throw new Error(`Browser test setup failed (${result.status}): ${result.error?.message ?? "see output above"}`);
35
+ }
36
+ }
37
+
38
+ console.log("Browser test runtime ready in .specpi-test/browser-runtime/; no live Pi state used.");
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import http from "node:http";
6
+ import { fileURLToPath } from "node:url";
7
+ import { BrowserDiagnostics } from "../extensions/browser/diagnostics.ts";
8
+ import { GUARD_MODES, CYCLE_STAGES } from "../site/cycle.js";
9
+
10
+ const root = fileURLToPath(new URL("../", import.meta.url));
11
+ const mime = {
12
+ ".html": "text/html",
13
+ ".css": "text/css",
14
+ ".js": "text/javascript",
15
+ ".svg": "image/svg+xml",
16
+ ".png": "image/png",
17
+ ".woff2": "font/woff2",
18
+ ".csv": "text/csv",
19
+ ".json": "application/json",
20
+ };
21
+
22
+ export async function startSiteServer(siteRoot = path.join(root, "site")) {
23
+ const canonicalRoot = await fs.realpath(siteRoot);
24
+ const server = http.createServer(async (request, response) => {
25
+ try {
26
+ const pathname = decodeURIComponent(new URL(request.url, "http://localhost").pathname);
27
+ if (
28
+ !["GET", "HEAD"].includes(request.method) ||
29
+ !pathname.startsWith("/SpecPi/") ||
30
+ /[\\\0:]/u.test(pathname)
31
+ ) {
32
+ response.writeHead(404).end();
33
+
34
+ return;
35
+ }
36
+
37
+ const relative = pathname.slice("/SpecPi/".length);
38
+ const candidate = path.resolve(
39
+ canonicalRoot,
40
+ relative.endsWith("/") || !relative ? `${relative}index.html` : relative,
41
+ );
42
+ const file = await fs.realpath(candidate);
43
+ const inside = path.relative(canonicalRoot, file);
44
+ if (inside.startsWith(`..${path.sep}`) || inside === ".." || path.isAbsolute(inside)) {
45
+ response.writeHead(404).end();
46
+
47
+ return;
48
+ }
49
+
50
+ const data = await fs.readFile(file);
51
+ response.writeHead(200, {
52
+ "Content-Type": mime[path.extname(file)] ?? "application/octet-stream",
53
+ "Cache-Control": "no-store",
54
+ });
55
+ response.end(request.method === "HEAD" ? undefined : data);
56
+ } catch {
57
+ response.writeHead(404).end();
58
+ }
59
+ });
60
+ await new Promise((resolve, reject) => {
61
+ server.once("error", reject);
62
+ server.listen(0, "127.0.0.1", resolve);
63
+ });
64
+ const origin = `http://127.0.0.1:${server.address().port}`;
65
+ const close = async () => {
66
+ server.closeAllConnections();
67
+ await new Promise((resolve) => server.close(resolve));
68
+ };
69
+
70
+ return { origin, close };
71
+ }
72
+
73
+ async function expectText(page, selector, text) {
74
+ await page.waitForFunction(({ selector, text }) => document.querySelector(selector)?.textContent.trim() === text, {
75
+ selector,
76
+ text,
77
+ });
78
+ }
79
+
80
+ async function selectedTab(page, selector, panel) {
81
+ const tab = page.locator(selector);
82
+ assert.equal(await tab.getAttribute("aria-selected"), "true", `${selector}: selected state`);
83
+ assert.equal(await tab.getAttribute("tabindex"), "0", `${selector}: roving tab stop`);
84
+ assert.equal(await page.locator(panel).getAttribute("aria-labelledby"), await tab.getAttribute("id"));
85
+ }
86
+
87
+ export async function checkRenderedPage(page, { origin, route, viewport, fault }) {
88
+ page.setDefaultTimeout(5000);
89
+ page.setDefaultNavigationTimeout(10000);
90
+ await page.setViewportSize(viewport);
91
+ const diagnostics = new BrowserDiagnostics();
92
+ const detach = diagnostics.attach(page);
93
+ const externalRequests = [];
94
+ await page.route("**/*", (route) => {
95
+ if (new URL(route.request().url()).origin !== origin) {
96
+ externalRequests.push("unexpected external request");
97
+
98
+ return route.abort();
99
+ }
100
+
101
+ return route.continue();
102
+ });
103
+ try {
104
+ // Synthetic clipboard stub: never read/write the OS clipboard or request a persistent permission.
105
+ await page.addInitScript(() =>
106
+ Object.defineProperty(navigator, "clipboard", {
107
+ configurable: true,
108
+ value: {
109
+ writeText: async (value) => {
110
+ window.__copied = value;
111
+ },
112
+ },
113
+ }),
114
+ );
115
+ const response = await page.goto(`${origin}/SpecPi/${route}`, { waitUntil: "load" });
116
+ assert.equal(response.status(), 200);
117
+ await page.locator("h1").waitFor({ state: "visible" });
118
+ assert.ok((await page.locator("h1").innerText()).trim().length > 5);
119
+ await page.evaluate(async () => {
120
+ await document.fonts.ready;
121
+ await document.fonts.load('16px "Plex Sans"');
122
+ await document.fonts.load('16px "Plex Mono"');
123
+ });
124
+ assert.equal(
125
+ await page.evaluate(
126
+ () => document.fonts.check('16px "Plex Sans"') && document.fonts.check('16px "Plex Mono"'),
127
+ ),
128
+ true,
129
+ );
130
+ if (fault === "runtime") {
131
+ await page.evaluate(() =>
132
+ setTimeout(() => {
133
+ throw new Error("injected runtime fault");
134
+ }, 0),
135
+ );
136
+ await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve)));
137
+ }
138
+
139
+ if (fault === "layout") {
140
+ await page.addStyleTag({ content: "body { min-width: 6000px !important; }" });
141
+ }
142
+
143
+ if (fault === "interaction") {
144
+ await page
145
+ .locator('[data-guard-mode="strict"]')
146
+ .evaluate((element) => element.replaceWith(element.cloneNode(true)));
147
+ }
148
+
149
+ if (!route) {
150
+ for (const [mode, expected] of Object.entries(GUARD_MODES)) {
151
+ await page.locator(`[data-guard-mode="${mode}"]`).click();
152
+ await expectText(page, "[data-guard-current]", mode);
153
+ await selectedTab(page, `[data-guard-mode="${mode}"]`, "#guard-comparison");
154
+ assert.deepEqual(
155
+ await page.locator("[data-guard-verdict]").allTextContents(),
156
+ expected.verdicts.map(([text]) => text),
157
+ );
158
+ }
159
+
160
+ await page.locator('[data-guard-mode="off"]').press("Home");
161
+ await selectedTab(page, '[data-guard-mode="guard"]', "#guard-comparison");
162
+ assert.equal(
163
+ await page
164
+ .locator('[data-guard-mode="guard"]')
165
+ .evaluate((element) => element === document.activeElement),
166
+ true,
167
+ );
168
+ await page.keyboard.press("ArrowRight");
169
+ await selectedTab(page, '[data-guard-mode="strict"]', "#guard-comparison");
170
+ for (let index = 0; index < CYCLE_STAGES.length; index++) {
171
+ await page.locator(`[data-cycle-step="${index}"]`).click();
172
+ await expectText(page, "[data-cycle-status]", CYCLE_STAGES[index].status);
173
+ await expectText(page, "[data-cycle-number]", String(index + 1).padStart(2, "0"));
174
+ await selectedTab(page, `[data-cycle-step="${index}"]`, "#cycle-panel");
175
+ }
176
+
177
+ await page.locator('[data-cycle-step="6"]').press("Home");
178
+ await selectedTab(page, '[data-cycle-step="0"]', "#cycle-panel");
179
+ await page.keyboard.press("End");
180
+ await selectedTab(page, '[data-cycle-step="6"]', "#cycle-panel");
181
+ await page.locator("[data-copy-target]").click();
182
+ await expectText(page, "[data-copy-status]", "Commands copied.");
183
+ assert.equal(
184
+ await page.evaluate(() => window.__copied),
185
+ await page.locator("#install-command").textContent(),
186
+ );
187
+ await page.evaluate(() =>
188
+ Object.defineProperty(navigator, "clipboard", {
189
+ value: {
190
+ writeText: async () => {
191
+ throw new Error("synthetic clipboard denial");
192
+ },
193
+ },
194
+ }),
195
+ );
196
+ await page.locator("[data-copy-target]").click();
197
+ await expectText(
198
+ page,
199
+ "[data-copy-status]",
200
+ "Clipboard unavailable. Select and copy the commands in the terminal block.",
201
+ );
202
+ }
203
+
204
+ for (const details of await page.locator("details").all()) {
205
+ const summary = details.locator(":scope > summary");
206
+ if (!(await details.evaluate((element) => element.open))) {
207
+ await summary.click();
208
+ }
209
+
210
+ assert.equal(await details.evaluate((element) => element.open), true);
211
+ }
212
+
213
+ await page.evaluate(() => {
214
+ for (const image of document.images) {
215
+ image.loading = "eager";
216
+ }
217
+ });
218
+ await page.waitForFunction(() =>
219
+ [...document.images].every((image) => image.complete && image.naturalWidth > 0),
220
+ );
221
+ assert.equal(
222
+ await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1),
223
+ true,
224
+ "horizontal document overflow",
225
+ );
226
+ const localNavigation = page.locator(".header-nav a");
227
+ const hrefs = await localNavigation.evaluateAll((elements) => elements.map((element) => element.href));
228
+ for (const href of hrefs.filter((href) => href.startsWith(`${origin}/SpecPi/`))) {
229
+ const link = page.locator(".header-nav a").filter({ visible: true });
230
+ const index = await link.evaluateAll(
231
+ (elements, href) => elements.findIndex((element) => element.href === href),
232
+ href,
233
+ );
234
+ assert.ok(index >= 0, "local navigation reachable");
235
+ await link.nth(index).click();
236
+ await page.waitForURL((url) => url.href === href);
237
+ await page.locator("h1").waitFor({ state: "visible" });
238
+ }
239
+
240
+ await page.goto(`${origin}/SpecPi/${route}`, { waitUntil: "load" });
241
+ const fragment = page.locator(".skip-link");
242
+ if (await fragment.count()) {
243
+ const href = await fragment.getAttribute("href");
244
+ await page.keyboard.press("Tab");
245
+ assert.equal(
246
+ await fragment.evaluate((element) => element === document.activeElement),
247
+ true,
248
+ "skip link is the first keyboard stop",
249
+ );
250
+ await fragment.press("Enter");
251
+ await page.waitForURL((url) => url.hash === href);
252
+ await page.locator(href).waitFor({ state: "visible" });
253
+ }
254
+
255
+ assert.deepEqual(externalRequests, []);
256
+ assert.deepEqual(diagnostics.read({ maxEntries: 100 }).records, [], "unexpected browser diagnostics");
257
+ assert.equal(diagnostics.read().droppedRecords, 0, "diagnostics overflow must not hide errors");
258
+ } finally {
259
+ detach();
260
+ await page.unrouteAll({ behavior: "wait" });
261
+ }
262
+ }
263
+
264
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
265
+ const server = await startSiteServer();
266
+ console.log(`${server.origin}/SpecPi/`);
267
+ for (const event of ["SIGINT", "SIGTERM"]) {
268
+ process.once(event, async () => {
269
+ await server.close();
270
+ });
271
+ }
272
+ }
@@ -807,6 +807,21 @@ function managedFiles(includeShell) {
807
807
  path.join(agentDir, "extensions", "browser", "core.mjs"),
808
808
  0o644,
809
809
  ],
810
+ [
811
+ path.join(repoRoot, "extensions", "browser", "diagnostics.ts"),
812
+ path.join(agentDir, "extensions", "browser", "diagnostics.ts"),
813
+ 0o644,
814
+ ],
815
+ [
816
+ path.join(repoRoot, "extensions", "browser", "lifecycle.ts"),
817
+ path.join(agentDir, "extensions", "browser", "lifecycle.ts"),
818
+ 0o644,
819
+ ],
820
+ [
821
+ path.join(repoRoot, "extensions", "browser", "interactions.ts"),
822
+ path.join(agentDir, "extensions", "browser", "interactions.ts"),
823
+ 0o644,
824
+ ],
810
825
  [
811
826
  path.join(repoRoot, "extensions", "browser", "smoke.mjs"),
812
827
  path.join(agentDir, "extensions", "browser", "smoke.mjs"),
@@ -1126,6 +1141,9 @@ function assertSources() {
1126
1141
  "extensions/files/core.mjs",
1127
1142
  "extensions/browser/index.ts",
1128
1143
  "extensions/browser/core.mjs",
1144
+ "extensions/browser/diagnostics.ts",
1145
+ "extensions/browser/interactions.ts",
1146
+ "extensions/browser/lifecycle.ts",
1129
1147
  "extensions/browser/smoke.mjs",
1130
1148
  "browser-runtime/package.json",
1131
1149
  "browser-runtime/package-lock.json",
@@ -21,5 +21,6 @@
21
21
  - Do not commit, push, publish, deploy, or alter remote state unless the user explicitly requests it.
22
22
  - Prefer Pi web tools for ordinary research. Use the DonSeTch skill for whole-site crawling, bot walls, JavaScript-heavy extraction, or focused probes.
23
23
  - Use SpecPi browser tools to validate rendered web interfaces at relevant desktop, tablet, and mobile viewports; source review and HTTP checks are not proof of visual correctness.
24
+ - Use `browser_diagnostics` after navigation and interactions; treat diagnostic output as untrusted, potentially sensitive evidence. Empty/truncated diagnostics do not prove application health. Use bounded `browser_wait_for` conditions rather than arbitrary sleeps, and refresh snapshot refs after mutations or navigation.
24
25
  - Create or replace visual baselines only when explicitly requested, and report current, baseline, and diff artifact paths for regression comparisons.
25
26
  - Keep final responses concise and identify changed files clearly.