dsh-browser-playwright-codex 0.5.1

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +486 -0
  2. package/LICENSE +21 -0
  3. package/README.md +322 -0
  4. package/SECURITY.md +114 -0
  5. package/cordis.patch.yml +66 -0
  6. package/lib/cdp-policy.d.ts +69 -0
  7. package/lib/cdp-policy.js +105 -0
  8. package/lib/compat.d.ts +37 -0
  9. package/lib/compat.js +31 -0
  10. package/lib/contract.d.ts +74 -0
  11. package/lib/contract.js +117 -0
  12. package/lib/errors.d.ts +25 -0
  13. package/lib/errors.js +21 -0
  14. package/lib/index.d.ts +20 -0
  15. package/lib/index.js +12 -0
  16. package/lib/injected.d.ts +35 -0
  17. package/lib/injected.js +350 -0
  18. package/lib/playwright.d.ts +647 -0
  19. package/lib/playwright.js +2515 -0
  20. package/lib/runtime-state.d.ts +45 -0
  21. package/lib/runtime-state.js +181 -0
  22. package/lib/service.d.ts +56 -0
  23. package/lib/service.js +96 -0
  24. package/lib/snapshot-aria.d.ts +121 -0
  25. package/lib/snapshot-aria.js +485 -0
  26. package/lib/snapshot-render.d.ts +28 -0
  27. package/lib/snapshot-render.js +100 -0
  28. package/lib/tool.d.ts +77 -0
  29. package/lib/tool.js +907 -0
  30. package/lib/types.d.ts +242 -0
  31. package/lib/types.js +5 -0
  32. package/locale/en.json +6 -0
  33. package/locale/zh.json +6 -0
  34. package/package.json +108 -0
  35. package/scripts/analysis/hl-compare.mjs +149 -0
  36. package/scripts/analysis/live-handoff-probe.mjs +97 -0
  37. package/scripts/analysis/needs-human-probe.mjs +83 -0
  38. package/scripts/analysis/repro-minimize.mjs +112 -0
  39. package/scripts/autocommit.ps1 +23 -0
  40. package/scripts/check-doc-links.py +112 -0
  41. package/scripts/doctor.mjs +144 -0
  42. package/scripts/dsh-browser-verify.mjs +475 -0
  43. package/scripts/measure-prompt-cost.mjs +57 -0
  44. package/scripts/prompt-cost.mjs +73 -0
  45. package/scripts/shipped-gates.mjs +43 -0
package/lib/types.d.ts ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Domain types for the browser capability: snapshots, sessions, providers.
3
+ * @module dsh-browser-playwright-codex/types
4
+ */
5
+ /** Page load condition a navigation waits for. */
6
+ export type LoadState = 'load' | 'domcontentloaded' | 'networkidle';
7
+ /** One snapshot element. Refs identify actionable elements for later calls. */
8
+ export interface BrowserNode {
9
+ /** Accessible role, e.g. link, button, textbox, heading. */
10
+ readonly role: string;
11
+ /** Accessible-ish name; empty when the element has none. */
12
+ readonly name: string;
13
+ /** Stable per-snapshot reference; present only on actionable elements. */
14
+ readonly ref?: string;
15
+ /** Heading level (1-6); present only for headings. */
16
+ readonly level?: number;
17
+ /** Whether a checkbox/radio is checked. */
18
+ readonly checked?: boolean;
19
+ /** Whether an option is selected. */
20
+ readonly selected?: boolean;
21
+ /** Whether the element is disabled. */
22
+ readonly disabled?: boolean;
23
+ /** Href for links; present when the page is same-origin. */
24
+ readonly href?: string;
25
+ readonly children: readonly BrowserNode[];
26
+ }
27
+ /** Bounded accessibility snapshot of the current page. */
28
+ export interface BrowserSnapshot {
29
+ readonly url: string;
30
+ readonly title: string;
31
+ readonly nodes: readonly BrowserNode[];
32
+ /** Total refs assigned before any truncation. */
33
+ readonly totalRefs: number;
34
+ /** True when node or text caps cut the tree short. */
35
+ readonly truncated: boolean;
36
+ /**
37
+ * Incremental diff against the previous snapshot of this document, present
38
+ * only when the provider's snapshot.diff is enabled and a baseline exists.
39
+ * Absence means "full snapshot follows": first capture, navigation reset,
40
+ * or a truncated tree (diff is then unreliable, so the provider degrades
41
+ * to the full tree). Diff entries are keyed by the stable data-dsh-ref.
42
+ */
43
+ readonly diff?: SnapshotDiff;
44
+ /**
45
+ * Note parked on the snapshot when a native dialog blocks the page: the
46
+ * snapshot then describes the blocker instead of the page (DIALOG-POLICY).
47
+ */
48
+ dialogNote?: string;
49
+ /**
50
+ * Where the last click actually landed, when the provider can prove it. Its
51
+ * absence means "not reported", never "landed on the target".
52
+ */
53
+ landingNote?: string;
54
+ /**
55
+ * Set when the configured page reader could not read this page and the
56
+ * provider degraded to the other engine. Without it the fallback was silent,
57
+ * and a user who picked smart mode kept believing it was running.
58
+ */
59
+ engineNote?: string;
60
+ }
61
+ /** One element that entered the tree since the previous snapshot. */
62
+ export interface SnapshotDiffEntry {
63
+ readonly ref: string;
64
+ readonly role?: string;
65
+ readonly name?: string;
66
+ /** State flags for an added element (checked / selected / disabled / level=N). */
67
+ readonly flags?: string[];
68
+ /** State flags that changed on an existing element. */
69
+ readonly flagsDelta?: string[];
70
+ /** Nearest ancestor ref, so a diff line keeps its tree context. */
71
+ readonly parentRef?: string;
72
+ }
73
+ /**
74
+ * Incremental delta between two snapshots of the same document, keyed by the
75
+ * stable data-dsh-ref (e<docNonce><seq>). `navigationReset: true` means the
76
+ * ref space changed (navigation) or the tree was truncated, so the delta is
77
+ * void and the consumer must rely on the full snapshot that follows.
78
+ */
79
+ export interface SnapshotDiff {
80
+ readonly added: readonly SnapshotDiffEntry[];
81
+ readonly removed: readonly string[];
82
+ readonly changed: readonly SnapshotDiffEntry[];
83
+ /** Number of elements whose ref and features are unchanged. */
84
+ readonly same: number;
85
+ /** True when the previous ref space is void (navigation or truncation). */
86
+ readonly navigationReset: boolean;
87
+ }
88
+ /** One open tab in the browser session. */
89
+ export interface TabInfo {
90
+ readonly index: number;
91
+ readonly url: string;
92
+ readonly title: string;
93
+ /** True for the tab the session currently drives. */
94
+ readonly active: boolean;
95
+ }
96
+ /** PNG capture of a page or element. */
97
+ export interface ScreenshotCapture {
98
+ readonly mime: 'image/png';
99
+ readonly bytes: Uint8Array;
100
+ }
101
+ /** One captured diagnostics entry: a console line or a network request. */
102
+ export type DiagnosticsEntry = {
103
+ /** Monotonic sequence number within the session's ring buffer. */
104
+ n?: number | undefined;
105
+ /** Capture time (epoch milliseconds), set by the provider. */
106
+ at?: number | undefined;
107
+ /** Console level (log / warning / error / pageerror), diagnostics only. */
108
+ level?: string | undefined;
109
+ /** Console text, truncated by the provider. */
110
+ text?: string | undefined;
111
+ /** Source URL the console entry came from. */
112
+ source?: string | undefined;
113
+ /** HTTP method, network entries only. */
114
+ method?: string | undefined;
115
+ /** Request URL, network entries only. */
116
+ url?: string | undefined;
117
+ /** Response status, null while pending or after a failure. */
118
+ status?: number | null | undefined;
119
+ /** Resource type, network entries only. */
120
+ kind?: string | undefined;
121
+ /** Round-trip duration in milliseconds, when the request settled. */
122
+ ms?: number | null | undefined;
123
+ /** Transport failure text, when the request never completed. */
124
+ failure?: string | null | undefined;
125
+ /**
126
+ * Which page of this session produced the entry. The rings stay session-wide so
127
+ * chronology survives a tab switch, but the diagnostics tools report only the
128
+ * page the session is driving and say how many entries they left out.
129
+ */
130
+ page?: number | undefined;
131
+ };
132
+ /** Canonical value of browser_console_messages and browser_network_requests. */
133
+ export type DiagnosticsValue = {
134
+ /** URL of the tab the entries were captured from, when known. */
135
+ url?: string | null;
136
+ /** Requested console level filter, diagnostics only. */
137
+ level?: string | undefined;
138
+ /** Entries matching the filter. */
139
+ readonly total: number;
140
+ /** Entries actually returned after the limit was applied. */
141
+ readonly returned: number;
142
+ /** Entries evicted from the ring buffer, so truncation stays visible. */
143
+ readonly dropped?: number;
144
+ /**
145
+ * Matching entries that were left out because they belong to another tab of
146
+ * the shared window. Non-zero means "this tab is quiet, the noise is elsewhere"
147
+ * — the reader must say so instead of letting a model diagnose a foreign page.
148
+ */
149
+ readonly otherPages?: number;
150
+ readonly entries: readonly DiagnosticsEntry[];
151
+ };
152
+ /**
153
+ * Live browser session owned by one caller (a harness session id).
154
+ * Providers manage the underlying browser contexts; callers only drive.
155
+ */
156
+ export interface BrowserSession {
157
+ /** Opaque owner key supplied at acquisition. */
158
+ readonly owner: string;
159
+ /** Navigate the active page and return the post-navigation snapshot. */
160
+ navigate(url: string, waitUntil: LoadState, signal?: AbortSignal): Promise<BrowserSnapshot>;
161
+ /** Snapshot the active page without changing it. */
162
+ snapshot(opts?: {
163
+ readonly interactiveOnly?: boolean;
164
+ }): Promise<BrowserSnapshot>;
165
+ /** Click the element referenced by the latest snapshot. */
166
+ click(ref: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
167
+ /** Replace the referenced input's value and return the new snapshot. */
168
+ fill(ref: string, text: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
169
+ /** Press a keyboard key on the referenced element. */
170
+ press(ref: string, key: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
171
+ /** Scroll the page, or the referenced scrollable, by the given amount. */
172
+ scroll(direction: 'up' | 'down', amount: number, ref: string | undefined, signal?: AbortSignal): Promise<BrowserSnapshot>;
173
+ /** History back. */
174
+ back(signal?: AbortSignal): Promise<BrowserSnapshot>;
175
+ /** History forward. */
176
+ forward(signal?: AbortSignal): Promise<BrowserSnapshot>;
177
+ /** Wait a bounded duration, letting lazy content arrive. */
178
+ wait(ms: number, signal?: AbortSignal): Promise<void>;
179
+ /** List open tabs. */
180
+ tabs(): Promise<readonly TabInfo[]>;
181
+ /** Activate the tab at the given index. */
182
+ switchTab(index: number, signal?: AbortSignal): Promise<BrowserSnapshot>;
183
+ /** Open a new tab and navigate it. */
184
+ openTab(url: string, waitUntil: LoadState, signal?: AbortSignal): Promise<BrowserSnapshot>;
185
+ /** Close the tab at the given index; the last tab resets to a blank page. */
186
+ closeTab(index: number, signal?: AbortSignal): Promise<void>;
187
+ /** Capture a PNG of the page or referenced element. */
188
+ screenshot(opts: {
189
+ readonly fullPage?: boolean;
190
+ readonly ref?: string;
191
+ } | undefined, signal?: AbortSignal): Promise<ScreenshotCapture>;
192
+ /** Extract the page's bounded data: text, links, forms, frames. */
193
+ pageData(): Promise<unknown>;
194
+ /** Evaluate one JavaScript expression and return its JSON result. */
195
+ evaluate(expression: string, signal?: AbortSignal): Promise<unknown>;
196
+ /** Click the element whose visible text matches, by semantic locator. */
197
+ clickText(text: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
198
+ /** Click the element referenced by the latest snapshot at its geometry. */
199
+ clickAtRef(ref: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
200
+ /** Click raw viewport coordinates, when no ref resolves the target. */
201
+ clickAt(x: number, y: number, signal?: AbortSignal): Promise<BrowserSnapshot>;
202
+ /** Hover the referenced element. */
203
+ hover(ref: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
204
+ /** Hover the element whose visible text matches. */
205
+ hoverText(text: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
206
+ /** Answer the parked native dialog and return the post-answer snapshot. */
207
+ resolveDialog(accept: boolean, promptText: string | undefined, signal?: AbortSignal): Promise<BrowserSnapshot>;
208
+ /**
209
+ * Send one raw CDP command on the current page. The provider performs no
210
+ * policy filtering: callers are the tool layer, which holds the allow-list.
211
+ */
212
+ cdp(method: string, params: Record<string, unknown>, signal?: AbortSignal): Promise<unknown>;
213
+ /** Recent console messages and uncaught page errors for this session. */
214
+ consoleMessages(args: {
215
+ readonly level?: string;
216
+ readonly limit?: number;
217
+ }): DiagnosticsValue;
218
+ /** Recent network requests for this session, newest last. */
219
+ networkRequests(args: {
220
+ readonly urlContains?: string;
221
+ readonly failedOnly?: boolean;
222
+ readonly limit?: number;
223
+ }): DiagnosticsValue;
224
+ /** Close the session; the provider forgets its browser context. */
225
+ close(): Promise<void>;
226
+ }
227
+ /**
228
+ * One browser-capability implementation. Providers own browser binaries,
229
+ * context fleets, and lifecycle; the runtime selects exactly one.
230
+ */
231
+ export interface BrowserProvider {
232
+ /** Stable unique provider id. */
233
+ readonly id: string;
234
+ /** Whether this provider can currently serve sessions. */
235
+ available(): boolean;
236
+ /** Acquire or reuse the session owned by the owner key. */
237
+ acquire(owner: string, signal?: AbortSignal): Promise<BrowserSession>;
238
+ /** Release the session owned by the owner key and its browser resources. */
239
+ disposeOwner(owner: string): Promise<void>;
240
+ /** Release every session and the browser itself. */
241
+ dispose(): Promise<void>;
242
+ }
package/lib/types.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Domain types for the browser capability: snapshots, sessions, providers.
3
+ * @module dsh-browser-playwright-codex/types
4
+ */
5
+ export {};
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Browser Automation",
4
+ "description": "Playwright-powered browser capability for DeepSeek Harness: accessibility-snapshot interaction with stable element refs, session-scoped browser sessions, screenshots as attachments."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "浏览器自动化",
4
+ "description": "浏览器自动化(Playwright):无障碍快照交互 + 稳定元素引用、会话级浏览器实例、截图作为附件。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,108 @@
1
+ {
2
+ "name": "dsh-browser-playwright-codex",
3
+ "version": "0.5.1",
4
+ "description": "Browser automation for DeepSeek Harness: fill in forms, scrape lists and work across tabs in real pages, using the logins you already have. Runs in a visible window that stays quiet when minimised, and stops for sign-ins, CAPTCHAs and native dialogs instead of confirming for you.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "lib/index.js",
8
+ "types": "lib/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./lib/index.d.ts",
12
+ "default": "./lib/index.js"
13
+ },
14
+ "./service": {
15
+ "types": "./lib/service.d.ts",
16
+ "default": "./lib/service.js"
17
+ },
18
+ "./playwright": {
19
+ "types": "./lib/playwright.d.ts",
20
+ "default": "./lib/playwright.js"
21
+ },
22
+ "./tool": {
23
+ "types": "./lib/tool.d.ts",
24
+ "default": "./lib/tool.js"
25
+ },
26
+ "./runtime-state": {
27
+ "default": "./lib/runtime-state.js"
28
+ },
29
+ "./cordis.patch.yml": "./cordis.patch.yml",
30
+ "./package.json": "./package.json",
31
+ "./locale/*.json": "./locale/*.json"
32
+ },
33
+ "files": [
34
+ "lib",
35
+ "scripts",
36
+ "cordis.patch.yml",
37
+ "README.md",
38
+ "CHANGELOG.md",
39
+ "SECURITY.md",
40
+ "locale"
41
+ ],
42
+ "scripts": {
43
+ "build": "node node_modules/typescript/bin/tsc -p tsconfig.json",
44
+ "test": "node node_modules/tsx/dist/cli.mjs --test \"tests/*.test.ts\"",
45
+ "test:scenarios": "node node_modules/tsx/dist/cli.mjs --test tests/scenarios.test.ts tests/tool-scenarios.test.ts",
46
+ "test:live": "node node_modules/tsx/dist/cli.mjs --test tests/live-smoke.test.ts",
47
+ "verify": "npm run build && node scripts/dsh-browser-verify.mjs",
48
+ "verify:live": "npm run build && node scripts/dsh-browser-verify.mjs --live",
49
+ "doctor": "node scripts/doctor.mjs",
50
+ "cost": "node node_modules/tsx/dist/cli.mjs scripts/measure-prompt-cost.mjs",
51
+ "format": "node node_modules/prettier/bin/prettier.cjs --write \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
52
+ "format:check": "node node_modules/prettier/bin/prettier.cjs --check \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
53
+ "prepublishOnly": "npm run build && npm test"
54
+ },
55
+ "dependencies": {
56
+ "@deepseek-ai/schemastery": "*",
57
+ "playwright-core": "~1.62.1"
58
+ },
59
+ "peerDependencies": {
60
+ "@deepseek-ai/cordis": "*",
61
+ "@deepseek-ai/dsh-llm": "*",
62
+ "@deepseek-ai/dsh-tools": "*"
63
+ },
64
+ "devDependencies": {
65
+ "@deepseek-ai/cordis": "4.0.4",
66
+ "@deepseek-ai/dsh-agent": "0.2.0-rc.1",
67
+ "@deepseek-ai/dsh-attachment": "0.2.0-rc.1",
68
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.1",
69
+ "@deepseek-ai/dsh-session": "0.2.0-rc.1",
70
+ "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.1",
71
+ "@deepseek-ai/dsh-tools": "0.2.0-rc.1",
72
+ "@deepseek-ai/schemastery": "3.18.4",
73
+ "@types/node": "^24.13.3",
74
+ "prettier": "^3.9.9",
75
+ "tsx": "^4.20.0",
76
+ "typescript": "^5.9.0"
77
+ },
78
+ "dsh": {
79
+ "bundle": {
80
+ "patch": "./cordis.patch.yml"
81
+ }
82
+ },
83
+ "keywords": [
84
+ "deepseek-harness",
85
+ "dsh",
86
+ "dsh-plugin",
87
+ "browser",
88
+ "playwright",
89
+ "web-automation",
90
+ "agent",
91
+ "automation",
92
+ "browser-automation",
93
+ "web-scraping",
94
+ "form-filling",
95
+ "deepseek",
96
+ "ai-agent",
97
+ "rpa"
98
+ ],
99
+ "engines": {
100
+ "node": "^22.19.0 || >=24.0.0"
101
+ },
102
+ "packageManager": "pnpm@10.31.0",
103
+ "repository": {
104
+ "type": "git",
105
+ "url": "git+https://github.com/miqian-nomad/dsh-browser-playwright-codex.git"
106
+ },
107
+ "homepage": "https://github.com/miqian-nomad/dsh-browser-playwright-codex#readme"
108
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Measures what actually differs between the plugin's headless and headful
3
+ * launch shapes, on the SAME persistent profile, using the plugin's real
4
+ * launch options (channel chrome, HUMANIZED_LAUNCH args, viewport rules).
5
+ *
6
+ * MODE=headless|headful PROFILE=<dir> node hl-compare.mjs
7
+ */
8
+ import { createRequire } from 'node:module'
9
+ import path from 'node:path'
10
+ import fs from 'node:fs'
11
+
12
+ const require = createRequire(import.meta.url)
13
+ const { chromium } = require(process.env.PW_CORE_PATH ?? 'playwright-core')
14
+
15
+ const MODE = process.env.MODE ?? 'headless'
16
+ const headless = MODE === 'headless'
17
+ const profile = process.env.PROFILE
18
+ const probeHtml = process.env.PROBE_HTML
19
+
20
+ // Mirror of the plugin's baseOptions (lib/playwright.js:437-457).
21
+ const HUMANIZED_LAUNCH = { args: ['--disable-blink-features=AutomationControlled'] }
22
+ const options = {
23
+ headless,
24
+ channel: process.env.CHANNEL || undefined,
25
+ ...(headless ? { viewport: { width: 1280, height: 800 } } : { viewport: null }),
26
+ ignoreHTTPSErrors: false,
27
+ ...HUMANIZED_LAUNCH,
28
+ args: [
29
+ '--no-first-run',
30
+ '--no-default-browser-check',
31
+ ...(headless ? [] : ['--start-maximized']),
32
+ ...HUMANIZED_LAUNCH.args,
33
+ ...(process.env.EXTRA_ARG ? [process.env.EXTRA_ARG] : []),
34
+ ],
35
+ }
36
+
37
+ const context = await chromium.launchPersistentContext(profile, options)
38
+ const page = context.pages()[0] ?? (await context.newPage())
39
+
40
+ // Persistent state continuity across a headless <-> headful switch.
41
+ if (process.env.PHASE === 'write') {
42
+ await page.goto('https://example.com', { waitUntil: 'domcontentloaded' }).catch(() => {})
43
+ await context.addCookies([
44
+ { name: 'switch-session', value: 'session-42', domain: 'example.com', path: '/' },
45
+ {
46
+ name: 'switch-durable',
47
+ value: 'durable-42',
48
+ domain: 'example.com',
49
+ path: '/',
50
+ expires: Math.floor(Date.now() / 1000) + 365 * 24 * 3600,
51
+ },
52
+ ])
53
+ await page.evaluate(() => localStorage.setItem('switch-probe', 'kept-42')).catch(() => {})
54
+ console.log(JSON.stringify({ mode: MODE, phase: 'write', cookieSet: true }))
55
+ await context.close()
56
+ process.exit(0)
57
+ }
58
+
59
+ if (process.env.PHASE === 'check') {
60
+ await page.goto('https://example.com', { waitUntil: 'domcontentloaded' }).catch(() => {})
61
+ const cookies = await context.cookies('https://example.com')
62
+ const ls = await page.evaluate(() => localStorage.getItem('switch-probe')).catch(() => null)
63
+ console.log(
64
+ JSON.stringify(
65
+ {
66
+ mode: MODE,
67
+ phase: 'check',
68
+ sessionCookie: cookies.find((c) => c.name === 'switch-session')?.value ?? null,
69
+ durableCookie: cookies.find((c) => c.name === 'switch-durable')?.value ?? null,
70
+ localStorage: ls,
71
+ },
72
+ null,
73
+ 2,
74
+ ),
75
+ )
76
+ await context.close()
77
+ process.exit(0)
78
+ }
79
+
80
+ await page.goto('file:///' + probeHtml.replace(/\\/g, '/'), { waitUntil: 'load' })
81
+
82
+ const measured = await page.evaluate(async () => {
83
+ const box = document.querySelector('#box').getBoundingClientRect()
84
+ const cjk = document.querySelector('#cjk').getBoundingClientRect()
85
+ const tail = document.querySelector('#tail').getBoundingClientRect()
86
+ const rafStart = performance.now()
87
+ let rafCount = 0
88
+ await new Promise((resolve) => {
89
+ const tick = () => {
90
+ rafCount += 1
91
+ if (performance.now() - rafStart > 1000) resolve()
92
+ else requestAnimationFrame(tick)
93
+ }
94
+ requestAnimationFrame(tick)
95
+ })
96
+ return {
97
+ ua: navigator.userAgent,
98
+ webdriver: navigator.webdriver,
99
+ inner: [window.innerWidth, window.innerHeight],
100
+ outer: [window.outerWidth, window.outerHeight],
101
+ screen: [screen.width, screen.height],
102
+ avail: [screen.availWidth, screen.availHeight],
103
+ dpr: window.devicePixelRatio,
104
+ visibility: document.visibilityState,
105
+ hasFocus: document.hasFocus(),
106
+ rafPerSecond: rafCount,
107
+ box: [Math.round(box.width), Math.round(box.height)],
108
+ cjkWidth: Math.round(cjk.width * 100) / 100,
109
+ tailY: Math.round(tail.top),
110
+ fonts: document.fonts.size,
111
+ // Media-feature fingerprint: Playwright's headless default args include
112
+ // --blink-settings=primaryPointerType=4,availableHoverTypes=2.
113
+ coarse: matchMedia('(pointer: coarse)').matches,
114
+ fine: matchMedia('(pointer: fine)').matches,
115
+ hoverHover: matchMedia('(hover: hover)').matches,
116
+ anyHover: matchMedia('(any-hover: hover)').matches,
117
+ maxTouchPoints: navigator.maxTouchPoints,
118
+ languages: navigator.languages.join(','),
119
+ breakpoints: {
120
+ 'min-width:1000px': matchMedia('(min-width: 1000px)').matches,
121
+ 'min-width:1152px': matchMedia('(min-width: 1152px)').matches,
122
+ 'min-width:1200px': matchMedia('(min-width: 1200px)').matches,
123
+ 'min-width:1280px': matchMedia('(min-width: 1280px)').matches,
124
+ 'min-width:1440px': matchMedia('(min-width: 1440px)').matches,
125
+ },
126
+ }
127
+ })
128
+
129
+ const shot = await page.screenshot({ path: path.join(path.dirname(profile), `shot-${MODE}.png`) })
130
+ const png = fs.readFileSync(path.join(path.dirname(profile), `shot-${MODE}.png`))
131
+ // PNG IHDR: width/height are bytes 16-23.
132
+ const shotW = png.readUInt32BE(16)
133
+ const shotH = png.readUInt32BE(20)
134
+
135
+ console.log(
136
+ JSON.stringify(
137
+ {
138
+ mode: MODE,
139
+ headless,
140
+ ...measured,
141
+ screenshotBytes: shot.length,
142
+ screenshotSize: [shotW, shotH],
143
+ },
144
+ null,
145
+ 2,
146
+ ),
147
+ )
148
+
149
+ await context.close()
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Does a minimize -> (agent works) -> restore cycle preserve the live page?
3
+ *
4
+ * If yes, a headful browser can sit minimized while the agent works, and a
5
+ * "show the window" action can hand the SAME page (same captcha, same form,
6
+ * same scroll position) to the human without a relaunch, a reload, or any
7
+ * loss of tabs / session cookies.
8
+ */
9
+ import { createRequire } from 'node:module'
10
+ import path from 'node:path'
11
+ import fs from 'node:fs'
12
+ import os from 'node:os'
13
+
14
+ const require = createRequire(import.meta.url)
15
+ const { chromium } = require(process.env.PW_CORE_PATH ?? 'playwright-core')
16
+
17
+ const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-live-handoff-'))
18
+ const context = await chromium.launchPersistentContext(profile, {
19
+ headless: false,
20
+ channel: process.env.CHANNEL || undefined,
21
+ viewport: null,
22
+ ignoreDefaultArgs: ['--enable-automation'],
23
+ args: [
24
+ '--no-first-run',
25
+ '--no-default-browser-check',
26
+ '--start-maximized',
27
+ '--disable-blink-features=AutomationControlled',
28
+ ],
29
+ })
30
+ const page = context.pages()[0] ?? (await context.newPage())
31
+ const out = {}
32
+
33
+ // A "challenge-like" page: a form + a live counter + scroll depth, all of which
34
+ // would be destroyed by a reload.
35
+ await page.setContent(`<html><body style="height:5000px">
36
+ <input id=t value="">
37
+ <div id=status>fresh</div>
38
+ <script>
39
+ window.__loads = (window.__loads || 0) + 1;
40
+ window.__counter = 0;
41
+ setInterval(() => { window.__counter++; document.querySelector('#status').textContent = 'ticks:' + window.__counter; }, 200);
42
+ window.scrollTo(0, 1500);
43
+ </script>
44
+ </body></html>`)
45
+ await page.fill('#t', 'captcha-answer-in-progress')
46
+ await page.evaluate(() => window.scrollTo(0, 1500))
47
+ await new Promise((r) => setTimeout(r, 700))
48
+
49
+ const cdp = await context.newCDPSession(page)
50
+ const windowId = async () => (await cdp.send('Browser.getWindowForTarget')).windowId
51
+ const state = async () => (await cdp.send('Browser.getWindowBounds', { windowId: await windowId() })).bounds.windowState
52
+ const setState = async (s) => {
53
+ const id = await windowId()
54
+ return cdp.send('Browser.setWindowBounds', { windowId: id, bounds: { windowState: s } })
55
+ }
56
+
57
+ out.beforeMinimize = { windowState: await state() }
58
+
59
+ // Human walks away / the harness wants the desktop: minimize.
60
+ await setState('minimized')
61
+ await new Promise((r) => setTimeout(r, 900))
62
+ out.minimized = { windowState: await state() }
63
+
64
+ // Agent keeps working on the SAME minimized page.
65
+ const snapshotWhileMinimized = await page.evaluate(() => ({
66
+ value: document.querySelector('#t').value,
67
+ status: document.querySelector('#status').textContent,
68
+ scrollY: Math.round(window.scrollY),
69
+ loads: window.__loads,
70
+ counter: window.__counter,
71
+ }))
72
+ await page.screenshot({ path: path.join(path.dirname(profile), 'while-minimized.png') })
73
+ out.agentWorkWhileMinimized = snapshotWhileMinimized
74
+
75
+ // Human needed: show the window. No relaunch, no reload.
76
+ const foregroundBefore = await page.evaluate(() => document.hasFocus())
77
+ await setState('normal')
78
+ await page.bringToFront()
79
+ await new Promise((r) => setTimeout(r, 900))
80
+ out.afterShow = {
81
+ windowState: await state(),
82
+ documentFocused: await page.evaluate(() => document.hasFocus()),
83
+ documentFocusedBefore: foregroundBefore,
84
+ // The point of the whole exercise: is it still the same page instance?
85
+ statePreserved: await page.evaluate(() => ({
86
+ value: document.querySelector('#t').value,
87
+ status: document.querySelector('#status').textContent,
88
+ scrollY: Math.round(window.scrollY),
89
+ loads: window.__loads,
90
+ counter: window.__counter,
91
+ })),
92
+ }
93
+
94
+ console.log(JSON.stringify(out, null, 2))
95
+ await setState('normal').catch(() => {})
96
+ await context.close()
97
+ fs.rmSync(profile, { recursive: true, force: true })