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/CHANGELOG.md +38 -0
- package/README.md +35 -2
- package/SECURITY.md +3 -3
- package/lib/challenge-signatures.d.ts +105 -0
- package/lib/challenge-signatures.js +154 -0
- package/lib/challenge.d.ts +48 -8
- package/lib/challenge.js +211 -26
- package/lib/injected.js +13 -0
- package/lib/page-actions.d.ts +7 -56
- package/lib/page-actions.js +1 -1
- package/lib/page-diagnostics.js +23 -5
- package/lib/page-snapshot.d.ts +14 -8
- package/lib/page-snapshot.js +48 -1
- package/lib/session.d.ts +21 -82
- package/lib/session.js +15 -3
- package/lib/snapshot-aria.js +16 -0
- package/lib/tool-render.js +21 -1
- package/lib/tool-types.d.ts +10 -0
- package/lib/types.d.ts +23 -0
- package/package.json +4 -3
- package/scripts/analysis/hl-compare.mjs +0 -149
- package/scripts/analysis/live-handoff-probe.mjs +0 -97
- package/scripts/analysis/needs-human-probe.mjs +0 -83
- package/scripts/analysis/repro-minimize.mjs +0 -112
- package/scripts/autocommit.ps1 +0 -23
- package/scripts/check-doc-links.py +0 -112
- package/scripts/measure-prompt-cost.mjs +0 -57
- package/scripts/preflight.mjs +0 -192
- package/scripts/prompt-cost.mjs +0 -73
- package/scripts/shipped-gates.mjs +0 -43
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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').
|
package/lib/snapshot-aria.js
CHANGED
|
@@ -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);
|
package/lib/tool-render.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/lib/tool-types.d.ts
CHANGED
|
@@ -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.
|
|
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 })
|