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 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
- 一句话摘要:**把「看着危险」变成「说得清楚」** —— 新增 `SECURITY.md`,把 dsh.so 扫描报的 **2 Critical / 5 Warning / 26 Info** 逐条对上:那 2 个 Critical 就是 `browser_evaluate` 的实现,而**包里默认关闭**(关着时工具根本不注册)。README 第一屏重写(中文一句话 + 一条命令安装 + 五条差异点 + 安全入口),并又清掉两处「出厂开启闸门」的过期说法。
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
 
@@ -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 | undefined;
413
- hitSelf: boolean | undefined;
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
- const describe = (n) => {
1224
- if (!n)
1225
- return '(nothing)';
1226
- const cls = typeof n.className === 'string' && n.className.trim()
1227
- ? '.' + n.className.trim().split(/\s+/).filter(Boolean).join('.')
1228
- : '';
1229
- const role = n.getAttribute && n.getAttribute('role');
1230
- const text = n.childElementCount === 0 && n.textContent ? ' "' + n.textContent.trim().slice(0, 24) + '"' : '';
1231
- return ((n.tagName ? n.tagName.toLowerCase() : n.nodeName) +
1232
- (n.id ? '#' + n.id : '') +
1233
- cls +
1234
- (role ? '[role=' + role + ']' : '') +
1235
- text);
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: describe(hit), hitSelf: hit === el }
1269
- : { ok: false, reason: 'intercepted', x, y, by: describe(hit) };
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
- // Wait out layout shifts before sampling the centre point.
1286
- await this.waitForElementStable(locator);
1287
- const probe = await withAbort(locator.evaluate(hitTest), signal).catch(() => null);
1288
- if (probe && probe.ok)
1289
- return { x: probe.x, y: probe.y, hitDesc: probe.hitDesc, hitSelf: probe.hitSelf };
1290
- if (probe && probe.reason === 'intercepted') {
1291
- lastBlockedBy = probe.by;
1292
- lastReason = 'intercepted';
1293
- }
1294
- else if (lastReason === null) {
1295
- lastReason = probe ? probe.reason : 'unreachable';
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; take a fresh browser_snapshot');
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.timeoutMs() }), signal);
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.timeoutMs(),
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
- const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
2369
- const ownText = (el) => {
2370
- let out = '';
2371
- for (const node of el.childNodes) {
2372
- if (node.nodeType === 3)
2373
- out += node.textContent;
2374
- else if (node.nodeType === 1 && node.tagName.toLowerCase() === 'br')
2375
- out += ' ';
2376
- }
2377
- return norm(out);
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 = (el) => {
2383
- const attr = (name) => {
2384
- const v = el.getAttribute(name);
2385
- return v && v.trim() ? v : '';
2386
- };
2387
- return (norm(attr('aria-label')) || norm(attr('alt')) || norm(attr('title')) || norm(attr('placeholder')) || ownText(el));
2388
- };
2389
- const visible = (el) => {
2390
- if (!(el instanceof Element))
2391
- return false;
2392
- if (el.getAttribute('hidden') !== null || el.getAttribute('aria-hidden') === 'true')
2393
- return false;
2394
- const style = getComputedStyle(el);
2395
- if (style.display === 'none' || style.visibility === 'hidden')
2396
- return false;
2397
- const rect = el.getBoundingClientRect();
2398
- return rect.width > 0 || rect.height > 0;
2399
- };
2400
- const depthOf = (el) => {
2401
- let depth = 0;
2402
- let cur = el;
2403
- while (cur !== null && cur.parentElement !== null) {
2404
- depth += 1;
2405
- cur = cur.parentElement;
2406
- }
2407
- return depth;
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
- const r = el.getBoundingClientRect();
2457
- return [r.left, r.top, r.width, r.height].join(',');
2458
- };
2459
- const tick = () => {
2460
- const cur = sig();
2461
- if (prev !== null && cur === prev) {
2462
- stable += 1;
2463
- if (stable >= frames) {
2464
- resolve(true);
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
- else {
2469
- stable = 0;
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
- const de = document.documentElement;
2493
- return [de.scrollWidth, de.scrollHeight, document.querySelectorAll('img:not([complete])').length].join(',');
2494
- };
2495
- const tick = () => {
2496
- const cur = sig();
2497
- if (prev !== null && cur === prev) {
2498
- stable += 1;
2499
- if (stable >= frames) {
2500
- resolve(true);
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
- else {
2505
- stable = 0;
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.1",
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 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",
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",