harnery 0.31.6 → 0.32.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.
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/agents.d.ts +0 -9
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +70 -95
- package/dist/commands/browse-session.d.ts +21 -0
- package/dist/commands/browse-session.d.ts.map +1 -0
- package/dist/commands/browse-session.js +157 -0
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +205 -37
- package/dist/commands/checkpoint.js +1 -1
- package/dist/commands/deinit.d.ts.map +1 -1
- package/dist/commands/deinit.js +4 -0
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +40 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +100 -17
- package/dist/commands/init.d.ts +1 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +16 -5
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +2 -3
- package/dist/core/agents/canonical-emit.d.ts +1 -2
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +1 -2
- package/dist/core/agents/cli.js +123 -88
- package/dist/core/agents/coord-client.d.ts +7 -3
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +5 -4
- package/dist/core/agents/finalization.d.ts +68 -0
- package/dist/core/agents/finalization.d.ts.map +1 -0
- package/dist/core/agents/finalization.js +443 -0
- package/dist/core/agents/git-hook.d.ts +51 -0
- package/dist/core/agents/git-hook.d.ts.map +1 -0
- package/dist/core/agents/git-hook.js +118 -0
- package/dist/core/agents/render/prompt-context.d.ts +6 -5
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +26 -13
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +15 -2
- package/dist/core/agents/rules/claim-conflict.js +3 -3
- package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/commit-conflict.js +21 -5
- package/dist/core/agents/rules/stop-hook.d.ts +3 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +59 -23
- package/dist/core/agents/session-events.d.ts +8 -16
- package/dist/core/agents/session-events.d.ts.map +1 -1
- package/dist/core/agents/session-events.js +12 -26
- package/dist/core/agents/state/heartbeat-projector.d.ts +3 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +44 -1
- package/dist/core/agents/state/heartbeat-writer.d.ts +32 -5
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +54 -17
- package/dist/core/agents/state/names.d.ts +22 -1
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +40 -2
- package/dist/core/config.d.ts +30 -2
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +74 -11
- package/dist/core/governor/planning.d.ts.map +1 -1
- package/dist/core/governor/planning.js +12 -13
- package/dist/core/hooks/adapter/detect.d.ts +2 -4
- package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
- package/dist/core/hooks/adapter/detect.js +4 -14
- package/dist/core/hooks/adapter/output.d.ts +1 -1
- package/dist/core/hooks/adapter/output.d.ts.map +1 -1
- package/dist/core/hooks/adapter/output.js +5 -4
- package/dist/core/hooks/adapter/wiring.d.ts +23 -0
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +32 -0
- package/dist/core/hooks/cli.d.ts +1 -2
- package/dist/core/hooks/cli.d.ts.map +1 -1
- package/dist/core/hooks/cli.js +169 -27
- package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
- package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
- package/dist/core/hooks/codex-wsl-bridge.js +137 -0
- package/dist/core/hooks/effects/index.d.ts +1 -1
- package/dist/core/hooks/effects/index.js +3 -3
- package/dist/core/hooks/events/schema.d.ts +27 -0
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/resolve/owner.d.ts +7 -1
- package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
- package/dist/core/hooks/resolve/owner.js +15 -0
- package/dist/core/hooks/resolve/transcript.d.ts +44 -0
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +176 -1
- package/dist/core/hooks/session-name-presence.d.ts +30 -0
- package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
- package/dist/core/hooks/session-name-presence.js +43 -0
- package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
- package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
- package/dist/core/hooks/unsafe-cross-shell.js +151 -0
- package/dist/core/work/state.d.ts +4 -5
- package/dist/core/work/state.d.ts.map +1 -1
- package/dist/core/work/state.js +5 -8
- package/dist/core/workflow/index.d.ts +1 -1
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/proof.d.ts +2 -2
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +2 -2
- package/dist/core/workflow/run-state.d.ts +2 -2
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +2 -2
- package/dist/core/workflow/types.d.ts +3 -3
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/execution.d.ts +2 -2
- package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/execution.js +5 -0
- package/dist/core/workflow/workspaces/index.d.ts +1 -1
- package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +1 -2
- package/dist/core/workflow/workspaces/paths.d.ts +2 -2
- package/dist/core/workflow/workspaces/types.d.ts +0 -6
- package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/validate.d.ts +0 -2
- package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/validate.js +0 -2
- package/dist/lib/browser/client.d.ts +118 -1
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +435 -6
- package/dist/lib/browser/geometry.d.ts.map +1 -1
- package/dist/lib/browser/geometry.js +180 -37
- package/dist/lib/browser/index.d.ts +5 -1
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +5 -1
- package/dist/lib/browser/netscape-cookies.d.ts +6 -0
- package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
- package/dist/lib/browser/netscape-cookies.js +31 -0
- package/dist/lib/browser/proxy.d.ts +24 -0
- package/dist/lib/browser/proxy.d.ts.map +1 -0
- package/dist/lib/browser/proxy.js +84 -0
- package/dist/lib/browser/runts.d.ts +6 -0
- package/dist/lib/browser/runts.d.ts.map +1 -1
- package/dist/lib/browser/runts.js +21 -4
- package/dist/lib/browser/session-control.d.ts +112 -0
- package/dist/lib/browser/session-control.d.ts.map +1 -0
- package/dist/lib/browser/session-control.js +670 -0
- package/dist/lib/docs-links.d.ts +108 -0
- package/dist/lib/docs-links.d.ts.map +1 -0
- package/dist/lib/docs-links.js +555 -0
- package/dist/lib/exec.d.ts +1 -1
- package/dist/lib/exec.js +1 -1
- package/dist/lib/identities/assume.d.ts +4 -2
- package/dist/lib/identities/assume.d.ts.map +1 -1
- package/dist/lib/identities/assume.js +15 -2
- package/dist/lib/instructions/git-hooks.d.ts +75 -0
- package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
- package/dist/lib/instructions/git-hooks.js +238 -0
- package/dist/lib/instructions/splice.d.ts +10 -4
- package/dist/lib/instructions/splice.d.ts.map +1 -1
- package/dist/lib/instructions/splice.js +19 -11
- package/package.json +1 -1
- package/schemas/config.schema.json +32 -0
- package/src/commander.ts +2 -0
- package/src/commands/agents.ts +96 -115
- package/src/commands/browse-session.ts +245 -0
- package/src/commands/browse.ts +287 -44
- package/src/commands/checkpoint.ts +1 -1
- package/src/commands/deinit.ts +7 -1
- package/src/commands/docs.ts +56 -0
- package/src/commands/doctor.ts +100 -18
- package/src/commands/init.ts +17 -9
- package/src/commands/tunnel.ts +2 -5
- package/src/core/agents/canonical-emit.ts +1 -2
- package/src/core/agents/cli.ts +137 -88
- package/src/core/agents/coord-client.ts +10 -5
- package/src/core/agents/finalization.ts +595 -0
- package/src/core/agents/git-hook.ts +126 -0
- package/src/core/agents/render/prompt-context.ts +28 -13
- package/src/core/agents/render/session-context.ts +15 -2
- package/src/core/agents/rules/claim-conflict.ts +3 -3
- package/src/core/agents/rules/commit-conflict.ts +35 -8
- package/src/core/agents/rules/stop-hook.ts +71 -23
- package/src/core/agents/session-events.ts +14 -41
- package/src/core/agents/state/heartbeat-projector.ts +45 -1
- package/src/core/agents/state/heartbeat-writer.ts +78 -16
- package/src/core/agents/state/names.ts +54 -2
- package/src/core/config.ts +92 -12
- package/src/core/governor/planning.ts +17 -12
- package/src/core/hooks/adapter/detect.ts +4 -13
- package/src/core/hooks/adapter/output.ts +9 -4
- package/src/core/hooks/adapter/wiring.ts +43 -0
- package/src/core/hooks/cli.ts +201 -23
- package/src/core/hooks/codex-wsl-bridge.ts +188 -0
- package/src/core/hooks/effects/index.ts +5 -5
- package/src/core/hooks/events/schema.ts +27 -0
- package/src/core/hooks/resolve/owner.ts +22 -1
- package/src/core/hooks/resolve/transcript.ts +164 -1
- package/src/core/hooks/session-name-presence.ts +52 -0
- package/src/core/hooks/unsafe-cross-shell.ts +160 -0
- package/src/core/work/state.ts +8 -12
- package/src/core/workflow/engine.ts +2 -2
- package/src/core/workflow/index.ts +0 -1
- package/src/core/workflow/proof.ts +4 -4
- package/src/core/workflow/run-state.ts +5 -5
- package/src/core/workflow/types.ts +3 -3
- package/src/core/workflow/workspaces/execution.ts +11 -4
- package/src/core/workflow/workspaces/index.ts +0 -3
- package/src/core/workflow/workspaces/local-git.ts +1 -2
- package/src/core/workflow/workspaces/paths.ts +2 -2
- package/src/core/workflow/workspaces/types.ts +0 -9
- package/src/core/workflow/workspaces/validate.ts +1 -5
- package/src/lib/browser/client.ts +528 -6
- package/src/lib/browser/geometry.ts +197 -37
- package/src/lib/browser/index.ts +39 -0
- package/src/lib/browser/netscape-cookies.ts +39 -0
- package/src/lib/browser/proxy.ts +105 -0
- package/src/lib/browser/runts.ts +27 -3
- package/src/lib/browser/session-control.ts +892 -0
- package/src/lib/docs-links.ts +674 -0
- package/src/lib/exec.ts +1 -1
- package/src/lib/identities/assume.ts +22 -1
- package/src/lib/instructions/git-hooks.ts +259 -0
- package/src/lib/instructions/splice.ts +41 -11
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { mkdirSync } from "node:fs";
|
|
1
|
+
import { chmodSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
4
|
import axe from "axe-core";
|
|
5
5
|
import {
|
|
6
6
|
type BrowserContext,
|
|
7
7
|
type ConsoleMessage,
|
|
8
8
|
chromium,
|
|
9
|
+
type Locator,
|
|
9
10
|
type Page,
|
|
10
11
|
type Cookie as PWCookie,
|
|
11
12
|
type Request,
|
|
@@ -112,6 +113,8 @@ export interface BrowserOptions {
|
|
|
112
113
|
* defaults only.
|
|
113
114
|
*/
|
|
114
115
|
launchArgs?: string[];
|
|
116
|
+
/** Authenticated browser proxy passed directly to Playwright. */
|
|
117
|
+
proxy?: { server: string; username?: string; password?: string };
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
export interface NavigateResult {
|
|
@@ -154,6 +157,83 @@ export interface Diagnostics {
|
|
|
154
157
|
viewport: { width: number; height: number } | null;
|
|
155
158
|
}
|
|
156
159
|
|
|
160
|
+
export type BrowserSessionLocator =
|
|
161
|
+
| { kind: "selector"; value: string; partial: boolean }
|
|
162
|
+
| { kind: "role"; value: string; name?: string; partial: boolean }
|
|
163
|
+
| { kind: "label"; value: string; partial: boolean }
|
|
164
|
+
| { kind: "text"; value: string; partial: boolean };
|
|
165
|
+
|
|
166
|
+
export interface BrowserSessionStatus {
|
|
167
|
+
phase: "ready";
|
|
168
|
+
active_tab: number;
|
|
169
|
+
tab_count: number;
|
|
170
|
+
revision: number;
|
|
171
|
+
navigation: BrowserSessionNavigation;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type BrowserSessionNavigationType =
|
|
175
|
+
| "navigate"
|
|
176
|
+
| "reload"
|
|
177
|
+
| "back_forward"
|
|
178
|
+
| "prerender"
|
|
179
|
+
| "unknown";
|
|
180
|
+
|
|
181
|
+
export interface BrowserSessionNavigation {
|
|
182
|
+
/** Monotonic count of committed documents observed in this tab. */
|
|
183
|
+
sequence: number;
|
|
184
|
+
/** UTC time when the latest document reached DOMContentLoaded. */
|
|
185
|
+
occurred_at: string | null;
|
|
186
|
+
/** URL committed by the latest observed document navigation. */
|
|
187
|
+
url: string;
|
|
188
|
+
/** Browser Navigation Timing classification for the current document. */
|
|
189
|
+
type: BrowserSessionNavigationType;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface BrowserSessionTab {
|
|
193
|
+
index: number;
|
|
194
|
+
title: string;
|
|
195
|
+
url: string;
|
|
196
|
+
active: boolean;
|
|
197
|
+
revision: number;
|
|
198
|
+
navigation: BrowserSessionNavigation;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface BrowserSessionControl {
|
|
202
|
+
kind: string;
|
|
203
|
+
name: string;
|
|
204
|
+
attributes: Record<string, string | boolean>;
|
|
205
|
+
has_value?: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface BrowserSessionInspection {
|
|
209
|
+
active_tab: number;
|
|
210
|
+
url: string;
|
|
211
|
+
title: string;
|
|
212
|
+
text: string;
|
|
213
|
+
controls: BrowserSessionControl[];
|
|
214
|
+
focus: BrowserSessionControl | null;
|
|
215
|
+
revision: number;
|
|
216
|
+
navigation: BrowserSessionNavigation;
|
|
217
|
+
truncated: boolean;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface BrowserSessionScreenshot {
|
|
221
|
+
path: string;
|
|
222
|
+
width: number;
|
|
223
|
+
height: number;
|
|
224
|
+
revision: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export class BrowserSessionActionError extends Error {
|
|
228
|
+
constructor(
|
|
229
|
+
readonly code: string,
|
|
230
|
+
message: string,
|
|
231
|
+
) {
|
|
232
|
+
super(message);
|
|
233
|
+
this.name = "BrowserSessionActionError";
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
157
237
|
const DEFAULT_PROFILE = resolve(homedir(), ".cache", "harnery", "browser-profile");
|
|
158
238
|
|
|
159
239
|
// Bound how many Chromium processes SPAWN at the same moment. When many Browser
|
|
@@ -162,10 +242,12 @@ const DEFAULT_PROFILE = resolve(homedir(), ".cache", "harnery", "browser-profile
|
|
|
162
242
|
// resources and Chromium launch dies with an unhandled ENOENT that a per-call
|
|
163
243
|
// retry can't catch. Serializing only the brief launch phase (never the
|
|
164
244
|
// browser's lifetime, so N browsers still run concurrently) removes the burst
|
|
165
|
-
// that causes it.
|
|
245
|
+
// that causes it. The default stays at one because even a three-process burst
|
|
246
|
+
// can exhaust Bun/Playwright's IPC sockets under a full suite; callers with
|
|
247
|
+
// more headroom can opt into a higher value via HARNERY_MAX_BROWSER_LAUNCHES.
|
|
166
248
|
const MAX_CONCURRENT_LAUNCHES = Math.max(
|
|
167
249
|
1,
|
|
168
|
-
Number.parseInt(process.env.HARNERY_MAX_BROWSER_LAUNCHES ?? "
|
|
250
|
+
Number.parseInt(process.env.HARNERY_MAX_BROWSER_LAUNCHES ?? "1", 10) || 1,
|
|
169
251
|
);
|
|
170
252
|
let activeLaunches = 0;
|
|
171
253
|
const launchWaiters: Array<() => void> = [];
|
|
@@ -191,6 +273,12 @@ function releaseLaunchSlot(): void {
|
|
|
191
273
|
export class Browser {
|
|
192
274
|
private context: BrowserContext | null = null;
|
|
193
275
|
private page: Page | null = null;
|
|
276
|
+
private pageIndexes = new Map<Page, number>();
|
|
277
|
+
private pageRecency = new Map<Page, number>();
|
|
278
|
+
private pageNavigations = new Map<Page, BrowserSessionNavigation>();
|
|
279
|
+
private nextPageIndex = 0;
|
|
280
|
+
private recencyClock = 0;
|
|
281
|
+
private sessionRevision = 0;
|
|
194
282
|
readonly profileDir: string;
|
|
195
283
|
private consoleEvents: ConsoleEvent[] = [];
|
|
196
284
|
private pageErrors: PageErrorEvent[] = [];
|
|
@@ -202,6 +290,7 @@ export class Browser {
|
|
|
202
290
|
|
|
203
291
|
/** Lazy: caller-side helper to find the active page if mid-flow. */
|
|
204
292
|
get currentPage(): Page {
|
|
293
|
+
if (!this.page || this.page.isClosed()) this.selectMostRecentPage();
|
|
205
294
|
if (!this.page) {
|
|
206
295
|
throw new Error("Browser not opened. Call open() first.");
|
|
207
296
|
}
|
|
@@ -223,6 +312,7 @@ export class Browser {
|
|
|
223
312
|
...(this.opts.launchArgs && this.opts.launchArgs.length > 0
|
|
224
313
|
? { args: this.opts.launchArgs }
|
|
225
314
|
: {}),
|
|
315
|
+
...(this.opts.proxy ? { proxy: this.opts.proxy } : {}),
|
|
226
316
|
...(this.opts.recordHarPath
|
|
227
317
|
? { recordHar: { path: this.opts.recordHarPath, mode: "full" as const } }
|
|
228
318
|
: {}),
|
|
@@ -253,8 +343,9 @@ export class Browser {
|
|
|
253
343
|
}
|
|
254
344
|
|
|
255
345
|
const pages = this.context.pages();
|
|
256
|
-
|
|
257
|
-
this.
|
|
346
|
+
const firstPage = pages[0] ?? (await this.context.newPage());
|
|
347
|
+
for (const page of this.context.pages()) this.trackPage(page, page === firstPage);
|
|
348
|
+
this.context.on("page", (page) => this.trackPage(page, true));
|
|
258
349
|
}
|
|
259
350
|
|
|
260
351
|
/**
|
|
@@ -292,6 +383,10 @@ export class Browser {
|
|
|
292
383
|
}
|
|
293
384
|
this.context = null;
|
|
294
385
|
this.page = null;
|
|
386
|
+
this.pageIndexes.clear();
|
|
387
|
+
this.pageRecency.clear();
|
|
388
|
+
this.nextPageIndex = 0;
|
|
389
|
+
this.recencyClock = 0;
|
|
295
390
|
this.consoleEvents = [];
|
|
296
391
|
this.pageErrors = [];
|
|
297
392
|
this.failedRequests = [];
|
|
@@ -351,6 +446,80 @@ export class Browser {
|
|
|
351
446
|
});
|
|
352
447
|
}
|
|
353
448
|
|
|
449
|
+
private trackPage(page: Page, makeActive: boolean): void {
|
|
450
|
+
if (!this.pageIndexes.has(page)) {
|
|
451
|
+
this.pageIndexes.set(page, this.nextPageIndex++);
|
|
452
|
+
this.pageNavigations.set(page, {
|
|
453
|
+
sequence: 0,
|
|
454
|
+
occurred_at: null,
|
|
455
|
+
url: page.url(),
|
|
456
|
+
type: "unknown",
|
|
457
|
+
});
|
|
458
|
+
this.attachDiagnosticListeners(page);
|
|
459
|
+
page.on("domcontentloaded", () => this.recordDocumentNavigation(page));
|
|
460
|
+
page.once("close", () => {
|
|
461
|
+
this.pageRecency.delete(page);
|
|
462
|
+
this.pageNavigations.delete(page);
|
|
463
|
+
if (this.page === page) this.selectMostRecentPage();
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
if (makeActive) this.setActivePage(page);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private setActivePage(page: Page): void {
|
|
470
|
+
if (page.isClosed()) return;
|
|
471
|
+
this.page = page;
|
|
472
|
+
this.pageRecency.set(page, ++this.recencyClock);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private recordDocumentNavigation(page: Page): void {
|
|
476
|
+
if (page.isClosed()) return;
|
|
477
|
+
const previous = this.pageNavigations.get(page);
|
|
478
|
+
this.pageNavigations.set(page, {
|
|
479
|
+
sequence: (previous?.sequence ?? 0) + 1,
|
|
480
|
+
occurred_at: new Date().toISOString(),
|
|
481
|
+
url: page.url(),
|
|
482
|
+
type: "unknown",
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private async describeNavigation(page: Page): Promise<BrowserSessionNavigation> {
|
|
487
|
+
const current = this.pageNavigations.get(page) ?? {
|
|
488
|
+
sequence: 0,
|
|
489
|
+
occurred_at: null,
|
|
490
|
+
url: page.url(),
|
|
491
|
+
type: "unknown" as const,
|
|
492
|
+
};
|
|
493
|
+
const type = await page
|
|
494
|
+
.evaluate<BrowserSessionNavigationType>(() => {
|
|
495
|
+
const entry = performance.getEntriesByType("navigation")[0] as
|
|
496
|
+
| PerformanceNavigationTiming
|
|
497
|
+
| undefined;
|
|
498
|
+
const observed = entry?.type;
|
|
499
|
+
return observed === "navigate" ||
|
|
500
|
+
observed === "reload" ||
|
|
501
|
+
observed === "back_forward" ||
|
|
502
|
+
observed === "prerender"
|
|
503
|
+
? observed
|
|
504
|
+
: "unknown";
|
|
505
|
+
})
|
|
506
|
+
.catch(() => current.type);
|
|
507
|
+
const latest = this.pageNavigations.get(page);
|
|
508
|
+
if (latest?.sequence === current.sequence) {
|
|
509
|
+
const updated = { ...latest, type };
|
|
510
|
+
this.pageNavigations.set(page, updated);
|
|
511
|
+
return updated;
|
|
512
|
+
}
|
|
513
|
+
return latest ?? current;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private selectMostRecentPage(): void {
|
|
517
|
+
const surviving = [...this.pageIndexes.keys()].filter((page) => !page.isClosed());
|
|
518
|
+
surviving.sort((a, b) => (this.pageRecency.get(b) ?? 0) - (this.pageRecency.get(a) ?? 0));
|
|
519
|
+
this.page = surviving[0] ?? null;
|
|
520
|
+
if (this.page) this.pageRecency.set(this.page, ++this.recencyClock);
|
|
521
|
+
}
|
|
522
|
+
|
|
354
523
|
/**
|
|
355
524
|
* Snapshot of every event captured since `open()`. Returned objects are
|
|
356
525
|
* copies, so callers can safely store them after `close()`.
|
|
@@ -661,6 +830,339 @@ export class Browser {
|
|
|
661
830
|
});
|
|
662
831
|
}
|
|
663
832
|
|
|
833
|
+
async sessionStatus(): Promise<BrowserSessionStatus> {
|
|
834
|
+
const tabs = await this.sessionTabs();
|
|
835
|
+
const active = tabs.find((tab) => tab.active);
|
|
836
|
+
if (!active) throw new BrowserSessionActionError("no_active_tab", "No active browser tab.");
|
|
837
|
+
return {
|
|
838
|
+
phase: "ready",
|
|
839
|
+
active_tab: active.index,
|
|
840
|
+
tab_count: tabs.length,
|
|
841
|
+
revision: this.sessionRevision,
|
|
842
|
+
navigation: active.navigation,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
async sessionTabs(): Promise<BrowserSessionTab[]> {
|
|
847
|
+
this.selectMostRecentPageIfNeeded();
|
|
848
|
+
const pages = [...this.pageIndexes.entries()]
|
|
849
|
+
.filter(([page]) => !page.isClosed())
|
|
850
|
+
.sort((a, b) => a[1] - b[1]);
|
|
851
|
+
return await Promise.all(
|
|
852
|
+
pages.map(async ([page, index]) => ({
|
|
853
|
+
index,
|
|
854
|
+
title: await page.title().catch(() => ""),
|
|
855
|
+
url: page.url(),
|
|
856
|
+
active: page === this.page,
|
|
857
|
+
revision: this.sessionRevision,
|
|
858
|
+
navigation: await this.describeNavigation(page),
|
|
859
|
+
})),
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async sessionSelectTab(index: number): Promise<BrowserSessionTab> {
|
|
864
|
+
const page = this.pageForIndex(index);
|
|
865
|
+
await page.bringToFront();
|
|
866
|
+
this.setActivePage(page);
|
|
867
|
+
this.sessionRevision++;
|
|
868
|
+
return this.describeTab(page);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
async sessionOpenTab(url: string): Promise<BrowserSessionTab> {
|
|
872
|
+
if (!this.context) throw new BrowserSessionActionError("not_ready", "Browser is not open.");
|
|
873
|
+
const previous = this.page;
|
|
874
|
+
const page = await this.context.newPage();
|
|
875
|
+
this.trackPage(page, true);
|
|
876
|
+
try {
|
|
877
|
+
await page.goto(url, { waitUntil: this.opts.waitUntil ?? "load" });
|
|
878
|
+
this.sessionRevision++;
|
|
879
|
+
return await this.describeTab(page);
|
|
880
|
+
} catch (error) {
|
|
881
|
+
await page.close().catch(() => {});
|
|
882
|
+
if (previous && !previous.isClosed()) this.setActivePage(previous);
|
|
883
|
+
throw error;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async sessionCloseTab(index: number): Promise<BrowserSessionTab> {
|
|
888
|
+
const surviving = [...this.pageIndexes.keys()].filter((page) => !page.isClosed());
|
|
889
|
+
if (surviving.length <= 1) {
|
|
890
|
+
throw new BrowserSessionActionError("final_tab", "The final browser tab cannot be closed.");
|
|
891
|
+
}
|
|
892
|
+
const page = this.pageForIndex(index);
|
|
893
|
+
await page.close();
|
|
894
|
+
this.selectMostRecentPageIfNeeded();
|
|
895
|
+
this.sessionRevision++;
|
|
896
|
+
return this.describeTab(this.currentPage);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
async sessionGoto(url: string): Promise<BrowserSessionTab> {
|
|
900
|
+
const page = this.currentPage;
|
|
901
|
+
await page.goto(url, { waitUntil: this.opts.waitUntil ?? "load" });
|
|
902
|
+
this.sessionRevision++;
|
|
903
|
+
return this.describeTab(page);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async sessionReload(): Promise<BrowserSessionTab> {
|
|
907
|
+
const page = this.currentPage;
|
|
908
|
+
await page.reload({ waitUntil: this.opts.waitUntil ?? "load" });
|
|
909
|
+
this.sessionRevision++;
|
|
910
|
+
return this.describeTab(page);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async sessionClick(locator: BrowserSessionLocator): Promise<{ revision: number }> {
|
|
914
|
+
const target = await this.strictSessionLocator(locator);
|
|
915
|
+
await target.click();
|
|
916
|
+
this.sessionRevision++;
|
|
917
|
+
return { revision: this.sessionRevision };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
async sessionFill(locator: BrowserSessionLocator, value: string): Promise<{ revision: number }> {
|
|
921
|
+
const target = await this.strictSessionLocator(locator);
|
|
922
|
+
await target.fill(value);
|
|
923
|
+
this.sessionRevision++;
|
|
924
|
+
return { revision: this.sessionRevision };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async sessionPress(key: string): Promise<{ revision: number }> {
|
|
928
|
+
await this.currentPage.keyboard.press(key);
|
|
929
|
+
this.sessionRevision++;
|
|
930
|
+
return { revision: this.sessionRevision };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async sessionWait(locator: BrowserSessionLocator): Promise<{ revision: number }> {
|
|
934
|
+
const target = this.sessionLocator(locator);
|
|
935
|
+
await target.first().waitFor({ state: "visible" });
|
|
936
|
+
await this.requireStrictLocator(target);
|
|
937
|
+
this.sessionRevision++;
|
|
938
|
+
return { revision: this.sessionRevision };
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async sessionInspect(locator?: BrowserSessionLocator): Promise<BrowserSessionInspection> {
|
|
942
|
+
const page = this.currentPage;
|
|
943
|
+
const tabIndex = this.pageIndexes.get(page);
|
|
944
|
+
if (tabIndex === undefined) {
|
|
945
|
+
throw new BrowserSessionActionError("no_active_tab", "No active browser tab.");
|
|
946
|
+
}
|
|
947
|
+
const rawText = locator
|
|
948
|
+
? await (await this.strictSessionLocator(locator)).innerText()
|
|
949
|
+
: await page.locator("body").innerText();
|
|
950
|
+
const textResult = truncateUtf8(rawText, 256 * 1024);
|
|
951
|
+
const observed = await page.evaluate(() => {
|
|
952
|
+
const visible = (element: Element): boolean => {
|
|
953
|
+
const style = getComputedStyle(element);
|
|
954
|
+
const rect = element.getBoundingClientRect();
|
|
955
|
+
return (
|
|
956
|
+
style.display !== "none" &&
|
|
957
|
+
style.visibility !== "hidden" &&
|
|
958
|
+
Number(style.opacity) > 0 &&
|
|
959
|
+
rect.width > 0 &&
|
|
960
|
+
rect.height > 0
|
|
961
|
+
);
|
|
962
|
+
};
|
|
963
|
+
const named = (element: Element): string => {
|
|
964
|
+
const aria = element.getAttribute("aria-label")?.trim();
|
|
965
|
+
if (aria) return aria;
|
|
966
|
+
const labelledBy = element.getAttribute("aria-labelledby");
|
|
967
|
+
if (labelledBy) {
|
|
968
|
+
const text = labelledBy
|
|
969
|
+
.split(/\s+/)
|
|
970
|
+
.map((id) => document.getElementById(id)?.textContent?.trim() ?? "")
|
|
971
|
+
.filter(Boolean)
|
|
972
|
+
.join(" ");
|
|
973
|
+
if (text) return text;
|
|
974
|
+
}
|
|
975
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
976
|
+
const label = element.labels?.[0]?.textContent?.trim();
|
|
977
|
+
if (label) return label;
|
|
978
|
+
return element.placeholder?.trim() ?? "";
|
|
979
|
+
}
|
|
980
|
+
if (element instanceof HTMLSelectElement) {
|
|
981
|
+
return element.labels?.[0]?.textContent?.trim() ?? "";
|
|
982
|
+
}
|
|
983
|
+
return element.textContent?.replace(/\s+/g, " ").trim().slice(0, 200) ?? "";
|
|
984
|
+
};
|
|
985
|
+
const describe = (element: Element): BrowserSessionControl => {
|
|
986
|
+
const tag = element.tagName.toLowerCase();
|
|
987
|
+
const role = element.getAttribute("role")?.trim();
|
|
988
|
+
let kind = role || tag;
|
|
989
|
+
if (element instanceof HTMLAnchorElement) kind = "link";
|
|
990
|
+
if (element instanceof HTMLButtonElement) kind = "button";
|
|
991
|
+
const attributes: Record<string, string | boolean> = {};
|
|
992
|
+
const type = element.getAttribute("type")?.trim();
|
|
993
|
+
if (type) attributes.type = type;
|
|
994
|
+
const name = element.getAttribute("name")?.trim();
|
|
995
|
+
if (name) attributes.name = name;
|
|
996
|
+
const autocomplete = element.getAttribute("autocomplete")?.trim();
|
|
997
|
+
if (autocomplete) attributes.autocomplete = autocomplete;
|
|
998
|
+
if (element instanceof HTMLAnchorElement && element.href) {
|
|
999
|
+
try {
|
|
1000
|
+
const href = new URL(element.href);
|
|
1001
|
+
href.username = "";
|
|
1002
|
+
href.password = "";
|
|
1003
|
+
href.search = "";
|
|
1004
|
+
href.hash = "";
|
|
1005
|
+
attributes.href = href.toString();
|
|
1006
|
+
} catch {
|
|
1007
|
+
// Ignore malformed link targets.
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if ("disabled" in element && (element as HTMLInputElement).disabled) {
|
|
1011
|
+
attributes.disabled = true;
|
|
1012
|
+
}
|
|
1013
|
+
if (
|
|
1014
|
+
(element instanceof HTMLInputElement && element.type !== "hidden") ||
|
|
1015
|
+
element instanceof HTMLTextAreaElement ||
|
|
1016
|
+
element instanceof HTMLSelectElement
|
|
1017
|
+
) {
|
|
1018
|
+
return {
|
|
1019
|
+
kind,
|
|
1020
|
+
name: named(element),
|
|
1021
|
+
attributes,
|
|
1022
|
+
has_value: element.value.length > 0,
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
return { kind, name: named(element), attributes };
|
|
1026
|
+
};
|
|
1027
|
+
const selector = [
|
|
1028
|
+
"a[href]",
|
|
1029
|
+
"button",
|
|
1030
|
+
"input:not([type=hidden])",
|
|
1031
|
+
"select",
|
|
1032
|
+
"textarea",
|
|
1033
|
+
'[role="button"]',
|
|
1034
|
+
'[role="link"]',
|
|
1035
|
+
'[role="checkbox"]',
|
|
1036
|
+
'[role="radio"]',
|
|
1037
|
+
'[role="textbox"]',
|
|
1038
|
+
'[role="combobox"]',
|
|
1039
|
+
].join(",");
|
|
1040
|
+
const controls = Array.from(document.querySelectorAll(selector))
|
|
1041
|
+
.filter(visible)
|
|
1042
|
+
.slice(0, 500)
|
|
1043
|
+
.map(describe);
|
|
1044
|
+
const focus = document.activeElement;
|
|
1045
|
+
return {
|
|
1046
|
+
controls,
|
|
1047
|
+
focus: focus && focus !== document.body && visible(focus) ? describe(focus) : null,
|
|
1048
|
+
};
|
|
1049
|
+
});
|
|
1050
|
+
return {
|
|
1051
|
+
active_tab: tabIndex,
|
|
1052
|
+
url: page.url(),
|
|
1053
|
+
title: await page.title(),
|
|
1054
|
+
text: textResult.text,
|
|
1055
|
+
controls: observed.controls,
|
|
1056
|
+
focus: observed.focus,
|
|
1057
|
+
revision: this.sessionRevision,
|
|
1058
|
+
navigation: await this.describeNavigation(page),
|
|
1059
|
+
truncated: textResult.truncated,
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
async sessionScreenshot(outInput: string): Promise<BrowserSessionScreenshot> {
|
|
1064
|
+
const out = resolve(outInput);
|
|
1065
|
+
let parent: ReturnType<typeof statSync>;
|
|
1066
|
+
try {
|
|
1067
|
+
parent = statSync(dirname(out));
|
|
1068
|
+
} catch {
|
|
1069
|
+
throw new BrowserSessionActionError(
|
|
1070
|
+
"screenshot_parent_missing",
|
|
1071
|
+
"Screenshot parent directory must already exist.",
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
if (!parent.isDirectory()) {
|
|
1075
|
+
throw new BrowserSessionActionError(
|
|
1076
|
+
"screenshot_parent_invalid",
|
|
1077
|
+
"Screenshot parent is not a directory.",
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
const buffer = await this.currentPage.screenshot({ type: "png", fullPage: true });
|
|
1081
|
+
if (buffer.length < 24 || buffer.toString("ascii", 1, 4) !== "PNG") {
|
|
1082
|
+
throw new BrowserSessionActionError("screenshot_invalid", "Browser returned an invalid PNG.");
|
|
1083
|
+
}
|
|
1084
|
+
try {
|
|
1085
|
+
writeFileSync(out, buffer, { flag: "wx", mode: 0o600 });
|
|
1086
|
+
chmodSync(out, 0o600);
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
1089
|
+
throw new BrowserSessionActionError(
|
|
1090
|
+
"screenshot_exists",
|
|
1091
|
+
"Screenshot output already exists.",
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
throw error;
|
|
1095
|
+
}
|
|
1096
|
+
this.sessionRevision++;
|
|
1097
|
+
return {
|
|
1098
|
+
path: out,
|
|
1099
|
+
width: buffer.readUInt32BE(16),
|
|
1100
|
+
height: buffer.readUInt32BE(20),
|
|
1101
|
+
revision: this.sessionRevision,
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
private sessionLocator(locator: BrowserSessionLocator): Locator {
|
|
1106
|
+
const page = this.currentPage;
|
|
1107
|
+
switch (locator.kind) {
|
|
1108
|
+
case "selector":
|
|
1109
|
+
return page.locator(locator.value);
|
|
1110
|
+
case "role":
|
|
1111
|
+
return page.getByRole(locator.value as Parameters<Page["getByRole"]>[0], {
|
|
1112
|
+
...(locator.name === undefined ? {} : { name: locator.name, exact: !locator.partial }),
|
|
1113
|
+
});
|
|
1114
|
+
case "label":
|
|
1115
|
+
return page.getByLabel(locator.value, { exact: !locator.partial });
|
|
1116
|
+
case "text":
|
|
1117
|
+
return page.getByText(locator.value, { exact: !locator.partial });
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
private async strictSessionLocator(locator: BrowserSessionLocator): Promise<Locator> {
|
|
1122
|
+
return this.requireStrictLocator(this.sessionLocator(locator));
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
private async requireStrictLocator(locator: Locator): Promise<Locator> {
|
|
1126
|
+
const count = await locator.count();
|
|
1127
|
+
if (count === 0) {
|
|
1128
|
+
throw new BrowserSessionActionError("locator_not_found", "Locator matched no elements.");
|
|
1129
|
+
}
|
|
1130
|
+
if (count !== 1) {
|
|
1131
|
+
throw new BrowserSessionActionError(
|
|
1132
|
+
"locator_ambiguous",
|
|
1133
|
+
`Locator matched ${count} elements; refine it before acting.`,
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
return locator;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
private pageForIndex(index: number): Page {
|
|
1140
|
+
const entry = [...this.pageIndexes.entries()].find(
|
|
1141
|
+
([page, pageIndex]) => pageIndex === index && !page.isClosed(),
|
|
1142
|
+
);
|
|
1143
|
+
if (!entry) throw new BrowserSessionActionError("tab_not_found", "Browser tab was not found.");
|
|
1144
|
+
return entry[0];
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
private async describeTab(page: Page): Promise<BrowserSessionTab> {
|
|
1148
|
+
const index = this.pageIndexes.get(page);
|
|
1149
|
+
if (index === undefined || page.isClosed()) {
|
|
1150
|
+
throw new BrowserSessionActionError("tab_not_found", "Browser tab was not found.");
|
|
1151
|
+
}
|
|
1152
|
+
return {
|
|
1153
|
+
index,
|
|
1154
|
+
title: await page.title().catch(() => ""),
|
|
1155
|
+
url: page.url(),
|
|
1156
|
+
active: page === this.page,
|
|
1157
|
+
revision: this.sessionRevision,
|
|
1158
|
+
navigation: await this.describeNavigation(page),
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
private selectMostRecentPageIfNeeded(): void {
|
|
1163
|
+
if (!this.page || this.page.isClosed()) this.selectMostRecentPage();
|
|
1164
|
+
}
|
|
1165
|
+
|
|
664
1166
|
/**
|
|
665
1167
|
* Run occlusion checks on one or more selectors. For each, samples a grid
|
|
666
1168
|
* of points inside the element's bounding rect and uses
|
|
@@ -826,6 +1328,12 @@ export class Browser {
|
|
|
826
1328
|
await this.context.addInitScript(script);
|
|
827
1329
|
}
|
|
828
1330
|
|
|
1331
|
+
/** Snapshot all cookies in the live persistent context. */
|
|
1332
|
+
async cookies(): Promise<PWCookie[]> {
|
|
1333
|
+
if (!this.context) throw new Error("Browser not opened. Call open() first.");
|
|
1334
|
+
return this.context.cookies();
|
|
1335
|
+
}
|
|
1336
|
+
|
|
829
1337
|
/**
|
|
830
1338
|
* Sync cookies from the live context back into the jar (if one was provided),
|
|
831
1339
|
* then tear everything down. Safe to call multiple times.
|
|
@@ -845,6 +1353,12 @@ export class Browser {
|
|
|
845
1353
|
await this.context.close().catch(() => {});
|
|
846
1354
|
this.context = null;
|
|
847
1355
|
this.page = null;
|
|
1356
|
+
this.pageIndexes.clear();
|
|
1357
|
+
this.pageRecency.clear();
|
|
1358
|
+
this.pageNavigations.clear();
|
|
1359
|
+
this.nextPageIndex = 0;
|
|
1360
|
+
this.recencyClock = 0;
|
|
1361
|
+
this.sessionRevision = 0;
|
|
848
1362
|
}
|
|
849
1363
|
}
|
|
850
1364
|
|
|
@@ -890,3 +1404,11 @@ function normalizeSameSite(s: string | undefined): "Strict" | "Lax" | "None" | u
|
|
|
890
1404
|
if (lower === "none") return "None";
|
|
891
1405
|
return undefined;
|
|
892
1406
|
}
|
|
1407
|
+
|
|
1408
|
+
function truncateUtf8(value: string, maxBytes: number): { text: string; truncated: boolean } {
|
|
1409
|
+
const buffer = Buffer.from(value, "utf8");
|
|
1410
|
+
if (buffer.length <= maxBytes) return { text: value, truncated: false };
|
|
1411
|
+
let end = maxBytes;
|
|
1412
|
+
while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
|
|
1413
|
+
return { text: buffer.subarray(0, end).toString("utf8"), truncated: true };
|
|
1414
|
+
}
|