dsh-browser-playwright-codex 0.6.0 → 0.6.5

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/lib/session.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { BrowserContext, CDPSession, Locator, Page, Request } from 'playwright-core';
7
7
  import type { PlaywrightProvider } from './provider.ts';
8
- import type { BrowserNode, BrowserSnapshot, DiagnosticsEntry, LoadState } from './types.ts';
8
+ import type { BrowserNode, BrowserSnapshot, DiagnosticsEntry, DocumentResponse, LoadState } from './types.ts';
9
9
  import type { RefSignature } from './page-snapshot.ts';
10
10
  import { type CookieRow } from './login-sites.ts';
11
11
  import type { ActionResult, PendingDialogRecord } from './provider.ts';
@@ -44,6 +44,15 @@ export declare class PlaywrightSession {
44
44
  entry: DiagnosticsEntry;
45
45
  startedAt: number;
46
46
  }>;
47
+ /**
48
+ * Main document response per page. Challenge detection reads this before it
49
+ * reads a word of the page: a response header is the interceptor's own API.
50
+ */
51
+ docResponses: WeakMap<Page, DocumentResponse>;
52
+ /** Fingerprint of the previous capture's page state (no-progress backstop). */
53
+ lastFingerprint: string | undefined;
54
+ /** Consecutive captures that produced an identical page-state fingerprint. */
55
+ unchangedStreak: number;
47
56
  /** Ring capacity per stream. */
48
57
  diagLimit: number;
49
58
  /** ref -> feature signature of the previous capture (diff baseline). */
@@ -87,14 +96,7 @@ export declare class PlaywrightSession {
87
96
  * cached data (page.url() needs no round trip) — page.evaluate() and
88
97
  * page.title() would both stall behind the dialog.
89
98
  */
90
- blockedSnapshot(page: Page, pending: PendingDialogRecord | undefined): {
91
- url: string;
92
- title: string;
93
- nodes: never[];
94
- totalRefs: number;
95
- truncated: boolean;
96
- dialogNote: string;
97
- };
99
+ blockedSnapshot(page: Page, pending: PendingDialogRecord | undefined): BrowserSnapshot;
98
100
  /**
99
101
  * Run one page action that may raise a native dialog. Such an action never
100
102
  * resolves while the dialog is up, so race it against the dialog appearing
@@ -109,7 +111,7 @@ export declare class PlaywrightSession {
109
111
  /** Read the snapshot tree from one page (must run inside a run()). */
110
112
  capture(page: Page, opts: {
111
113
  interactiveOnly?: boolean;
112
- } | undefined): Promise<BrowserSnapshot>;
114
+ } | undefined, afterAction?: boolean): Promise<BrowserSnapshot>;
113
115
  /**
114
116
  * Semantic-tree capture via Playwright's official ariaSnapshot(mode:'ai').
115
117
  * An unavailable or unreadable aria tree (`missing`) degrades to the legacy
@@ -153,24 +155,10 @@ export declare class PlaywrightSession {
153
155
  buildRefSignatures(nodes: readonly BrowserNode[]): Map<string, RefSignature>;
154
156
  /** The document nonce shared by this snapshot's refs, or undefined. */
155
157
  refNonce(nodes: readonly BrowserNode[]): string | undefined;
156
- navigate(url: string, waitUntil: LoadState, signal?: AbortSignal): Promise<BrowserSnapshot | {
157
- url: string;
158
- title: string;
159
- nodes: never[];
160
- totalRefs: number;
161
- truncated: boolean;
162
- dialogNote: string;
163
- }>;
158
+ navigate(url: string, waitUntil: LoadState, signal?: AbortSignal): Promise<BrowserSnapshot>;
164
159
  snapshot(opts?: {
165
160
  interactiveOnly?: boolean;
166
- }): Promise<BrowserSnapshot | {
167
- url: string;
168
- title: string;
169
- nodes: never[];
170
- totalRefs: number;
171
- truncated: boolean;
172
- dialogNote: string;
173
- }>;
161
+ }): Promise<BrowserSnapshot>;
174
162
  /**
175
163
  * Codex-style click-point resolution. Tries a scroll-alignment ladder
176
164
  * (centre, then end, then start). For each alignment: scroll the element
@@ -186,14 +174,7 @@ export declare class PlaywrightSession {
186
174
  hitDesc: string;
187
175
  hitSelf: boolean;
188
176
  }>;
189
- click(ref: string, signal?: AbortSignal): Promise<BrowserSnapshot | {
190
- url: string;
191
- title: string;
192
- nodes: never[];
193
- totalRefs: number;
194
- truncated: boolean;
195
- dialogNote: string;
196
- }>;
177
+ click(ref: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
197
178
  /**
198
179
  * Click an element the snapshot gave no ref, addressed by its visible label
199
180
  * (e.g. a menu item revealed by browser_click with mode "hover" that is plain text). The
@@ -201,14 +182,7 @@ export declare class PlaywrightSession {
201
182
  * and the landing note names what was actually clicked plus how many
202
183
  * elements shared the label.
203
184
  */
204
- clickText(text: string, signal?: AbortSignal): Promise<BrowserSnapshot | {
205
- url: string;
206
- title: string;
207
- nodes: never[];
208
- totalRefs: number;
209
- truncated: boolean;
210
- dialogNote: string;
211
- }>;
185
+ clickText(text: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
212
186
  /**
213
187
  * Enforce the navigation policy for the link a click is about to follow.
214
188
  * Click navigation does not run through navigate(), so this is the only place
@@ -240,41 +214,13 @@ export declare class PlaywrightSession {
240
214
  * then report what actually received the click. The label names the target
241
215
  * in the landing note; shared is how many elements matched it (0 for refs).
242
216
  */
243
- clickLocator(locator: Locator, signal: AbortSignal | undefined, label: string, shared: number): Promise<BrowserSnapshot | {
244
- url: string;
245
- title: string;
246
- nodes: never[];
247
- totalRefs: number;
248
- truncated: boolean;
249
- dialogNote: string;
250
- }>;
251
- clickAt(x: number, y: number, signal?: AbortSignal): Promise<BrowserSnapshot | {
252
- url: string;
253
- title: string;
254
- nodes: never[];
255
- totalRefs: number;
256
- truncated: boolean;
257
- dialogNote: string;
258
- }>;
259
- clickAtRef(ref: string, signal?: AbortSignal): Promise<BrowserSnapshot | {
260
- url: string;
261
- title: string;
262
- nodes: never[];
263
- totalRefs: number;
264
- truncated: boolean;
265
- dialogNote: string;
266
- }>;
217
+ clickLocator(locator: Locator, signal: AbortSignal | undefined, label: string, shared: number): Promise<BrowserSnapshot>;
218
+ clickAt(x: number, y: number, signal?: AbortSignal): Promise<BrowserSnapshot>;
219
+ clickAtRef(ref: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
267
220
  fill(ref: string, text: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
268
221
  /** Read the field back and decide whether the fill landed. */
269
222
  verifyFill(locator: Locator, tag: string, text: string): Promise<boolean>;
270
- press(ref: string, key: string, signal?: AbortSignal): Promise<BrowserSnapshot | {
271
- url: string;
272
- title: string;
273
- nodes: never[];
274
- totalRefs: number;
275
- truncated: boolean;
276
- dialogNote: string;
277
- }>;
223
+ press(ref: string, key: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
278
224
  /**
279
225
  * Hover the referenced element: rest the pointer on it so hover-revealed
280
226
  * content appears (CSS :hover rules, JS mouseenter handlers, submenus,
@@ -308,14 +254,7 @@ export declare class PlaywrightSession {
308
254
  title: string;
309
255
  active: boolean;
310
256
  }[]>;
311
- switchTab(index: number, signal?: AbortSignal): Promise<BrowserSnapshot | {
312
- url: string;
313
- title: string;
314
- nodes: never[];
315
- totalRefs: number;
316
- truncated: boolean;
317
- dialogNote: string;
318
- }>;
257
+ switchTab(index: number, signal?: AbortSignal): Promise<BrowserSnapshot>;
319
258
  openTab(url: string, waitUntil: LoadState, signal?: AbortSignal): Promise<BrowserSnapshot>;
320
259
  closeTab(index: number, signal?: AbortSignal): Promise<void>;
321
260
  /**
package/lib/session.js CHANGED
@@ -4,7 +4,7 @@ import { asTimeoutError, isAbortError, isContextDestroyed, isCrashError } from "
4
4
  import { consoleMessages, networkRequests, pageData, trackPage } from "./page-diagnostics.js";
5
5
  import { elementStabilityProbe, pageStabilityProbe } from "./page-probes.js";
6
6
  import { assertActionable, back, clickAt, clickAtRef, clickLocator, clickText, closeTab, fill, forward, hoverLocator, hoverText, navigate, openTab, press, resolveClickPoint, runAction, scroll, switchTab, verifyFill, withAbort, } from "./page-actions.js";
7
- import { assertLive, attachDiff, buildRefSignatures, captureAria, captureLegacy, refLocator, resolveSnapshotEngine, settleAndSnapshot, snapshot, } from "./page-snapshot.js";
7
+ import { assertLive, attachDiff, attachDocResponse, attachNoProgress, buildRefSignatures, captureAria, captureLegacy, refLocator, resolveSnapshotEngine, settleAndSnapshot, snapshot, } from "./page-snapshot.js";
8
8
  import { assertAllowedUrl } from "./url-policy.js";
9
9
  /** Live session over one browser context owned by one caller. */
10
10
  export class PlaywrightSession {
@@ -35,6 +35,15 @@ export class PlaywrightSession {
35
35
  diagDropped = { console: 0, network: 0 };
36
36
  /** In-flight requests keyed by request object, for status and duration attribution. */
37
37
  requestEntries = new WeakMap();
38
+ /**
39
+ * Main document response per page. Challenge detection reads this before it
40
+ * reads a word of the page: a response header is the interceptor's own API.
41
+ */
42
+ docResponses = new WeakMap();
43
+ /** Fingerprint of the previous capture's page state (no-progress backstop). */
44
+ lastFingerprint = undefined;
45
+ /** Consecutive captures that produced an identical page-state fingerprint. */
46
+ unchangedStreak = 0;
38
47
  /** Ring capacity per stream. */
39
48
  diagLimit = 200;
40
49
  /** ref -> feature signature of the previous capture (diff baseline). */
@@ -217,12 +226,15 @@ export class PlaywrightSession {
217
226
  });
218
227
  }
219
228
  /** Read the snapshot tree from one page (must run inside a run()). */
220
- async capture(page, opts) {
229
+ async capture(page, opts, afterAction = false) {
221
230
  this.provider.armDialogGuard(page);
222
231
  const raw = resolveSnapshotEngine(this.provider.config.snapshot.engine) === 'aria'
223
232
  ? await this.captureAria(page, opts)
224
233
  : await this.captureLegacy(page, opts);
225
- return this.attachDiff(raw);
234
+ // Order matters: the response metadata is attached before the diff, so the
235
+ // snapshot the model reads carries the same evidence the detector used, and
236
+ // the no-progress backstop sees the final tree.
237
+ return this.attachDiff(attachNoProgress(this, attachDocResponse(this, page, raw), afterAction));
226
238
  }
227
239
  /**
228
240
  * Semantic-tree capture via Playwright's official ariaSnapshot(mode:'ai').
@@ -325,6 +325,22 @@ function normalizeRole(role) {
325
325
  function convertAriaNode(node, opts, refs, roles, state) {
326
326
  const actionable = isActionableRole(node.role);
327
327
  if (opts.interactiveOnly === true && !actionable) {
328
+ // Frames are the one non-actionable node kept: their accessible name is the
329
+ // embedded widget's own title, and that title appears nowhere else in the
330
+ // tree (see the same rule in the legacy walker).
331
+ if (node.role === 'iframe') {
332
+ state.count += 1;
333
+ if (state.count > opts.maxNodes) {
334
+ state.truncated = true;
335
+ return null;
336
+ }
337
+ const frameName = node.name !== '' ? node.name : (node.placeholder ?? '');
338
+ return {
339
+ role: node.role,
340
+ name: frameName.length > opts.maxNameLength ? frameName.slice(0, opts.maxNameLength) + '…' : frameName,
341
+ children: [],
342
+ };
343
+ }
328
344
  const kids = [];
329
345
  for (const child of node.children) {
330
346
  const sub = convertAriaNode(child, opts, refs, roles, state);
@@ -72,6 +72,14 @@ export function snapshotValue(snapshot) {
72
72
  const engine = snapshot.engineNote;
73
73
  if (engine !== undefined && engine !== '')
74
74
  notes.push('[page reader] ' + engine);
75
+ // The no-progress backstop rides with the other notes: it is advice about the
76
+ // action the model just took, so it has to be in that action's result. It is
77
+ // suppressed when a challenge was already identified, because "this is a wall,
78
+ // here is why" is the actionable message and stacking a second, vaguer warning
79
+ // on top of it only spends prompt budget on a case that is already decided.
80
+ const stall = snapshot.stallNote;
81
+ if (challenge === null && stall !== undefined && stall !== '')
82
+ notes.push(stall);
75
83
  const head = notes.length > 0 ? notes.join('\n\n') + '\n\n' : '';
76
84
  // Incremental diff rides along when the provider computed one (snapshot.diff).
77
85
  const diffBlock = snapshot.diff !== undefined ? '\n\n' + renderSnapshotDiff(snapshot.diff) : '';
@@ -81,7 +89,19 @@ export function snapshotValue(snapshot) {
81
89
  refs: snapshot.totalRefs,
82
90
  truncated: snapshot.truncated,
83
91
  tree: head +
84
- (challenge === null ? tree : challenge.engine + ': ' + challenge.hint + '.\n' + CHALLENGE_NOTE + '\n\n' + tree) +
92
+ (challenge === null
93
+ ? tree
94
+ : challenge.engine +
95
+ ' [' +
96
+ challenge.tier +
97
+ ': ' +
98
+ challenge.evidence +
99
+ ']: ' +
100
+ challenge.hint +
101
+ '.\n' +
102
+ CHALLENGE_NOTE +
103
+ '\n\n' +
104
+ tree) +
85
105
  diffBlock,
86
106
  };
87
107
  }
@@ -3,6 +3,8 @@
3
3
  * @module dsh-browser-playwright-codex/tool-types
4
4
  */
5
5
  import type { StreamChunk } from '@deepseek-ai/dsh-llm';
6
+ import type { ChallengeTier } from './challenge-signatures.ts';
7
+ import type { DocumentResponse } from './types.ts';
6
8
  /**
7
9
  * JSON value: the shape every tool value must be serializable to, so the
8
10
  * framework can put it on the wire. Declared locally because the installed
@@ -42,6 +44,8 @@ export interface ScreenshotValue {
42
44
  /** Minimal node shape the challenge detector walks. */
43
45
  export interface ChallengeNode {
44
46
  readonly name?: string;
47
+ /** Accessible role. Both built-in engines provide one; it gates the wording tier. */
48
+ readonly role?: string;
45
49
  readonly children?: readonly ChallengeNode[];
46
50
  }
47
51
  /** The slice of a snapshot the challenge detector reads. */
@@ -49,11 +53,17 @@ export interface ChallengeSnapshot {
49
53
  readonly title?: string;
50
54
  readonly url?: string;
51
55
  readonly nodes?: readonly ChallengeNode[];
56
+ /** Main document response metadata; absent when no navigation response was seen. */
57
+ readonly response?: DocumentResponse;
52
58
  }
53
59
  /** What the challenge detector reports when a page is an anti-bot wall. */
54
60
  export interface ChallengeMatch {
55
61
  engine: string;
56
62
  hint: string;
63
+ /** How it was proven, strongest first. */
64
+ tier: ChallengeTier;
65
+ /** The concrete signal that fired, so a reader can judge the call instead of trusting it. */
66
+ evidence: string;
57
67
  }
58
68
  /** The attachments-service face consumed at execution time. */
59
69
  export interface AttachmentServiceLike {
package/lib/types.d.ts CHANGED
@@ -25,6 +25,17 @@ export interface BrowserNode {
25
25
  readonly href?: string;
26
26
  readonly children: readonly BrowserNode[];
27
27
  }
28
+ /**
29
+ * Metadata of the main document response that produced a snapshot, read from the
30
+ * network layer. Challenge detection consults this first: a response header is the
31
+ * interceptor's own published API (`cf-mitigated: challenge`), unlike its wording.
32
+ */
33
+ export interface DocumentResponse {
34
+ readonly status: number;
35
+ readonly url: string;
36
+ /** Response headers as Playwright reports them (names lowercased). */
37
+ readonly headers: Readonly<Record<string, string>>;
38
+ }
28
39
  /** Bounded accessibility snapshot of the current page. */
29
40
  export interface BrowserSnapshot {
30
41
  readonly url: string;
@@ -58,6 +69,18 @@ export interface BrowserSnapshot {
58
69
  * and a user who picked smart mode kept believing it was running.
59
70
  */
60
71
  engineNote?: string;
72
+ /**
73
+ * Metadata of the main document response, when the capture observed one. It is
74
+ * the first thing challenge detection reads, because it is the interceptor's
75
+ * own API rather than its prose.
76
+ */
77
+ response?: DocumentResponse;
78
+ /**
79
+ * Advisory note appended when an action left the page identical. This is the
80
+ * no-progress backstop: it covers anti-bot walls whose vendor we have no
81
+ * signature for, by asking "did anything happen" instead of "which wall is this".
82
+ */
83
+ stallNote?: string;
61
84
  }
62
85
  /** One element that entered the tree since the previous snapshot. */
63
86
  export interface SnapshotDiffEntry {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-browser-playwright-codex",
3
- "version": "0.6.0",
3
+ "version": "0.6.5",
4
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
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,13 +32,14 @@
32
32
  },
33
33
  "files": [
34
34
  "lib",
35
- "scripts",
36
35
  "cordis.patch.yml",
37
36
  "README.md",
38
37
  "CHANGELOG.md",
39
38
  "SECURITY.md",
40
39
  "NOTICE.md",
41
- "locale"
40
+ "locale",
41
+ "scripts/doctor.mjs",
42
+ "scripts/dsh-browser-verify.mjs"
42
43
  ],
43
44
  "scripts": {
44
45
  "build": "node node_modules/typescript/bin/tsc -p tsconfig.json",
@@ -1,149 +0,0 @@
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()
@@ -1,97 +0,0 @@
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 })