dsh-browser-playwright-codex 0.5.1 → 0.5.2
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 +24 -1
- package/lib/playwright.d.ts +10 -2
- package/lib/playwright.js +222 -114
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -3,7 +3,30 @@
|
|
|
3
3
|
Bilingual by intent: the version headings and the summary of each release are given in both
|
|
4
4
|
languages, the individual entries follow the language the surrounding code and docs use.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
## 0.5.2
|
|
7
|
+
|
|
8
|
+
一句话摘要:**查出 Windows CI 抖动的真正根因并修掉**—— 测试用的转译器 tsx/esbuild 会开启 `keepNames`,把"要送进浏览器执行"的页内函数里的**命名内部函数**改写成 `__name(fn, "名字")`,而 `__name` 只存在于模块作用域、页面里没有 → 探测每次抛 `ReferenceError`,且被 `catch` 吞成无声的 `unreachable`。后果是**几何点击路径从来没工作过**:所有点击都退化成 Playwright 兜底点击(上限 3 秒),慢速 Windows runner 一超时,就把几何路径那句**误导性**的错误抛了出来。
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **根因:页内探测函数必须自包含。** `hitTest` 里那个 `const describe = (…) => …` 被 esbuild 包成 `__name(...)`,序列化进页面后直接抛 `ReferenceError: __name is not defined`(用 `fn.toString()` 在 tsx 下实测确认)。改为内联成字符串计算 —— 函数体里不再有任何函数字面量。
|
|
13
|
+
- **测试运行器换成 Node 原生类型剥离**(`node --test "tests/*.test.ts"`,CI 用 Node 24,引擎要求 `^22.19 || >=24`)。这从根上移除了整类陷阱:同一个测试在 tsx 下红、在原生下绿(已实测)。`tsx` 仍是 devDependency(`cost` 等脚本还在用),只是测试不再经过它。
|
|
14
|
+
- **失败原因不再被丢弃。** 原循环的判断读的是 `lastReason === null`,而变量初值是 `undefined`,所以那个分支**从未执行过**:瞬时失败一律伪装成 "could not find an unobstructed click point"。现在按信息量排序记录原因、在错误里点名、连探测抛出的**错误文本**也带上(正是它让我看到 `__name`)。
|
|
15
|
+
- **点击探测点改为取「元素 ∩ 视口」的中心**。`elementFromPoint()` 对视口外坐标返回 `null`,而原先取元素自身中心 —— 比视口高的元素、或中心落在折线以下的元素会三个对齐档全败。现在取可见交集中心;**完全不在屏幕上**给出独立的 `off-screen` 原因。
|
|
16
|
+
- **截图改用独立的超时下限**(`Math.max(timeoutMs, 10s)`):全页截图的耗时由页面大小与 CPU 编码决定,复用"导航超时"(2500ms)会让慢机器把一次成功捕获判成失败 —— 2026-10-05 的 windows runner 正是这样挂的(`page.screenshot: Timeout 2500ms exceeded`)。这个下限只约束失败路径,拍好即返回。
|
|
17
|
+
- **每个对齐档允许有限重采样**(三次、间隔 60ms,稳定性等待只在第一次前做一次)。
|
|
18
|
+
|
|
19
|
+
### Tests
|
|
20
|
+
|
|
21
|
+
- `the click geometry pipeline itself delivers the click, not the fallback` —— **先写测试**并在旧源码上验红(红的时候它打印出的正是 `got: playwright actionability click delivered …`),在修复后转绿。它断言的是**哪条路径送出的点击**,所以能挡住这一类回归。
|
|
22
|
+
- `click by visible label reaches the element through the page-side probe` —— 新覆盖:此前**整套测试从未调用过** `clickText`,而它的页内探测(`labelTargetProbe`)同样被 `__name` 破坏,只会以"找不到这个文字"的形式表现出来。这条在 tsx 下红、原生下绿。
|
|
23
|
+
- `click by ref reaches an element taller than the viewport`、`an element that is never on screen is reported as off-screen` —— 夹视口与原因措辞的回归用例。
|
|
24
|
+
- `stale-ref journey` 改用**不在 `#recent` 里**的商品做断言:`#recent` 异步插入 `CATALOG.slice(0,4)`,其中恰好含原先那个商品,于是"链接数 === 0"只在竞态窗口内成立 —— 慢一点的点击就会输掉这个竞态(这正是本次调查中先撞上的那条失败)。
|
|
25
|
+
|
|
26
|
+
### Hardened afterwards
|
|
27
|
+
|
|
28
|
+
- **三个同类页内函数(`elementStabilityProbe` / `pageStabilityProbe` / `labelTargetProbe`,含 `labelOf` 内部的 `attr`)也改成对转译器免疫的写法。** 用的是实测安全的数组解构:`const [sig, tick] = [() => …, () => …]` —— 初始值是数组字面量而不是函数字面量,所以不会被 `__name` 包,而且两个函数仍可互相调用(自递归也验过)。
|
|
29
|
+
验证方式很直接:同一批测试**在 tsx 下也变绿**。加固前 `click by visible label` 在 tsx 下红、原生下绿;加固后两种运行器全绿(本地全套 117 / 114 pass / 0 fail / 3 skipped,verify 57 项)。
|
|
7
30
|
|
|
8
31
|
## 0.5.1
|
|
9
32
|
|
package/lib/playwright.d.ts
CHANGED
|
@@ -409,8 +409,8 @@ declare class PlaywrightSession {
|
|
|
409
409
|
resolveClickPoint(locator: Locator, signal: AbortSignal | undefined): Promise<{
|
|
410
410
|
x: number;
|
|
411
411
|
y: number;
|
|
412
|
-
hitDesc: string
|
|
413
|
-
hitSelf: boolean
|
|
412
|
+
hitDesc: string;
|
|
413
|
+
hitSelf: boolean;
|
|
414
414
|
}>;
|
|
415
415
|
click(ref: string, signal: AbortSignal | undefined): Promise<BrowserSnapshot | {
|
|
416
416
|
url: string;
|
|
@@ -544,6 +544,14 @@ declare class PlaywrightSession {
|
|
|
544
544
|
}>;
|
|
545
545
|
openTab(url: string, waitUntil: LoadState, signal: AbortSignal | undefined): Promise<BrowserSnapshot>;
|
|
546
546
|
closeTab(index: number, signal: AbortSignal | undefined): Promise<void>;
|
|
547
|
+
/**
|
|
548
|
+
* Screenshots get their own floor instead of reusing the navigation budget: a
|
|
549
|
+
* full-page capture is sized by the page and encoded by the CPU, so a loaded machine
|
|
550
|
+
* legitimately needs seconds (2026-10-05: the Windows runner blew through the 2500ms
|
|
551
|
+
* navigation timeout while encoding a full-page PNG — a slow runner, not a defect).
|
|
552
|
+
* The floor only bounds failures; a capture that is ready returns immediately.
|
|
553
|
+
*/
|
|
554
|
+
screenshotTimeoutMs(): number;
|
|
547
555
|
screenshot(opts: {
|
|
548
556
|
fullPage?: boolean;
|
|
549
557
|
ref?: string;
|
package/lib/playwright.js
CHANGED
|
@@ -1214,26 +1214,32 @@ class PlaywrightSession {
|
|
|
1214
1214
|
];
|
|
1215
1215
|
const hitTest = (el) => {
|
|
1216
1216
|
const r = el.getBoundingClientRect();
|
|
1217
|
-
const x = Math.max(0, r.left + r.width / 2);
|
|
1218
|
-
const y = Math.max(0, r.top + r.height / 2);
|
|
1219
1217
|
const w = r.width;
|
|
1220
1218
|
const h = r.height;
|
|
1219
|
+
const geometry = 'rect ' + Math.round(r.left) + ',' + Math.round(r.top) + ' ' + Math.round(w) + 'x' + Math.round(h);
|
|
1221
1220
|
if (w <= 0 || h <= 0)
|
|
1222
|
-
return { ok: false, reason: 'zero-size', x, y };
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1221
|
+
return { ok: false, reason: 'zero-size', x: r.left, y: r.top, geometry };
|
|
1222
|
+
// The probe must sit inside the element AND inside the viewport:
|
|
1223
|
+
// elementFromPoint() answers null for coordinates outside the viewport, so
|
|
1224
|
+
// sampling the element's own centre missed any target taller than the viewport
|
|
1225
|
+
// (or one whose centre was below the fold) even when most of it was plainly
|
|
1226
|
+
// clickable. Aim at the centre of the visible intersection instead.
|
|
1227
|
+
const view = (el.ownerDocument && el.ownerDocument.defaultView) || window;
|
|
1228
|
+
const left = Math.max(r.left, 0);
|
|
1229
|
+
const top = Math.max(r.top, 0);
|
|
1230
|
+
const right = Math.min(r.right, view.innerWidth);
|
|
1231
|
+
const bottom = Math.min(r.bottom, view.innerHeight);
|
|
1232
|
+
if (right - left <= 0 || bottom - top <= 0) {
|
|
1233
|
+
return {
|
|
1234
|
+
ok: false,
|
|
1235
|
+
reason: 'off-screen',
|
|
1236
|
+
x: r.left,
|
|
1237
|
+
y: r.top,
|
|
1238
|
+
geometry: geometry + ' viewport ' + view.innerWidth + 'x' + view.innerHeight,
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
const x = (left + right) / 2;
|
|
1242
|
+
const y = (top + bottom) / 2;
|
|
1237
1243
|
let hit = null;
|
|
1238
1244
|
try {
|
|
1239
1245
|
hit = document.elementFromPoint(x, y);
|
|
@@ -1242,7 +1248,23 @@ class PlaywrightSession {
|
|
|
1242
1248
|
hit = null;
|
|
1243
1249
|
}
|
|
1244
1250
|
if (!hit)
|
|
1245
|
-
return { ok: false, reason: 'nothing-at-point', x, y };
|
|
1251
|
+
return { ok: false, reason: 'nothing-at-point', x, y, geometry };
|
|
1252
|
+
// Describe the hit inline, as a string: this function is serialized into the
|
|
1253
|
+
// page, so it must be self-contained. A transpiler that keeps function names
|
|
1254
|
+
// (esbuild/tsx, through its `__name` helper) rewrites a named inner binding
|
|
1255
|
+
// like `const describe = (…) => …` into a call to a helper that exists only in
|
|
1256
|
+
// module scope — and the page has no such helper, so the probe would throw
|
|
1257
|
+
// ReferenceError and every click would silently fall back to Playwright's
|
|
1258
|
+
// actionability click (3s cap). That is what made the Windows runner flaky
|
|
1259
|
+
// until 2026-10-05; the test runner now strips types natively, and keeping the
|
|
1260
|
+
// probe free of inner function bindings protects anyone who transpiles src/.
|
|
1261
|
+
const hitDesc = (hit.tagName ? hit.tagName.toLowerCase() : hit.nodeName) +
|
|
1262
|
+
(hit.id ? '#' + hit.id : '') +
|
|
1263
|
+
(typeof hit.className === 'string' && hit.className.trim()
|
|
1264
|
+
? '.' + hit.className.trim().split(/\s+/).filter(Boolean).join('.')
|
|
1265
|
+
: '') +
|
|
1266
|
+
(hit.getAttribute && hit.getAttribute('role') ? '[role=' + hit.getAttribute('role') + ']' : '') +
|
|
1267
|
+
(hit.childElementCount === 0 && hit.textContent ? ' "' + hit.textContent.trim().slice(0, 24) + '"' : '');
|
|
1246
1268
|
let belongs = false;
|
|
1247
1269
|
if (hit === el || el.contains(hit)) {
|
|
1248
1270
|
belongs = true;
|
|
@@ -1265,11 +1287,34 @@ class PlaywrightSession {
|
|
|
1265
1287
|
}
|
|
1266
1288
|
}
|
|
1267
1289
|
return belongs
|
|
1268
|
-
? { ok: true, x, y, hitDesc
|
|
1269
|
-
: { ok: false, reason: 'intercepted', x, y, by:
|
|
1290
|
+
? { ok: true, x, y, hitDesc, hitSelf: hit === el }
|
|
1291
|
+
: { ok: false, reason: 'intercepted', x, y, by: hitDesc, geometry };
|
|
1270
1292
|
};
|
|
1271
1293
|
let lastBlockedBy;
|
|
1272
1294
|
let lastReason;
|
|
1295
|
+
let lastGeometry;
|
|
1296
|
+
/** Message from the most recent probe that threw, so the failure names its cause. */
|
|
1297
|
+
let probeError;
|
|
1298
|
+
// 2026-10-05: the first version of this loop dropped the probe's reason on the
|
|
1299
|
+
// floor — the guard below read `lastReason === null` while the variable started
|
|
1300
|
+
// as `undefined`, so it never ran and every transient miss came out as "could not
|
|
1301
|
+
// find an unobstructed click point" with nothing to act on. That message is what
|
|
1302
|
+
// the slower Windows runner produced while ubuntu and local runs passed, and it
|
|
1303
|
+
// cost a debugging session to learn what it actually meant. Record the reason and
|
|
1304
|
+
// keep the most informative one.
|
|
1305
|
+
const rank = {
|
|
1306
|
+
intercepted: 5,
|
|
1307
|
+
'zero-size': 4,
|
|
1308
|
+
'off-screen': 3,
|
|
1309
|
+
'nothing-at-point': 2,
|
|
1310
|
+
unreachable: 1,
|
|
1311
|
+
};
|
|
1312
|
+
const record = (reason, geometry) => {
|
|
1313
|
+
if (lastReason === undefined || (rank[reason] ?? 0) > (rank[lastReason] ?? 0)) {
|
|
1314
|
+
lastReason = reason;
|
|
1315
|
+
lastGeometry = geometry;
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1273
1318
|
for (const align of alignments) {
|
|
1274
1319
|
try {
|
|
1275
1320
|
// Native scroll so block alignment is honoured; Playwright's
|
|
@@ -1282,17 +1327,35 @@ class PlaywrightSession {
|
|
|
1282
1327
|
catch {
|
|
1283
1328
|
/* try the next alignment */
|
|
1284
1329
|
}
|
|
1285
|
-
//
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1330
|
+
// Sample a few times per alignment: a page that is still laying out can move the
|
|
1331
|
+
// element between the scroll and the probe, and the first sample then misses even
|
|
1332
|
+
// though a later one is fine. Retries are bounded and cheap — the stability wait
|
|
1333
|
+
// only runs before the first sample, and the extra samples are 60ms apart.
|
|
1334
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1335
|
+
if (attempt === 0)
|
|
1336
|
+
await this.waitForElementStable(locator);
|
|
1337
|
+
else
|
|
1338
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
1339
|
+
const probe = await withAbort(locator.evaluate(hitTest), signal).catch((error) => {
|
|
1340
|
+
// Keep the message. "unreachable" on its own told nobody anything, and
|
|
1341
|
+
// swallowing the error here is exactly what let a broken probe look like an
|
|
1342
|
+
// obstructed element for a whole debugging session.
|
|
1343
|
+
probeError = error instanceof Error ? error.message : String(error);
|
|
1344
|
+
return null;
|
|
1345
|
+
});
|
|
1346
|
+
if (probe !== null && probe.ok)
|
|
1347
|
+
return { x: probe.x, y: probe.y, hitDesc: probe.hitDesc, hitSelf: probe.hitSelf };
|
|
1348
|
+
if (probe === null)
|
|
1349
|
+
record('unreachable', probeError);
|
|
1350
|
+
else {
|
|
1351
|
+
record(probe.reason, probe.geometry);
|
|
1352
|
+
if (probe.reason === 'intercepted')
|
|
1353
|
+
lastBlockedBy = probe.by;
|
|
1354
|
+
}
|
|
1355
|
+
// Interception is a property of the page, not of the moment: re-sampling cannot
|
|
1356
|
+
// clear it, so spend the remaining budget on the next alignment instead.
|
|
1357
|
+
if (probe !== null && probe.reason === 'intercepted')
|
|
1358
|
+
break;
|
|
1296
1359
|
}
|
|
1297
1360
|
}
|
|
1298
1361
|
if (lastReason === 'intercepted') {
|
|
@@ -1300,10 +1363,18 @@ class PlaywrightSession {
|
|
|
1300
1363
|
(lastBlockedBy || 'another element') +
|
|
1301
1364
|
' intercepts the click (tried centre / end / start alignment)');
|
|
1302
1365
|
}
|
|
1366
|
+
if (lastReason === 'off-screen') {
|
|
1367
|
+
throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'the element is outside the viewport even after scrolling it into view; nothing of it is on screen' +
|
|
1368
|
+
(lastGeometry === undefined ? '' : ' (' + lastGeometry + ')') +
|
|
1369
|
+
'; take a fresh browser_snapshot');
|
|
1370
|
+
}
|
|
1303
1371
|
if (lastReason === 'zero-size') {
|
|
1304
1372
|
throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'ref has no clickable geometry (zero size); take a fresh browser_snapshot');
|
|
1305
1373
|
}
|
|
1306
|
-
throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'could not find an unobstructed click point for the ref
|
|
1374
|
+
throw new BrowserError('ELEMENT_NOT_ACTIONABLE', 'could not find an unobstructed click point for the ref (last probe: ' +
|
|
1375
|
+
(lastReason ?? 'no probe completed') +
|
|
1376
|
+
(lastGeometry === undefined ? '' : ' — ' + lastGeometry) +
|
|
1377
|
+
'); take a fresh browser_snapshot');
|
|
1307
1378
|
}
|
|
1308
1379
|
async click(ref, signal) {
|
|
1309
1380
|
return this.run(async () => {
|
|
@@ -1896,13 +1967,23 @@ class PlaywrightSession {
|
|
|
1896
1967
|
this.currentIndex = Math.min(this.currentIndex, this.context.pages().length - 1);
|
|
1897
1968
|
});
|
|
1898
1969
|
}
|
|
1970
|
+
/**
|
|
1971
|
+
* Screenshots get their own floor instead of reusing the navigation budget: a
|
|
1972
|
+
* full-page capture is sized by the page and encoded by the CPU, so a loaded machine
|
|
1973
|
+
* legitimately needs seconds (2026-10-05: the Windows runner blew through the 2500ms
|
|
1974
|
+
* navigation timeout while encoding a full-page PNG — a slow runner, not a defect).
|
|
1975
|
+
* The floor only bounds failures; a capture that is ready returns immediately.
|
|
1976
|
+
*/
|
|
1977
|
+
screenshotTimeoutMs() {
|
|
1978
|
+
return Math.max(this.timeoutMs(), 10_000);
|
|
1979
|
+
}
|
|
1899
1980
|
async screenshot(opts, signal) {
|
|
1900
1981
|
return this.run(async () => {
|
|
1901
1982
|
this.assertLive();
|
|
1902
1983
|
this.provider.touch(this.owner);
|
|
1903
1984
|
const page = await this.ensurePage();
|
|
1904
1985
|
if (opts?.ref !== undefined) {
|
|
1905
|
-
const bytes = await withAbort((await this.refLocator(opts.ref)).screenshot({ type: 'png', timeout: this.
|
|
1986
|
+
const bytes = await withAbort((await this.refLocator(opts.ref)).screenshot({ type: 'png', timeout: this.screenshotTimeoutMs() }), signal);
|
|
1906
1987
|
return { mime: 'image/png', bytes };
|
|
1907
1988
|
}
|
|
1908
1989
|
const bytes = await withAbort(page.screenshot({
|
|
@@ -1910,7 +1991,7 @@ class PlaywrightSession {
|
|
|
1910
1991
|
fullPage: opts?.fullPage ?? false,
|
|
1911
1992
|
animations: 'disabled',
|
|
1912
1993
|
caret: 'hide',
|
|
1913
|
-
timeout: this.
|
|
1994
|
+
timeout: this.screenshotTimeoutMs(),
|
|
1914
1995
|
}), signal);
|
|
1915
1996
|
return { mime: 'image/png', bytes };
|
|
1916
1997
|
});
|
|
@@ -2365,47 +2446,58 @@ const clearLabelTargets = () => {
|
|
|
2365
2446
|
*/
|
|
2366
2447
|
const labelTargetProbe = (arg) => {
|
|
2367
2448
|
const wanted = String(arg.text).replace(/\s+/g, ' ').trim();
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2449
|
+
// Array destructuring, not named bindings: this function is serialized into the page,
|
|
2450
|
+
// and a transpiler that keeps function names (esbuild's `__name`) would rewrite a named
|
|
2451
|
+
// inner binding into a call to a module-scope helper the page does not have. An array
|
|
2452
|
+
// literal is not a named binding, so it survives (same rule as the probes below).
|
|
2453
|
+
const [norm, ownText] = [
|
|
2454
|
+
(s) => (s || '').replace(/\s+/g, ' ').trim(),
|
|
2455
|
+
(el) => {
|
|
2456
|
+
let out = '';
|
|
2457
|
+
for (const node of el.childNodes) {
|
|
2458
|
+
if (node.nodeType === 3)
|
|
2459
|
+
out += node.textContent;
|
|
2460
|
+
else if (node.nodeType === 1 && node.tagName.toLowerCase() === 'br')
|
|
2461
|
+
out += ' ';
|
|
2462
|
+
}
|
|
2463
|
+
return norm(out);
|
|
2464
|
+
},
|
|
2465
|
+
];
|
|
2379
2466
|
// Mirror the snapshot's name sources, so anything the page shows under a
|
|
2380
2467
|
// name can be addressed by that name: aria-label / alt / title / placeholder
|
|
2381
2468
|
// first, then the element's own visible text.
|
|
2382
|
-
const labelOf
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2469
|
+
const [labelOf, visible, depthOf] = [
|
|
2470
|
+
(el) =>
|
|
2471
|
+
// Attribute reads are inlined, not wrapped in a local `attr` helper: any named
|
|
2472
|
+
// function binding inside this serialized function would be rewritten to a call to
|
|
2473
|
+
// a module-scope helper the page does not have. norm() already turns null/blank
|
|
2474
|
+
// into '', which is what the helper did.
|
|
2475
|
+
norm(el.getAttribute('aria-label')) ||
|
|
2476
|
+
norm(el.getAttribute('alt')) ||
|
|
2477
|
+
norm(el.getAttribute('title')) ||
|
|
2478
|
+
norm(el.getAttribute('placeholder')) ||
|
|
2479
|
+
ownText(el),
|
|
2480
|
+
(el) => {
|
|
2481
|
+
if (!(el instanceof Element))
|
|
2482
|
+
return false;
|
|
2483
|
+
if (el.getAttribute('hidden') !== null || el.getAttribute('aria-hidden') === 'true')
|
|
2484
|
+
return false;
|
|
2485
|
+
const style = getComputedStyle(el);
|
|
2486
|
+
if (style.display === 'none' || style.visibility === 'hidden')
|
|
2487
|
+
return false;
|
|
2488
|
+
const rect = el.getBoundingClientRect();
|
|
2489
|
+
return rect.width > 0 || rect.height > 0;
|
|
2490
|
+
},
|
|
2491
|
+
(el) => {
|
|
2492
|
+
let depth = 0;
|
|
2493
|
+
let cur = el;
|
|
2494
|
+
while (cur !== null && cur.parentElement !== null) {
|
|
2495
|
+
depth += 1;
|
|
2496
|
+
cur = cur.parentElement;
|
|
2497
|
+
}
|
|
2498
|
+
return depth;
|
|
2499
|
+
},
|
|
2500
|
+
];
|
|
2409
2501
|
const root = document.body !== null && document.body !== undefined ? document.body : document.documentElement;
|
|
2410
2502
|
const all = Array.from(root.querySelectorAll('*')).slice(0, 8000).filter(visible);
|
|
2411
2503
|
let pool = all.filter((el) => labelOf(el) === wanted);
|
|
@@ -2452,29 +2544,37 @@ const elementStabilityProbe = (el, arg) => new Promise((resolve) => {
|
|
|
2452
2544
|
const deadline = performance.now() + timeoutMs;
|
|
2453
2545
|
let prev = null;
|
|
2454
2546
|
let stable = 0;
|
|
2455
|
-
const sig =
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2547
|
+
// Array destructuring, not `const sig = …`: these functions are serialized into
|
|
2548
|
+
// the page, and a transpiler that keeps function names (esbuild's `__name`) rewrites
|
|
2549
|
+
// a named inner binding into a *call* to a module-scope helper the page does not have
|
|
2550
|
+
// (`ReferenceError: __name is not defined`). An array literal is not a named binding,
|
|
2551
|
+
// so this shape survives — and the two can still call each other. Verified 2026-10-05
|
|
2552
|
+
// with fn.toString() under tsx, and by running this suite under tsx again.
|
|
2553
|
+
const [sig, tick] = [
|
|
2554
|
+
() => {
|
|
2555
|
+
const r = el.getBoundingClientRect();
|
|
2556
|
+
return [r.left, r.top, r.width, r.height].join(',');
|
|
2557
|
+
},
|
|
2558
|
+
() => {
|
|
2559
|
+
const cur = sig();
|
|
2560
|
+
if (prev !== null && cur === prev) {
|
|
2561
|
+
stable += 1;
|
|
2562
|
+
if (stable >= frames) {
|
|
2563
|
+
resolve(true);
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
else {
|
|
2568
|
+
stable = 0;
|
|
2569
|
+
}
|
|
2570
|
+
prev = cur;
|
|
2571
|
+
if (performance.now() >= deadline) {
|
|
2572
|
+
resolve(false);
|
|
2465
2573
|
return;
|
|
2466
2574
|
}
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
}
|
|
2471
|
-
prev = cur;
|
|
2472
|
-
if (performance.now() >= deadline) {
|
|
2473
|
-
resolve(false);
|
|
2474
|
-
return;
|
|
2475
|
-
}
|
|
2476
|
-
requestAnimationFrame(tick);
|
|
2477
|
-
};
|
|
2575
|
+
requestAnimationFrame(tick);
|
|
2576
|
+
},
|
|
2577
|
+
];
|
|
2478
2578
|
requestAnimationFrame(tick);
|
|
2479
2579
|
});
|
|
2480
2580
|
/**
|
|
@@ -2488,28 +2588,36 @@ const pageStabilityProbe = (arg) => new Promise((resolve) => {
|
|
|
2488
2588
|
const deadline = performance.now() + timeoutMs;
|
|
2489
2589
|
let prev = null;
|
|
2490
2590
|
let stable = 0;
|
|
2491
|
-
const sig =
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2591
|
+
// Array destructuring, not `const sig = …`: these functions are serialized into
|
|
2592
|
+
// the page, and a transpiler that keeps function names (esbuild's `__name`) rewrites
|
|
2593
|
+
// a named inner binding into a *call* to a module-scope helper the page does not have
|
|
2594
|
+
// (`ReferenceError: __name is not defined`). An array literal is not a named binding,
|
|
2595
|
+
// so this shape survives — and the two can still call each other. Verified 2026-10-05
|
|
2596
|
+
// with fn.toString() under tsx, and by running this suite under tsx again.
|
|
2597
|
+
const [sig, tick] = [
|
|
2598
|
+
() => {
|
|
2599
|
+
const de = document.documentElement;
|
|
2600
|
+
return [de.scrollWidth, de.scrollHeight, document.querySelectorAll('img:not([complete])').length].join(',');
|
|
2601
|
+
},
|
|
2602
|
+
() => {
|
|
2603
|
+
const cur = sig();
|
|
2604
|
+
if (prev !== null && cur === prev) {
|
|
2605
|
+
stable += 1;
|
|
2606
|
+
if (stable >= frames) {
|
|
2607
|
+
resolve(true);
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
else {
|
|
2612
|
+
stable = 0;
|
|
2613
|
+
}
|
|
2614
|
+
prev = cur;
|
|
2615
|
+
if (performance.now() >= deadline) {
|
|
2616
|
+
resolve(false);
|
|
2501
2617
|
return;
|
|
2502
2618
|
}
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
}
|
|
2507
|
-
prev = cur;
|
|
2508
|
-
if (performance.now() >= deadline) {
|
|
2509
|
-
resolve(false);
|
|
2510
|
-
return;
|
|
2511
|
-
}
|
|
2512
|
-
requestAnimationFrame(tick);
|
|
2513
|
-
};
|
|
2619
|
+
requestAnimationFrame(tick);
|
|
2620
|
+
},
|
|
2621
|
+
];
|
|
2514
2622
|
requestAnimationFrame(tick);
|
|
2515
2623
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-browser-playwright-codex",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
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",
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
],
|
|
42
42
|
"scripts": {
|
|
43
43
|
"build": "node node_modules/typescript/bin/tsc -p tsconfig.json",
|
|
44
|
-
"test": "node
|
|
45
|
-
"test:scenarios": "node
|
|
46
|
-
"test:live": "node
|
|
44
|
+
"test": "node --test \"tests/*.test.ts\"",
|
|
45
|
+
"test:scenarios": "node --test tests/scenarios.test.ts tests/tool-scenarios.test.ts",
|
|
46
|
+
"test:live": "node --test tests/live-smoke.test.ts",
|
|
47
47
|
"verify": "npm run build && node scripts/dsh-browser-verify.mjs",
|
|
48
48
|
"verify:live": "npm run build && node scripts/dsh-browser-verify.mjs --live",
|
|
49
49
|
"doctor": "node scripts/doctor.mjs",
|