@glittering0717/ai-browser 2.1.17 → 2.1.19

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/README.md CHANGED
@@ -724,7 +724,7 @@ not bugs. The full write-up, including why the first measurement of this was wro
724
724
  ## Test & dev
725
725
 
726
726
  ```bash
727
- npm test # vitest — 411 tests across 27 files
727
+ npm test # vitest — 970 tests across 69 files
728
728
  npm run smoke # Electron contract layer, offline fixture — 31 checks
729
729
  npm run capabilities # text fields, canvas, page source, network, DOM editing — 54 checks
730
730
  npm run richtext # rich-text editors, nested menus, forms, errors, tags, upload — 117 checks
@@ -736,8 +736,14 @@ npm run audit # every advertised action must exist; wait/downloads/permis
736
736
  npm run nav # address bar + load-failure error page (the "white screen" report) — 19 checks
737
737
  npm run focus # no focus stealing + full capability while backgrounded — 32 checks
738
738
  npm run interact # trusted drag / key press / wheel (pointer gestures) — 31 checks
739
- npm run view # viewport repaint + snapshot/diff verification — 28 checks
740
- npm run e2e:all # 一次跑完上面全部套件(每个独立端口)+ 可信汇总 — 428 checks
739
+ npm run view # viewport repaint budget + snapshot/diff verification — 29 checks
740
+ npm run dialog # browser-level dialogs (native sheet blocks the renderer) — 15 checks
741
+ npm run wrapper # "in the tree but won't click" = matched a container, not a link — 13 checks
742
+ npm run paint # blank-page self-check: verdicts, bounded ladder, no false alarms — 23 checks
743
+ npm run layout # geometry invariants + "no twitching" (zero extra repaints on nav) — 25 checks
744
+ npm run flicker # frame-level: no frame may render the page shrunk (needs screen recording) — 6 checks
745
+ npm run e2e:all # 一次跑完上面全部 19 个套件(每个独立端口)+ 可信汇总 — 636 checks
746
+ npm run verify:app # boot the packaged /Applications app and assert on it — 22 checks
741
747
  npm run realsites # navigate/read/act against a matrix of real sites
742
748
  ```
743
749
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittering0717/ai-browser",
3
- "version": "2.1.17",
3
+ "version": "2.1.19",
4
4
  "description": "AI-native browser — renders pixels for humans, structured semantic-tree API for agents. No screenshots, no OCR, no coordinate guessing.",
5
5
  "main": "src/main/index.js",
6
6
  "bin": {
@@ -84,7 +84,8 @@
84
84
  "verify:app": "node e2e/shipped_ws.cjs",
85
85
  "wrapper": "node e2e/wrapper_ws.cjs",
86
86
  "paint": "node e2e/paint_ws.cjs",
87
- "layout": "node e2e/layout_ws.cjs"
87
+ "layout": "node e2e/layout_ws.cjs",
88
+ "flicker": "node e2e/flicker_ws.cjs"
88
89
  },
89
90
  "dependencies": {
90
91
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1040,7 +1040,18 @@ class PageManager {
1040
1040
  captureBeyondViewport: o.fullPage,
1041
1041
  };
1042
1042
  if (o.quality !== undefined) params.quality = o.quality;
1043
- if (o.clip) params.clip = o.clip;
1043
+ // clip.scale < 1("给我一张缩略概览")**绝不交给合成器**:带 clip.scale 的
1044
+ // 截图会让 Chromium 按该比例临时重建 surface,窗口恰好在合成时就会贴上那张
1045
+ // 缩小的图 1–3 帧(见 SPEC「取帧两条铁律」,2026-10-25 实锤)。
1046
+ // 现在按 scale=1 抓、用 nativeImage 在我们自己这边缩 —— 输出尺寸一致,
1047
+ // 但不再碰合成器。scale>1(放大取值)仍交给 Chromium:本地放大得不到像素。
1048
+ let localScale = 1;
1049
+ if (o.clip && typeof o.clip.scale === 'number' && o.clip.scale > 0 && o.clip.scale < 1) {
1050
+ localScale = o.clip.scale;
1051
+ params.clip = { ...o.clip, scale: 1 };
1052
+ } else if (o.clip) {
1053
+ params.clip = o.clip;
1054
+ }
1044
1055
  const shot = await withDeadline(
1045
1056
  dbg.sendCommand('Page.captureScreenshot', params),
1046
1057
  20000, 'captureScreenshot'
@@ -1048,6 +1059,21 @@ class PageManager {
1048
1059
  if (!shot || typeof shot.data !== 'string') {
1049
1060
  return { ok: false, error: 'empty screenshot result', tab: tid };
1050
1061
  }
1062
+ // 需要缩小就自己做(见上:不把缩放交给合成器)。
1063
+ let imgData = shot.data;
1064
+ if (localScale !== 1) {
1065
+ try {
1066
+ const img0 = nativeImage.createFromBuffer(Buffer.from(shot.data, 'base64'));
1067
+ if (!img0.isEmpty()) {
1068
+ const sz = img0.getSize();
1069
+ const w = Math.max(1, Math.round(sz.width * localScale));
1070
+ const resized = img0.resize({ width: w });
1071
+ imgData = o.format === 'jpeg'
1072
+ ? resized.toJPEG(typeof o.quality === 'number' ? o.quality : 80).toString('base64')
1073
+ : resized.toPNG().toString('base64');
1074
+ }
1075
+ } catch (_) { /* 缩失败就用原图(宁可图大,不要空图) */ }
1076
+ }
1051
1077
 
1052
1078
  const mime = 'image/' + (o.format === 'jpeg' ? 'jpeg' : o.format);
1053
1079
  // 一次 evaluate 拿两样东西:可见密码框(截图不脱敏,泄密了得让调用方
@@ -1070,7 +1096,7 @@ class PageManager {
1070
1096
  const summary = screenshotOpts.describeShot({
1071
1097
  format: o.format,
1072
1098
  mime,
1073
- bytes: Buffer.byteLength(shot.data, 'base64'),
1099
+ bytes: Buffer.byteLength(imgData, 'base64'),
1074
1100
  width: meta.width,
1075
1101
  height: meta.height,
1076
1102
  url: (this._getView(tid) && this._getView(tid).webContents.getURL()) || undefined,
@@ -1082,7 +1108,7 @@ class PageManager {
1082
1108
  if (o.path) {
1083
1109
  try {
1084
1110
  fs.mkdirSync(path.dirname(o.path), { recursive: true });
1085
- fs.writeFileSync(o.path, Buffer.from(shot.data, 'base64'));
1111
+ fs.writeFileSync(o.path, Buffer.from(imgData, 'base64'));
1086
1112
  filePath = o.path;
1087
1113
  } catch (e) {
1088
1114
  return { ok: false, tab: tid, error: 'screenshot captured but writing to ' + o.path + ' failed: ' + ((e && e.message) || e), ...summary };
@@ -1095,7 +1121,7 @@ class PageManager {
1095
1121
  ok: true,
1096
1122
  tab: tid,
1097
1123
  // data 是 base64 裸数据(不带 data: 前缀)—— 调用方按需自己拼。
1098
- data: shot.data,
1124
+ data: imgData,
1099
1125
  mime,
1100
1126
  ...(filePath ? { path: filePath } : {}),
1101
1127
  ...(ignored.length ? { ignored_params: ignored } : {}),
@@ -3727,41 +3753,46 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3727
3753
  let stats = null;
3728
3754
  let frameError = null;
3729
3755
  try {
3730
- // 位图来源优先走 CDP(和 ui.screenshot 同一条路),而不是
3731
- // webContents.capturePage()。实测(2026-10-25,e2e/paint 与真机):
3732
- // capturePage 对**被其它窗口盖住**的窗口返回空图(isEmpty()===true),
3733
- // 而"窗口被盖住"恰恰是本产品最常见的状态(不抢焦点原则)。用 capturePage
3734
- // 就等于在最需要观测的场景下失明 —— 判出来是 no-frame,什么都没看见。
3735
- // CDP 的 Page.captureScreenshot 走渲染器自己的 surface,被遮挡也拿得到。
3756
+ // 位图来源,优先级从"最不容易打扰合成器"往下排:
3736
3757
  //
3737
- // clip.scale=0.25:判"是不是白的"不需要细节,缩到 1/4 后像素数从 350 万
3738
- // 降到 22 万,统计从几十毫秒降到几毫秒,而且和用户看到的是同一帧内容。
3739
- const w = bounds && bounds.width ? Math.max(1, Math.round(bounds.width)) : 0;
3740
- const h = bounds && bounds.height ? Math.max(1, Math.round(bounds.height)) : 0;
3741
- let shot = null;
3742
- if (w && h) {
3743
- try {
3744
- this._ensureCdp(tid, view, 'Page');
3745
- shot = await withDeadline(
3746
- view.webContents.debugger.sendCommand('Page.captureScreenshot', {
3747
- format: 'png',
3748
- fromSurface: true,
3749
- clip: { x: 0, y: 0, width: w, height: h, scale: 0.25 },
3750
- }),
3751
- 4000, 'paint_shot'
3752
- );
3753
- } catch (_) { /* 落到 capturePage 兜底 */ }
3754
- }
3755
- if (shot && shot.data) {
3756
- const buf = Buffer.from(shot.data, 'base64');
3757
- const img = nativeImage.createFromBuffer(buf);
3758
- if (img && !img.isEmpty()) stats = blankPage.statsFromBitmap(img.toBitmap());
3759
- }
3758
+ // 1) webContents.capturePage() —— 不碰 CDP 的 Page 域、没有 capture 模式,
3759
+ // 只是把视图自己的 surface 拷一份。窗口被别的窗口盖住时会返回空图
3760
+ // (isEmpty()),这时跳过、交给下一档,绝不据此判"页面是白的"。
3761
+ // 2) CDP Page.captureScreenshot,**不带 clip / scale**。
3762
+ //
3763
+ // ⚠️ 2026-10-25 血泪:这里原本是
3764
+ // clip: {x:0,y:0,width:w,height:h, scale:0.25}
3765
+ // 图省事把统计用的图直接缩到 1/4。带 clip.scale 的截图会让 Chromium
3766
+ // **按该比例临时重建 surface**;自检又恰好在 did-finish-load+450ms 跑,
3767
+ // 那几十毫秒里窗口往往正在重新合成,于是把这张 1/4 的图贴上去 1–3 帧,
3768
+ // 用户看到的就是"每次加载先小一下再恢复"。实测抓到的那一帧内容宽度
3769
+ // 正好是 25%,与 clip.scale 一致。
3770
+ // 结论:**缩放只许在我们自己这边做**(nativeImage.resize),不许交给合成器。
3771
+ try {
3772
+ const img0 = await withDeadline(wc.capturePage(), 3000, 'capturePage');
3773
+ if (img0 && typeof img0.isEmpty === 'function' && !img0.isEmpty()) {
3774
+ const small0 = img0.resize({ width: 240 });
3775
+ stats = blankPage.statsFromBitmap(small0.toBitmap());
3776
+ }
3777
+ } catch (_) { /* 视图被遮挡/正在销毁:交给 CDP 档 */ }
3760
3778
  if (!stats) {
3761
- const img = await withDeadline(wc.capturePage(), 4000, 'capturePage');
3762
- if (img && typeof img.isEmpty === 'function' && !img.isEmpty()) {
3763
- const small = img.resize({ width: 240 });
3764
- stats = blankPage.statsFromBitmap(small.toBitmap());
3779
+ const shot = await withDeadline(
3780
+ (() => {
3781
+ this._ensureCdp(tid, view, 'Page');
3782
+ return view.webContents.debugger.sendCommand('Page.captureScreenshot', {
3783
+ format: 'jpeg',
3784
+ quality: 70,
3785
+ fromSurface: true,
3786
+ });
3787
+ })(),
3788
+ 5000, 'paint_shot'
3789
+ ).catch(() => null);
3790
+ if (shot && shot.data) {
3791
+ const img = nativeImage.createFromBuffer(Buffer.from(shot.data, 'base64'));
3792
+ if (img && !img.isEmpty()) {
3793
+ const small = img.resize({ width: 240 });
3794
+ stats = blankPage.statsFromBitmap(small.toBitmap());
3795
+ }
3765
3796
  }
3766
3797
  }
3767
3798
  } catch (e) {
@@ -3869,6 +3900,43 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3869
3900
  || actual.x !== expected.x
3870
3901
  || actual.y !== expected.y;
3871
3902
  if (!bad) return { ok: true, expected, actual };
3903
+ // 不一致时先复核一次再动手:窗口刚变化的那一两百毫秒里,getContentBounds 与
3904
+ // 视图 bounds 会短暂对不上,此刻"纠正"等于把视图改成中间尺寸 —— 那正是
3905
+ // "页面缩成左上角一小块"的成因之一。宁可晚 120ms 修,也不要修错。
3906
+ return this._confirmAndFixLayout(tabId, expected, actual);
3907
+ }
3908
+
3909
+ _confirmAndFixLayout(tabId, expected, actual) {
3910
+ const view = this._getView(tabId);
3911
+ if (!view) return { ok: false, expected, actual, fixed: false };
3912
+ const t = setTimeout(() => {
3913
+ try {
3914
+ const v = this._getView(tabId);
3915
+ if (!v || !v.webContents || v.webContents.isDestroyed()) return;
3916
+ let cb;
3917
+ try { cb = this.window.getContentBounds(); } catch (_) { return; }
3918
+ const exp = {
3919
+ x: 0,
3920
+ y: config.tabBarHeight,
3921
+ width: cb.width,
3922
+ height: Math.max(0, cb.height - config.tabBarHeight - (this._bottomInset || 0)),
3923
+ };
3924
+ let act;
3925
+ try { act = v.getBounds(); } catch (_) { return; }
3926
+ const stillBad = act.width < exp.width - 2
3927
+ || Math.abs(act.height - exp.height) > 2
3928
+ || act.x !== exp.x || act.y !== exp.y;
3929
+ if (!stillBad) return; // 只是中间态,已经自己好了
3930
+ try {
3931
+ this._layoutView(v);
3932
+ v.webContents.invalidate();
3933
+ } catch (_) { /* 视图正在销毁 */ }
3934
+ recordError('layout_mismatch', { tab: tabId, expected: exp, actual: act });
3935
+ this._broadcast('layout_mismatch', { tab: tabId, expected: exp, actual: act });
3936
+ } catch (_) { /* 忽略 */ }
3937
+ }, 120);
3938
+ if (t.unref) t.unref();
3939
+ return { ok: false, expected, actual, fixed: 'deferred' };
3872
3940
  try {
3873
3941
  this._layoutView(view);
3874
3942
  view.webContents.invalidate();
@@ -2,4 +2,4 @@
2
2
  // Kept in JS (not imported from package.json) to avoid Node import-attribute
3
3
  // (JSON module) compatibility issues inside Electron. package.json "version"
4
4
  // is manually synced to this value; tests assert they match.
5
- export const VERSION = '2.1.17';
5
+ export const VERSION = '2.1.19';