dsh-agent-team-ui-testing 0.0.0-stage → 0.1.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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +327 -2
  3. package/annotation-capture.js +111 -0
  4. package/banner.png +0 -0
  5. package/banner_zh.png +0 -0
  6. package/browser-safety.js +293 -0
  7. package/capture-p0.js +910 -0
  8. package/client.js +11302 -0
  9. package/context-menu.js +134 -0
  10. package/cordis.patch.yml +3 -0
  11. package/core/clock.js +88 -0
  12. package/core/config-store.js +566 -0
  13. package/core/config.js +892 -0
  14. package/core/contracts.js +240 -0
  15. package/core/errors.js +156 -0
  16. package/core/evidence.js +141 -0
  17. package/core/geometry.js +217 -0
  18. package/core/identity.js +180 -0
  19. package/core/index.js +24 -0
  20. package/core/p0.js +1101 -0
  21. package/core/readiness.js +175 -0
  22. package/core/registry.js +239 -0
  23. package/core/tab-access.js +1350 -0
  24. package/docs/adapters.md +160 -0
  25. package/docs/architecture.md +146 -0
  26. package/docs/capability-tiers.md +80 -0
  27. package/docs/capture-p0.md +90 -0
  28. package/docs/diagnose-reason-codes.md +157 -0
  29. package/docs/element-picker.md +143 -0
  30. package/docs/getting-started.md +136 -0
  31. package/docs/index.md +36 -0
  32. package/docs/multi-tab.md +162 -0
  33. package/docs/network-p0.md +117 -0
  34. package/docs/panel-context-menu.md +41 -0
  35. package/docs/t2-structured-gui.md +68 -0
  36. package/docs/t3-vision.md +84 -0
  37. package/docs/t4-dynamic-behavior.md +135 -0
  38. package/docs/tools.md +174 -0
  39. package/docs/verification.md +131 -0
  40. package/docs/viewport-control.md +94 -0
  41. package/dynamic-t4.js +5699 -0
  42. package/frame-push.js +508 -0
  43. package/frame-source.js +162 -0
  44. package/icon.png +0 -0
  45. package/index.js +23028 -0
  46. package/integration/cross-environment.js +1535 -0
  47. package/integration/diagnose.js +945 -0
  48. package/integration/tab-principal.js +493 -0
  49. package/locale/en.json +6 -0
  50. package/locale/zh.json +6 -0
  51. package/network-p0.js +4264 -0
  52. package/package.json +19 -4
  53. package/select-control.js +298 -0
  54. package/skills/ui-testing-common/SKILL.md +54 -0
  55. package/skills/ui-testing-fake/SKILL.md +43 -0
  56. package/skills/ui-testing-web/SKILL.md +520 -0
  57. package/skills/ui-testing-web/manifests/declared-capabilities.json +903 -0
  58. package/skills/ui-testing-web/references/contracts/capability.md +68 -0
  59. package/skills/ui-testing-web/references/contracts/evidence.md +41 -0
  60. package/skills/ui-testing-web/references/contracts/operation.md +44 -0
  61. package/skills/ui-testing-web/references/core/dynamic-visual.md +123 -0
  62. package/skills/ui-testing-web/references/core/fallback.md +83 -0
  63. package/skills/ui-testing-web/references/core/network-and-state.md +114 -0
  64. package/skills/ui-testing-web/references/core/page-identity.md +91 -0
  65. package/skills/ui-testing-web/references/core/report-template.md +144 -0
  66. package/skills/ui-testing-web/references/core/reporting.md +92 -0
  67. package/skills/ui-testing-web/references/core/safety.md +12 -0
  68. package/skills/ui-testing-web/references/core/selection.md +26 -0
  69. package/skills/ui-testing-web/references/core/tool-routing-and-limits.md +198 -0
  70. package/skills/ui-testing-web/references/core/verdict-examples.md +96 -0
  71. package/skills/ui-testing-web/references/core/verdict-gate.md +105 -0
  72. package/skills/ui-testing-web/references/core/verification.md +19 -0
  73. package/skills/ui-testing-web/references/core/workflow.md +16 -0
  74. package/skills/ui-testing-web/references/platforms/index.md +19 -0
  75. package/skills/ui-testing-web/references/platforms/web.md +17 -0
  76. package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
  77. package/skills/ui-testing-web/references/scenarios/functional.md +205 -0
  78. package/skills/ui-testing-web/references/scenarios/visual-motion.md +170 -0
  79. package/skills/ui-testing-web/references/tiers/t1-programmatic.md +25 -0
  80. package/skills/ui-testing-web/references/tiers/t2-structured.md +61 -0
  81. package/skills/ui-testing-web/references/tiers/t3-static-visual.md +45 -0
  82. package/skills/ui-testing-web/references/tiers/t4-dynamic-visual.md +58 -0
  83. package/skills/ui-testing-web/templates/capability-note.md +23 -0
  84. package/skills/ui-testing-web/templates/platform.md +30 -0
  85. package/skills/ui-testing-web/templates/regression-case.md +15 -0
  86. package/skills/ui-testing-web/templates/scenario.md +13 -0
  87. package/targets/fake/adapter.js +1163 -0
  88. package/targets/fake/fixtures.js +177 -0
  89. package/targets/web/adapter.js +1231 -0
  90. package/targets/web/provider.js +415 -0
  91. package/vision-baseline.js +1254 -0
  92. package/vision-t3.js +2657 -0
package/capture-p0.js ADDED
@@ -0,0 +1,910 @@
1
+ /**
2
+ * capture-p0.js — P0 采集基础设施的纯逻辑层(零第三方依赖,可单测)。
3
+ *
4
+ * 只回答四类问题,不做采集/不做工具编排:
5
+ * 1. 这段字节是不是一张结构完整、尺寸可信的 PNG? inspectPng / decodePng
6
+ * 2. 这张图是不是异常空白(纯色 / 全透明)? detectImageAnomaly
7
+ * 3. PNG 像素尺寸符合 clip 尺寸 × scale × dsf 的期望吗? expectedPixelSize / checkPixelSize
8
+ * 4. 输出路径是否在工作区内、能否原子且不覆盖地落盘? createCaptureMapping /
9
+ * resolveCapturePath / publishCapture
10
+ *
11
+ * 设计约束(与 docs/capture-p0.md 一致):
12
+ * - 只依赖 node:zlib / node:fs / node:path / node:crypto;同步 fs;无全局可变状态。
13
+ * - 失败一律返回结构化对象 `{ok:false, code, message, details}`,不向调用方抛裸异常
14
+ * (expectedPixelSize 是纯算术 helper,参数非法时抛 TypeError 由调用方先行校验)。
15
+ * - 返回对象里的 details 只放相对路径/数值,不放绝对路径,避免进工具证据时泄露工作区绝对路径。
16
+ * - 不含任何像素/页面原文日志。
17
+ *
18
+ * 采集语义(Lead 在本机 Chrome 152 + Linux headless 实测):
19
+ * - `Page.captureScreenshot` 的 `clip` 是文档 CSS 坐标;输出像素 = round(css * scale * dsf)。
20
+ * - `clip.scale` 是重新光栅化,不是位图放大;本插件固定 dsf=1,只用 clip.scale。
21
+ * - 因此 CAPTURE_UPSCALED 只证明“输出尺寸大于期望尺寸”,不单独证明发生了位图放大。
22
+ * - scale 允许 0.1–4(Lead 冻结,见 docs/viewport-control.md);实测 clip.scale=0.1/0.5/
23
+ * 0.65/1.11 时 PNG 尺寸都精确等于 round(css×scale)(取整发生在 clip 的 CSS 尺寸上,不是
24
+ * scale 上),所以 0 容差公式不变,只需额外拒绝两轴 <1 的空输出(hasPositiveOutputSize)。
25
+ */
26
+
27
+ import { randomUUID } from 'node:crypto';
28
+ import {
29
+ closeSync, fsyncSync, linkSync, mkdirSync, openSync, realpathSync, statSync,
30
+ unlinkSync, writeSync,
31
+ } from 'node:fs';
32
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
33
+ import { deflateSync, inflateSync } from 'node:zlib';
34
+
35
+ /** 8 字节 PNG 文件签名。 */
36
+ export const PNG_SIGNATURE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
37
+
38
+ /** 结构遍历上限:坏文件不许让循环跑太久。 */
39
+ const MAX_CHUNKS = 100000;
40
+
41
+ /** PNG 允许声明的像素上限(防御异常尺寸)。 */
42
+ const MAX_PIXELS = 200000000;
43
+
44
+ /** 空白识别最多实际读取的像素数(更大图按步长抽样,避免解码后全量扫描)。 */
45
+ const MAX_ANOMALY_SAMPLES = 65536;
46
+
47
+ /** distinctColors 统计上限:超过即按 1024 记(“≥1024”)。 */
48
+ const MAX_DISTINCT_COLORS = 1024;
49
+
50
+ /** P0 采集的公共限额(index.js / browser_capabilities 复用,避免魔法数字漂移)。 */
51
+ export const CAPTURE_LIMITS = Object.freeze({
52
+ /**
53
+ * 显式 scale 允许范围(闭区间)。
54
+ *
55
+ * 2026-11-01(docs/viewport-control.md,Lead 冻结):下界从 1 扩到 0.1,因为共享
56
+ * `view.scale` 可以是 10%(0.1)而默认截图比例跟随它。上界仍是 4。默认值**不是** scaleMin:
57
+ * 省略 scale 时由 Host 在调用内解析为当前 `view.scale`(cold 时为 1)。
58
+ */
59
+ scaleMin: 0.1,
60
+ scaleMax: 4,
61
+ /** 输出像素的下界:任一轴 < 1 时 Chrome 会取整成 0 像素,必须在 CDP 截图前拒绝。 */
62
+ minOutputPixels: 1,
63
+ /** 单次截图期望像素上限(宽×高);超过即 CAPTURE_TOO_LARGE,必须在 CDP 截图前判定。 */
64
+ maxOutputPixels: 16000000,
65
+ /** PNG 结构/解码预算之外的硬上限,避免异常 IHDR 拖垮解码。 */
66
+ maxPngPixels: MAX_PIXELS,
67
+ });
68
+
69
+ /** PNG color type -> 通道数(仅 8-bit 非交错支持)。 */
70
+ const COLOR_TYPE_CHANNELS = Object.freeze({ 0: 1, 2: 3, 4: 2, 6: 4 });
71
+
72
+ let crcTable = null;
73
+
74
+ function getCrcTable() {
75
+ if (crcTable) return crcTable;
76
+ const table = new Int32Array(256);
77
+ for (let n = 0; n < 256; n += 1) {
78
+ let c = n;
79
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
80
+ table[n] = c;
81
+ }
82
+ crcTable = table;
83
+ return table;
84
+ }
85
+
86
+ /** Buffer / Uint8Array / ArrayBuffer 统一成 Buffer(不拷贝已有 Buffer);其它类型返回 null。 */
87
+ export function asBuffer(bytes) {
88
+ if (Buffer.isBuffer(bytes)) return bytes;
89
+ if (bytes instanceof Uint8Array) return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
90
+ if (bytes instanceof ArrayBuffer) return Buffer.from(bytes);
91
+ return null;
92
+ }
93
+
94
+ /** CRC32(PNG 规范定义)。 */
95
+ export function crc32(bytes) {
96
+ const buf = asBuffer(bytes) || Buffer.alloc(0);
97
+ const table = getCrcTable();
98
+ let c = 0xffffffff;
99
+ for (let i = 0; i < buf.length; i += 1) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
100
+ return (c ^ 0xffffffff) >>> 0;
101
+ }
102
+
103
+ function invalidPng(message) {
104
+ return { ok: false, code: 'INVALID_PNG', message };
105
+ }
106
+
107
+ function unsupportedFormat(message) {
108
+ return { ok: false, code: 'PNG_UNSUPPORTED_FORMAT', message };
109
+ }
110
+
111
+ /**
112
+ * PNG 结构校验:签名、块边界、IHDR(13)、IEND、截断、IEND 后尾部字节、CRC32。
113
+ *
114
+ * @param {Uint8Array|Buffer} bytes
115
+ * @param {{ checkCrc?: boolean }} [options]
116
+ * @returns {{ok:true,width:number,height:number,bytes:number,chunks:number}
117
+ * |{ok:false,code:'INVALID_PNG',message:string}}
118
+ */
119
+ export function inspectPng(bytes, { checkCrc = true } = {}) {
120
+ const buf = asBuffer(bytes);
121
+ if (!buf || buf.length === 0) return invalidPng('PNG bytes are empty');
122
+ if (buf.length < 8) return invalidPng('PNG bytes are shorter than the 8-byte signature');
123
+ if (!buf.subarray(0, 8).equals(Buffer.from(PNG_SIGNATURE))) {
124
+ return invalidPng('PNG signature mismatch (89 50 4E 47 0D 0A 1A 0A expected)');
125
+ }
126
+
127
+ let offset = 8;
128
+ let chunks = 0;
129
+ let ihdr = null;
130
+ let sawIend = false;
131
+
132
+ while (offset < buf.length) {
133
+ if (chunks >= MAX_CHUNKS) return invalidPng('PNG has an implausible number of chunks');
134
+ if (offset + 8 > buf.length) return invalidPng('PNG is truncated inside a chunk header');
135
+
136
+ const length = buf.readUInt32BE(offset);
137
+ const type = buf.toString('latin1', offset + 4, offset + 8);
138
+ const dataStart = offset + 8;
139
+ const dataEnd = dataStart + length;
140
+ const crcEnd = dataEnd + 4;
141
+
142
+ if (!/^[A-Za-z]{4}$/.test(type)) return invalidPng(`PNG chunk type is invalid at offset ${offset}`);
143
+ if (dataEnd > buf.length || crcEnd > buf.length) return invalidPng(`PNG is truncated inside chunk ${type}`);
144
+
145
+ if (chunks === 0) {
146
+ if (type !== 'IHDR' || length !== 13) return invalidPng('PNG first chunk is not a 13-byte IHDR');
147
+ const width = buf.readUInt32BE(dataStart);
148
+ const height = buf.readUInt32BE(dataStart + 4);
149
+ if (!(width > 0) || !(height > 0)) return invalidPng('PNG IHDR declares a non-positive size');
150
+ if (width * height > MAX_PIXELS) {
151
+ return invalidPng(`PNG IHDR declares an implausible size ${width}x${height}`);
152
+ }
153
+ ihdr = { width, height };
154
+ }
155
+
156
+ if (checkCrc) {
157
+ const expected = buf.readUInt32BE(dataEnd);
158
+ const actual = crc32(buf.subarray(offset + 4, dataEnd));
159
+ if (expected !== actual) return invalidPng(`PNG chunk ${type} failed its CRC check`);
160
+ }
161
+
162
+ offset = crcEnd;
163
+ chunks += 1;
164
+
165
+ if (type === 'IEND') {
166
+ if (length !== 0) return invalidPng('PNG IEND chunk is not empty');
167
+ sawIend = true;
168
+ break;
169
+ }
170
+ }
171
+
172
+ if (!sawIend) return invalidPng('PNG has no IEND chunk (truncated or corrupt)');
173
+ if (offset !== buf.length) return invalidPng(`PNG has ${buf.length - offset} trailing bytes after IEND`);
174
+ if (!ihdr) return invalidPng('PNG has no IHDR');
175
+
176
+ return { ok: true, width: ihdr.width, height: ihdr.height, bytes: buf.length, chunks };
177
+ }
178
+
179
+ function paethPredictor(a, b, c) {
180
+ const p = a + b - c;
181
+ const pa = Math.abs(p - a);
182
+ const pb = Math.abs(p - b);
183
+ const pc = Math.abs(p - c);
184
+ if (pa <= pb && pa <= pc) return a;
185
+ if (pb <= pc) return b;
186
+ return c;
187
+ }
188
+
189
+ /**
190
+ * 解码 8-bit、color type 0/2/4/6、filter 0–4、非交错 PNG,返回逐行原始通道字节。
191
+ *
192
+ * palette(3) / 16-bit / 交错一律 PNG_UNSUPPORTED_FORMAT —— 不猜像素。
193
+ * 结构坏(签名/截断/CRC/IDAT 解压/行长度不符)一律 INVALID_PNG。
194
+ *
195
+ * @param {Uint8Array|Buffer} bytes
196
+ * @returns {{ok:true,width:number,height:number,channels:number,colorType:number,data:Buffer}
197
+ * |{ok:false,code:'INVALID_PNG'|'PNG_UNSUPPORTED_FORMAT',message:string}}
198
+ */
199
+ export function decodePng(bytes) {
200
+ const inspected = inspectPng(bytes);
201
+ if (!inspected.ok) return inspected;
202
+ const buf = asBuffer(bytes);
203
+ const bitDepth = buf[24];
204
+ const colorType = buf[25];
205
+ const interlace = buf[28];
206
+ if (bitDepth !== 8) return unsupportedFormat(`PNG bit depth ${bitDepth} is not supported (only 8-bit)`);
207
+ const channels = COLOR_TYPE_CHANNELS[colorType];
208
+ if (!channels) return unsupportedFormat(`PNG color type ${colorType} is not supported (0/2/4/6 only)`);
209
+ if (interlace !== 0) return unsupportedFormat('interlaced PNG is not supported');
210
+
211
+ const { width, height } = inspected;
212
+ const stride = width * channels;
213
+ const expectedLength = (stride + 1) * height;
214
+
215
+ // 收集全部 IDAT 数据(Chrome 会输出多块 IDAT)。
216
+ const parts = [];
217
+ let offset = 8;
218
+ while (offset < buf.length) {
219
+ const length = buf.readUInt32BE(offset);
220
+ const type = buf.toString('latin1', offset + 4, offset + 8);
221
+ if (type === 'IDAT') parts.push(buf.subarray(offset + 8, offset + 8 + length));
222
+ offset += 8 + length + 4;
223
+ if (type === 'IEND') break;
224
+ }
225
+ if (!parts.length) return invalidPng('PNG has no IDAT chunk');
226
+
227
+ let raw;
228
+ try {
229
+ raw = inflateSync(Buffer.concat(parts));
230
+ } catch (error) {
231
+ return invalidPng(`PNG IDAT cannot be inflated (${error && error.message ? error.message : 'zlib error'})`);
232
+ }
233
+ if (raw.length !== expectedLength) {
234
+ return invalidPng(`PNG scanline data is ${raw.length} bytes, expected ${expectedLength}`);
235
+ }
236
+
237
+ const data = Buffer.alloc(stride * height);
238
+ for (let y = 0; y < height; y += 1) {
239
+ const filter = raw[y * (stride + 1)];
240
+ if (filter > 4) return invalidPng(`PNG row ${y} uses unsupported filter type ${filter}`);
241
+ const inStart = y * (stride + 1) + 1;
242
+ const outStart = y * stride;
243
+ const upStart = outStart - stride;
244
+ for (let i = 0; i < stride; i += 1) {
245
+ const x = raw[inStart + i];
246
+ const left = i >= channels ? data[outStart + i - channels] : 0;
247
+ const up = y > 0 ? data[upStart + i] : 0;
248
+ const upLeft = y > 0 && i >= channels ? data[upStart + i - channels] : 0;
249
+ let value;
250
+ switch (filter) {
251
+ case 0: value = x; break;
252
+ case 1: value = x + left; break;
253
+ case 2: value = x + up; break;
254
+ case 3: value = x + Math.floor((left + up) / 2); break;
255
+ default: value = x + paethPredictor(left, up, upLeft); break;
256
+ }
257
+ data[outStart + i] = value & 0xff;
258
+ }
259
+ }
260
+
261
+ return { ok: true, width, height, channels, colorType, data };
262
+ }
263
+
264
+ /**
265
+ * 编码一张 8-bit RGBA、非交错、filter 0 的 PNG(合成截图用;纯逻辑、无第三方依赖)。
266
+ *
267
+ * 只接受逐像素 RGBA(channels=4)缓冲。输出是可被 inspectPng / decodePng 重新读回的合法 PNG;
268
+ * 合成路径必然重编码,调用方必须在证据里披露 `composite:true`(docs/viewport-control.md)。
269
+ *
270
+ * @param {{width:number,height:number,rgba:Uint8Array|Buffer}} image
271
+ * @param {{level?:number}} [options]
272
+ * @returns {Buffer}
273
+ */
274
+ export function encodePngRgba({ width, height, rgba } = {}, { level = 6 } = {}) {
275
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
276
+ throw new TypeError('encodePngRgba: width/height must be positive integers');
277
+ }
278
+ if (width * height > CAPTURE_LIMITS.maxPngPixels) {
279
+ throw new TypeError(`encodePngRgba: ${width}x${height} exceeds the PNG pixel budget`);
280
+ }
281
+ const pixels = asBuffer(rgba);
282
+ const stride = width * 4;
283
+ if (!pixels || pixels.length !== stride * height) {
284
+ throw new TypeError('encodePngRgba: rgba buffer length does not match width*height*4');
285
+ }
286
+ const raw = Buffer.alloc((stride + 1) * height);
287
+ for (let y = 0; y < height; y += 1) {
288
+ raw[y * (stride + 1)] = 0; // filter type 0 (None) keeps composition deterministic
289
+ pixels.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
290
+ }
291
+ const idat = deflateSync(raw, { level: Number.isInteger(level) && level >= 0 && level <= 9 ? level : 6 });
292
+
293
+ const chunk = (type, data) => {
294
+ const header = Buffer.alloc(8);
295
+ header.writeUInt32BE(data.length, 0);
296
+ header.write(type, 4, 'latin1');
297
+ const crcInput = Buffer.concat([Buffer.from(type, 'latin1'), data]);
298
+ const crc = Buffer.alloc(4);
299
+ crc.writeUInt32BE(crc32(crcInput), 0);
300
+ return Buffer.concat([header, data, crc]);
301
+ };
302
+ const ihdr = Buffer.alloc(13);
303
+ ihdr.writeUInt32BE(width, 0);
304
+ ihdr.writeUInt32BE(height, 4);
305
+ ihdr[8] = 8; // bit depth
306
+ ihdr[9] = 6; // colour type RGBA
307
+ ihdr[10] = 0; // compression
308
+ ihdr[11] = 0; // filter
309
+ ihdr[12] = 0; // interlace
310
+ return Buffer.concat([
311
+ Buffer.from(PNG_SIGNATURE),
312
+ chunk('IHDR', ihdr),
313
+ chunk('IDAT', idat),
314
+ chunk('IEND', Buffer.alloc(0)),
315
+ ]);
316
+ }
317
+
318
+ /**
319
+ * 两个 CSS 矩形的交集(右下开区间);无交集时 width/height 为 0,绝不返回负尺寸。
320
+ *
321
+ * @param {{x:number,y:number,width:number,height:number}} left
322
+ * @param {{x:number,y:number,width:number,height:number}} right
323
+ * @returns {{x:number,y:number,width:number,height:number}}
324
+ */
325
+ export function intersectRects(left, right) {
326
+ const a = left || { x: 0, y: 0, width: 0, height: 0 };
327
+ const b = right || { x: 0, y: 0, width: 0, height: 0 };
328
+ const x = Math.max(Number(a.x) || 0, Number(b.x) || 0);
329
+ const y = Math.max(Number(a.y) || 0, Number(b.y) || 0);
330
+ const rightEdge = Math.min((Number(a.x) || 0) + Math.max(0, Number(a.width) || 0), (Number(b.x) || 0) + Math.max(0, Number(b.width) || 0));
331
+ const bottomEdge = Math.min((Number(a.y) || 0) + Math.max(0, Number(a.height) || 0), (Number(b.y) || 0) + Math.max(0, Number(b.height) || 0));
332
+ return { x, y, width: Math.max(0, rightEdge - x), height: Math.max(0, bottomEdge - y) };
333
+ }
334
+
335
+ /**
336
+ * Canvas 合成(docs/viewport-control.md):白底 RGBA 输出矩形,把页面截图按 canvas CSS 位置贴入。
337
+ *
338
+ * 坐标空间是 **canvas CSS px**(canvas 坐标 == document 坐标:content 在 (0,0))。`pageRect` 是
339
+ * 实际截取的页面区域(canvas 与内容的交集),`outputRect` 是要交付的 canvas 取景框;二者都用
340
+ * CSS px,`scale` 是重新光栅化倍数。输出尺寸严格为 round(outputRect × scale)(与 P0 尺寸公式一致)。
341
+ *
342
+ * 采样用目标像素中心反推 canvas 坐标(最近邻)。整数几何时是逐像素拷贝;页面截图的真实像素
343
+ * 尺寸即使与名义尺寸相差 1px(Chromium 会对小数 clip 取整)也能正确落位,不产生拉伸空白。
344
+ *
345
+ * @param {{page?:{ok:true,width:number,height:number,channels:number,data:Buffer}|null,
346
+ * pageRect?:{x:number,y:number,width:number,height:number}|null,
347
+ * outputRect:{x:number,y:number,width:number,height:number},
348
+ * scale:number,
349
+ * blankColor?:[number,number,number,number]}} input
350
+ * @returns {{ok:true,width:number,height:number,rgba:Buffer}
351
+ * |{ok:false,code:'CAPTURE_FAILED',message:string,details:object}}
352
+ */
353
+ export function composeCanvasImage({ page = null, pageRect = null, outputRect, scale, blankColor = [255, 255, 255, 255] } = {}) {
354
+ const rect = outputRect || {};
355
+ const renderScale = Number(scale);
356
+ if (![rect.x, rect.y, rect.width, rect.height].every((value) => typeof value === 'number' && Number.isFinite(value))
357
+ || rect.width <= 0 || rect.height <= 0) {
358
+ return { ok: false, code: 'CAPTURE_FAILED', message: 'composeCanvasImage: outputRect must be a finite positive rectangle', details: { reason: 'output-rect-invalid' } };
359
+ }
360
+ if (!Number.isFinite(renderScale) || renderScale <= 0) {
361
+ return { ok: false, code: 'CAPTURE_FAILED', message: 'composeCanvasImage: scale must be a positive finite number', details: { reason: 'scale-invalid' } };
362
+ }
363
+ const width = Math.round(rect.width * renderScale);
364
+ const height = Math.round(rect.height * renderScale);
365
+ if (!hasPositiveOutputSize({ width, height })) {
366
+ return { ok: false, code: 'CAPTURE_FAILED', message: 'composeCanvasImage: output rounds to 0 pixels', details: { reason: 'empty-output-dimensions', width, height } };
367
+ }
368
+ if (width * height > CAPTURE_LIMITS.maxOutputPixels) {
369
+ return { ok: false, code: 'CAPTURE_FAILED', message: 'composeCanvasImage: output exceeds the pixel budget', details: { reason: 'too-large', width, height } };
370
+ }
371
+ const rgba = Buffer.alloc(width * height * 4);
372
+ const blank = Array.isArray(blankColor) && blankColor.length === 4 ? blankColor : [255, 255, 255, 255];
373
+ for (let i = 0; i < rgba.length; i += 4) {
374
+ rgba[i] = blank[0]; rgba[i + 1] = blank[1]; rgba[i + 2] = blank[2]; rgba[i + 3] = blank[3];
375
+ }
376
+ if (!page || page.ok !== true || !pageRect) return { ok: true, width, height, rgba };
377
+ const pageWidth = Number(page.width);
378
+ const pageHeight = Number(page.height);
379
+ if (!Number.isInteger(pageWidth) || !Number.isInteger(pageHeight) || pageWidth <= 0 || pageHeight <= 0
380
+ || !Buffer.isBuffer(page.data) || !(pageRect.width > 0) || !(pageRect.height > 0)) {
381
+ return { ok: true, width, height, rgba };
382
+ }
383
+ const channels = Number(page.channels);
384
+ if (![1, 2, 3, 4].includes(channels)) {
385
+ return { ok: false, code: 'CAPTURE_FAILED', message: 'composeCanvasImage: unsupported page channel count', details: { reason: 'page-channels', channels } };
386
+ }
387
+ // Destination paste rectangle in output pixels (right/bottom are exclusive).
388
+ const left = Math.max(0, Math.min(width, Math.round((pageRect.x - rect.x) * renderScale)));
389
+ const top = Math.max(0, Math.min(height, Math.round((pageRect.y - rect.y) * renderScale)));
390
+ const right = Math.max(left, Math.min(width, Math.round((pageRect.x + pageRect.width - rect.x) * renderScale)));
391
+ const bottom = Math.max(top, Math.min(height, Math.round((pageRect.y + pageRect.height - rect.y) * renderScale)));
392
+ const sourcePerDestX = pageRect.width / pageWidth;
393
+ const sourcePerDestY = pageRect.height / pageHeight;
394
+ for (let dy = top; dy < bottom; dy += 1) {
395
+ // canvas CSS centre of this destination pixel → source pixel (nearest neighbour)
396
+ const canvasY = rect.y + (dy + 0.5) / renderScale;
397
+ let sy = Math.floor((canvasY - pageRect.y) / sourcePerDestY);
398
+ if (sy < 0) sy = 0; else if (sy >= pageHeight) sy = pageHeight - 1;
399
+ for (let dx = left; dx < right; dx += 1) {
400
+ const canvasX = rect.x + (dx + 0.5) / renderScale;
401
+ let sx = Math.floor((canvasX - pageRect.x) / sourcePerDestX);
402
+ if (sx < 0) sx = 0; else if (sx >= pageWidth) sx = pageWidth - 1;
403
+ const source = (sy * pageWidth + sx) * channels;
404
+ const target = (dy * width + dx) * 4;
405
+ if (channels === 1) {
406
+ rgba[target] = page.data[source]; rgba[target + 1] = page.data[source]; rgba[target + 2] = page.data[source]; rgba[target + 3] = 255;
407
+ } else if (channels === 2) {
408
+ rgba[target] = page.data[source]; rgba[target + 1] = page.data[source]; rgba[target + 2] = page.data[source]; rgba[target + 3] = page.data[source + 1];
409
+ } else if (channels === 3) {
410
+ rgba[target] = page.data[source]; rgba[target + 1] = page.data[source + 1]; rgba[target + 2] = page.data[source + 2]; rgba[target + 3] = 255;
411
+ } else {
412
+ rgba[target] = page.data[source]; rgba[target + 1] = page.data[source + 1]; rgba[target + 2] = page.data[source + 2]; rgba[target + 3] = page.data[source + 3];
413
+ }
414
+ }
415
+ }
416
+ return { ok: true, width, height, rgba };
417
+ }
418
+
419
+ /**
420
+ * 异常空白图识别。
421
+ *
422
+ * uniform = 所有被扫描的像素,各通道相对“首个像素”的差都 <= tolerance。
423
+ * transparent = 有 alpha 通道(GA/RGBA)且全部被扫描像素 alpha 为 0。
424
+ *
425
+ * **默认全量扫描**:像素数 <= CAPTURE_LIMITS.maxOutputPixels(截图输出上限,实际总满足)时逐像素扫描,
426
+ * 不做抽样 —— “大面积纯色 + 少量内容”的页面(白底 + 一行 13px 文字)因此不会误报 uniform。
427
+ * 只有超过该上限的图才按步长抽样,并在 `sampled` 显式标记(`sampledPixels` 是实际扫描数);
428
+ * 截图路径永远走全量分支。
429
+ *
430
+ * 纯色页面是合法结果,因此这里只出事实,由调用方决定是否告警(P0 只告警不报错)。
431
+ *
432
+ * @param {{ok:true,width:number,height:number,channels:number,data:Buffer}} decoded
433
+ * @param {{tolerance?:number}} [options]
434
+ * @returns {{ok:true,sampled:boolean,sampledPixels:number,uniform:boolean,transparent:boolean,
435
+ * maxDelta:number,distinctColors:number,averageColor:{r:number,g:number,b:number,a:number}}
436
+ * |{ok:false,code:'INVALID_PNG',message:string}}
437
+ */
438
+ export function detectImageAnomaly(decoded, { tolerance = 0 } = {}) {
439
+ if (!decoded || decoded.ok !== true) {
440
+ return invalidPng('a successfully decoded image is required');
441
+ }
442
+ const { width, height, channels, data } = decoded;
443
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0
444
+ || !Number.isInteger(channels) || channels < 1 || !Buffer.isBuffer(data)) {
445
+ return invalidPng('decoded image metadata is inconsistent');
446
+ }
447
+ const total = width * height;
448
+ if (data.length < total * channels) return invalidPng('decoded pixel buffer is shorter than its dimensions');
449
+ const tol = Number.isFinite(tolerance) && tolerance > 0 ? tolerance : 0;
450
+ const hasAlpha = channels === 2 || channels === 4;
451
+ const alphaOffset = channels - 1;
452
+
453
+ const fullScan = total <= CAPTURE_LIMITS.maxOutputPixels;
454
+ const step = fullScan ? 1 : Math.ceil(total / MAX_ANOMALY_SAMPLES);
455
+ const isSampled = step > 1;
456
+ const first = 0;
457
+ const firstBase = first * channels;
458
+ const base = [data[firstBase], data[firstBase + 1], data[firstBase + 2], data[firstBase + 3]];
459
+
460
+ let scanned = 0;
461
+ let maxDelta = 0;
462
+ let transparent = hasAlpha;
463
+ let sumR = 0; let sumG = 0; let sumB = 0; let sumA = 0;
464
+ const distinct = new Set();
465
+
466
+ for (let pixel = 0; pixel < total; pixel += step) {
467
+ const base0 = pixel * channels;
468
+ let r; let g; let b; let a;
469
+ if (channels === 1) { r = data[base0]; g = r; b = r; a = 255; }
470
+ else if (channels === 2) { r = data[base0]; g = r; b = r; a = data[base0 + 1]; }
471
+ else if (channels === 3) { r = data[base0]; g = data[base0 + 1]; b = data[base0 + 2]; a = 255; }
472
+ else { r = data[base0]; g = data[base0 + 1]; b = data[base0 + 2]; a = data[base0 + 3]; }
473
+
474
+ const deltas = channels === 1
475
+ ? [Math.abs(data[base0] - base[0])]
476
+ : channels === 2
477
+ ? [Math.abs(data[base0] - base[0]), Math.abs(data[base0 + 1] - base[1])]
478
+ : channels === 3
479
+ ? [Math.abs(data[base0] - base[0]), Math.abs(data[base0 + 1] - base[1]), Math.abs(data[base0 + 2] - base[2])]
480
+ : [Math.abs(data[base0] - base[0]), Math.abs(data[base0 + 1] - base[1]),
481
+ Math.abs(data[base0 + 2] - base[2]), Math.abs(data[base0 + 3] - base[3])];
482
+ for (const delta of deltas) if (delta > maxDelta) maxDelta = delta;
483
+
484
+ if (hasAlpha && data[base0 + alphaOffset] !== 0) transparent = false;
485
+
486
+ sumR += r; sumG += g; sumB += b; sumA += a;
487
+ scanned += 1;
488
+ if (distinct.size < MAX_DISTINCT_COLORS) {
489
+ distinct.add((r << 24) | (g << 16) | (b << 8) | a);
490
+ }
491
+ }
492
+
493
+ return {
494
+ ok: true,
495
+ sampled: isSampled,
496
+ sampledPixels: scanned,
497
+ uniform: maxDelta <= tol,
498
+ transparent,
499
+ maxDelta,
500
+ distinctColors: distinct.size,
501
+ averageColor: {
502
+ r: Math.round(sumR / scanned),
503
+ g: Math.round(sumG / scanned),
504
+ b: Math.round(sumB / scanned),
505
+ a: Math.round(sumA / scanned),
506
+ },
507
+ };
508
+ }
509
+
510
+ /**
511
+ * 输出尺寸下界:两轴都必须 ≥ CAPTURE_LIMITS.minOutputPixels(1)。
512
+ *
513
+ * `round(css × scale)` 在 scale<1 且矩形很小时可能舍入到 0(例如 1 CSS px @0.1 = 0.1 → 0)。
514
+ * Chrome 对这种 clip 不会给出可用的 PNG,所以调用方必须在派发 CDP 截图**之前**用本函数拒绝
515
+ * (`INVALID_ARGUMENT` / reason=`empty-output-dimensions`),不能偷偷升到 1px 再截。
516
+ *
517
+ * @param {{width:number,height:number}} expected
518
+ * @returns {boolean}
519
+ */
520
+ export function hasPositiveOutputSize(expected) {
521
+ if (!expected || typeof expected !== 'object') return false;
522
+ return Number.isFinite(expected.width) && Number.isFinite(expected.height)
523
+ && expected.width >= CAPTURE_LIMITS.minOutputPixels
524
+ && expected.height >= CAPTURE_LIMITS.minOutputPixels;
525
+ }
526
+
527
+ /**
528
+ * 期望输出像素尺寸:width = round(cssWidth * scale * deviceScaleFactor)。
529
+ * 纯算术 helper:参数非法时抛 TypeError(调用方负责先用工具层校验)。
530
+ *
531
+ * @param {{width:number,height:number,scale?:number,deviceScaleFactor?:number}} input
532
+ * @returns {{width:number,height:number,pixelsPerCssPixel:number}}
533
+ */
534
+ export function expectedPixelSize({ width, height, scale = 1, deviceScaleFactor = 1 } = {}) {
535
+ for (const [name, value] of [['width', width], ['height', height], ['scale', scale], ['deviceScaleFactor', deviceScaleFactor]]) {
536
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
537
+ throw new TypeError(`expectedPixelSize: ${name} must be a positive finite number`);
538
+ }
539
+ }
540
+ const pixelsPerCssPixel = scale * deviceScaleFactor;
541
+ return {
542
+ width: Math.round(width * pixelsPerCssPixel),
543
+ height: Math.round(height * pixelsPerCssPixel),
544
+ pixelsPerCssPixel,
545
+ };
546
+ }
547
+
548
+ /**
549
+ * 尺寸校验:实际 PNG 像素 vs 期望像素。
550
+ *
551
+ * - 任一维 > expected + tolerance 且没有一维 < expected - tolerance -> CAPTURE_UPSCALED
552
+ * - 任一维 < expected - tolerance -> CAPTURE_SIZE_MISMATCH(两向都不符时 reason=mixed-dimensions)
553
+ * 注意:CAPTURE_UPSCALED 只证明“尺寸超标”,不单独证明发生了位图放大。
554
+ *
555
+ * @param {{width:number,height:number}} actual
556
+ * @param {{width:number,height:number}} expected
557
+ * @param {{tolerance?:number}} [options]
558
+ * @returns {{ok:true,actual:{width:number,height:number},expected:{width:number,height:number}}
559
+ * |{ok:false,code:'CAPTURE_UPSCALED'|'CAPTURE_SIZE_MISMATCH',details:object}}
560
+ */
561
+ export function checkPixelSize(actual, expected, { tolerance = 0 } = {}) {
562
+ const tol = Number.isFinite(tolerance) && tolerance > 0 ? tolerance : 0;
563
+ const a = { width: Number(actual && actual.width), height: Number(actual && actual.height) };
564
+ const e = { width: Number(expected && expected.width), height: Number(expected && expected.height) };
565
+ const oversized = a.width > e.width + tol || a.height > e.height + tol;
566
+ const undersized = a.width < e.width - tol || a.height < e.height - tol;
567
+
568
+ if (!oversized && !undersized) {
569
+ return { ok: true, actual: { width: a.width, height: a.height }, expected: { width: e.width, height: e.height } };
570
+ }
571
+ const details = {
572
+ actual: { width: a.width, height: a.height },
573
+ expected: { width: e.width, height: e.height },
574
+ tolerance: tol,
575
+ reason: oversized && undersized ? 'mixed-dimensions' : oversized ? 'upscaled' : 'undersized',
576
+ };
577
+ return { ok: false, code: oversized && !undersized ? 'CAPTURE_UPSCALED' : 'CAPTURE_SIZE_MISMATCH', details };
578
+ }
579
+
580
+ /**
581
+ * Canvas / 文档 CSS 坐标 -> 图像像素的映射(含可逆函数;进工具结果前必须用 projectCaptureMapping 投影)。
582
+ *
583
+ * `coordinateSpace` 默认 `document-css-px`(旧行为);画布合成截图传 `canvas-css-px`
584
+ * (docs/viewport-control.md)。`canvas`/`contentRect`/`blank` 是画布证据的可选附加字段,
585
+ * 不影响旧调用方。
586
+ *
587
+ * @param {{region:{x:number,y:number,width:number,height:number},
588
+ * scroll?:{x:number,y:number}, viewport?:{width:number,height:number},
589
+ * scale?:number, deviceScaleFactor?:number, coordinateSpace?:string,
590
+ * canvas?:object|null, contentRect?:object|null, blank?:string|null}} input
591
+ */
592
+ export function createCaptureMapping({
593
+ region, scroll = { x: 0, y: 0 }, viewport, scale = 1, deviceScaleFactor = 1,
594
+ coordinateSpace = 'document-css-px', canvas = null, contentRect = null, blank = null,
595
+ } = {}) {
596
+ if (!region || !Number.isFinite(region.width) || !Number.isFinite(region.height)) {
597
+ throw new TypeError('createCaptureMapping: region with finite width/height is required');
598
+ }
599
+ const cssRect = {
600
+ x: Number(region.x) || 0,
601
+ y: Number(region.y) || 0,
602
+ width: Number(region.width),
603
+ height: Number(region.height),
604
+ };
605
+ const imagePixels = expectedPixelSize({
606
+ width: cssRect.width,
607
+ height: cssRect.height,
608
+ scale,
609
+ deviceScaleFactor,
610
+ });
611
+ const originCss = { x: cssRect.x, y: cssRect.y };
612
+ const ppcp = imagePixels.pixelsPerCssPixel;
613
+ const mapping = {
614
+ coordinateSpace: coordinateSpace === 'canvas-css-px' ? 'canvas-css-px' : 'document-css-px',
615
+ originCss,
616
+ imagePixels: { width: imagePixels.width, height: imagePixels.height },
617
+ pixelsPerCssPixel: ppcp,
618
+ cssRect,
619
+ scroll: { x: Number(scroll.x) || 0, y: Number(scroll.y) || 0 },
620
+ viewport: viewport
621
+ ? { width: Number(viewport.width) || 0, height: Number(viewport.height) || 0 }
622
+ : null,
623
+ cssToPixel({ x, y }) {
624
+ return {
625
+ x: Math.round((Number(x) - originCss.x) * ppcp),
626
+ y: Math.round((Number(y) - originCss.y) * ppcp),
627
+ };
628
+ },
629
+ pixelToCss({ x, y }) {
630
+ return {
631
+ x: originCss.x + Number(x) / ppcp,
632
+ y: originCss.y + Number(y) / ppcp,
633
+ };
634
+ },
635
+ };
636
+ if (canvas && typeof canvas === 'object') mapping.canvas = { ...canvas };
637
+ if (contentRect && typeof contentRect === 'object') mapping.contentRect = { ...contentRect };
638
+ if (typeof blank === 'string') mapping.blank = blank;
639
+ return mapping;
640
+ }
641
+
642
+ /** Pure JSON copy of a finite-number rectangle (used by the canvas evidence fields). */
643
+ function finiteRect(rect) {
644
+ if (!rect || typeof rect !== 'object') return null;
645
+ const out = {};
646
+ for (const key of ['x', 'y', 'width', 'height']) {
647
+ if (Number.isFinite(rect[key])) out[key] = rect[key];
648
+ }
649
+ return out;
650
+ }
651
+
652
+ /** Pure JSON copy of the canvas evidence block (numbers/strings only). */
653
+ function finiteCanvas(canvas) {
654
+ if (!canvas || typeof canvas !== 'object') return null;
655
+ const out = {};
656
+ for (const key of ['version', 'x', 'y', 'scrollX', 'scrollY', 'offsetX', 'offsetY', 'contentWidth', 'contentHeight']) {
657
+ if (Number.isFinite(canvas[key])) out[key] = canvas[key];
658
+ }
659
+ if (typeof canvas.space === 'string') out.space = canvas.space;
660
+ if (typeof canvas.blank === 'string') out.blank = canvas.blank;
661
+ if (typeof canvas.source === 'string') out.source = canvas.source;
662
+ if (typeof canvas.composite === 'boolean') out.composite = canvas.composite;
663
+ return out;
664
+ }
665
+
666
+ /**
667
+ * 把 createCaptureMapping 的结果投影成纯 JSON(工具返回值不含函数)。
668
+ *
669
+ * @param {ReturnType<typeof createCaptureMapping>} mapping
670
+ * @returns {{coordinateSpace:string,originCss:object,imagePixels:object,pixelsPerCssPixel:number,
671
+ * cssRect:object,canvas?:object,contentRect?:object,blank?:string,
672
+ * formulas:{cssToPixel:string,pixelToCss:string}}}
673
+ */
674
+ export function projectCaptureMapping(mapping) {
675
+ const source = mapping || {};
676
+ const origin = source.originCss || { x: 0, y: 0 };
677
+ const pixels = source.imagePixels || { width: 0, height: 0 };
678
+ const rect = source.cssRect || { x: 0, y: 0, width: 0, height: 0 };
679
+ const out = {
680
+ coordinateSpace: source.coordinateSpace === 'canvas-css-px' ? 'canvas-css-px' : 'document-css-px',
681
+ originCss: { x: Number(origin.x) || 0, y: Number(origin.y) || 0 },
682
+ imagePixels: { width: Number(pixels.width) || 0, height: Number(pixels.height) || 0 },
683
+ pixelsPerCssPixel: Number(source.pixelsPerCssPixel) || 0,
684
+ cssRect: {
685
+ x: Number(rect.x) || 0,
686
+ y: Number(rect.y) || 0,
687
+ width: Number(rect.width) || 0,
688
+ height: Number(rect.height) || 0,
689
+ },
690
+ formulas: {
691
+ cssToPixel: 'px = (css - origin) * pixelsPerCssPixel',
692
+ pixelToCss: 'css = origin + px / pixelsPerCssPixel',
693
+ },
694
+ };
695
+ const canvas = finiteCanvas(source.canvas);
696
+ if (canvas) out.canvas = canvas;
697
+ const contentRect = finiteRect(source.contentRect);
698
+ if (contentRect) out.contentRect = contentRect;
699
+ if (typeof source.blank === 'string') out.blank = source.blank;
700
+ return out;
701
+ }
702
+
703
+ function pathInvalid(message, details) {
704
+ return { ok: false, code: 'OUTPUT_PATH_INVALID', message, details };
705
+ }
706
+
707
+ function isInside(root, target) {
708
+ const rel = relative(root, target);
709
+ if (rel === '') return true;
710
+ if (isAbsolute(rel)) return false;
711
+ return rel !== '..' && !rel.startsWith('..' + sep);
712
+ }
713
+
714
+ /**
715
+ * 校验 out(工作区相对的 POSIX .png 路径),创建目录并做 realpath 越界守卫。
716
+ *
717
+ * 拒绝:绝对路径、反斜杠、NUL、空段、`.`/`..` 段、非 .png 结尾、
718
+ * 总长 >200 字节或单段 >100 字节;root 必须是存在的目录绝对路径。
719
+ *
720
+ * @param {{root:string,out?:string,defaultName?:string}} input
721
+ * @returns {{ok:true,root:string,dir:string,relativePath:string,absolutePath:string}
722
+ * |{ok:false,code:'OUTPUT_PATH_INVALID',message:string,details:object}}
723
+ */
724
+ export function resolveCapturePath({ root, out, defaultName } = {}) {
725
+ if (typeof root !== 'string' || root.trim() === '' || !isAbsolute(root)) {
726
+ return pathInvalid('workspace root must be an absolute path', { reason: 'root_not_absolute' });
727
+ }
728
+ let canonicalRoot;
729
+ try {
730
+ const info = statSync(root);
731
+ if (!info.isDirectory()) return pathInvalid('workspace root is not a directory', { reason: 'root_not_directory' });
732
+ canonicalRoot = realpathSync(root);
733
+ } catch {
734
+ return pathInvalid('workspace root does not exist', { reason: 'root_unavailable' });
735
+ }
736
+
737
+ const requested = out === undefined || out === null ? defaultName : out;
738
+ if (typeof requested !== 'string' || requested.trim() === '') {
739
+ return pathInvalid('capture output path must be a non-empty string', { reason: 'out_missing' });
740
+ }
741
+ if (requested.includes('\0')) return pathInvalid('capture output path contains a NUL byte', { reason: 'out_nul' });
742
+ if (requested.includes('\\')) {
743
+ return pathInvalid('capture output path must use POSIX separators, not backslashes', { reason: 'out_backslash' });
744
+ }
745
+ if (/^[A-Za-z]:/.test(requested) || isAbsolute(requested) || requested.startsWith('//')) {
746
+ return pathInvalid('capture output path must be relative to the workspace root', { reason: 'out_absolute' });
747
+ }
748
+ const byteLength = Buffer.byteLength(requested, 'utf8');
749
+ if (byteLength > 200) {
750
+ return pathInvalid('capture output path is longer than 200 bytes', { reason: 'out_too_long', byteLength });
751
+ }
752
+ const segments = requested.split('/');
753
+ for (const segment of segments) {
754
+ if (segment === '' || segment === '.' || segment === '..') {
755
+ return pathInvalid('capture output path must not contain empty, "." or ".." segments', {
756
+ reason: segment === '..' ? 'out_parent_segment' : 'out_empty_segment',
757
+ });
758
+ }
759
+ if (Buffer.byteLength(segment, 'utf8') > 100) {
760
+ return pathInvalid('capture output path has a path segment longer than 100 bytes', { reason: 'out_segment_too_long' });
761
+ }
762
+ }
763
+ const name = segments[segments.length - 1];
764
+ if (!name.endsWith('.png')) {
765
+ return pathInvalid('capture output path must end with .png', { reason: 'out_not_png' });
766
+ }
767
+
768
+ const targetAbsolute = resolve(canonicalRoot, requested);
769
+ if (!isInside(canonicalRoot, targetAbsolute)) {
770
+ return pathInvalid('capture output path escapes the workspace root', { reason: 'out_escape' });
771
+ }
772
+
773
+ let dir;
774
+ try {
775
+ mkdirSync(dirname(targetAbsolute), { recursive: true });
776
+ dir = realpathSync(dirname(targetAbsolute));
777
+ } catch (error) {
778
+ return pathInvalid('capture output directory cannot be created', {
779
+ reason: 'dir_create_failed',
780
+ code: error && error.code ? String(error.code) : null,
781
+ });
782
+ }
783
+ if (!isInside(canonicalRoot, dir) && dir !== canonicalRoot) {
784
+ return pathInvalid('capture output directory resolves outside the workspace root', { reason: 'dir_escape' });
785
+ }
786
+
787
+ const absolutePath = join(dir, basename(targetAbsolute));
788
+ const relativePath = relative(canonicalRoot, absolutePath).split(sep).join('/');
789
+ if (relativePath === '' || relativePath.startsWith('../') || relativePath === '..' || isAbsolute(relativePath)) {
790
+ return pathInvalid('capture output path escapes the workspace root', { reason: 'out_escape' });
791
+ }
792
+ return { ok: true, root: canonicalRoot, dir, relativePath, absolutePath };
793
+ }
794
+
795
+ /** 目录 fsync:返回是否成功;失败只是“清理/持久化未确认”,不改变已提交文件的成功语义。 */
796
+ function fsyncDirectory(dir) {
797
+ let fd;
798
+ try {
799
+ fd = openSync(dir, 'r');
800
+ fsyncSync(fd);
801
+ return true;
802
+ } catch {
803
+ return false;
804
+ } finally {
805
+ if (fd !== undefined) {
806
+ try { closeSync(fd); } catch { /* ignore */ }
807
+ }
808
+ }
809
+ }
810
+
811
+ function writeFailed(message, details) {
812
+ return { ok: false, code: 'OUTPUT_WRITE_FAILED', message, details };
813
+ }
814
+
815
+ /**
816
+ * 原子且不覆盖地发布一个文件:同目录隐藏临时文件(O_EXCL、0600)写完 fsync ->
817
+ * `link(temp, final)`(目标已存在时 EEXIST)-> 删临时文件 -> 目录 fsync(尽力而为)。
818
+ *
819
+ * **提交点 = `link(temp, final)` 成功那一刻**。此后 temp unlink / 目录 fsync 的失败只记进
820
+ * `cleanup:{tempRemoved,dirFsync}` 并仍然返回成功:绝不因为清理失败改报错误,也绝不删除已发布文件。
821
+ * 只有 link 之前的失败(写/文件 fsync/link 非 EEXIST 错误)才清理 temp 并返回失败。
822
+ *
823
+ * 既有目标文件一个字节都不会被改动;symlink 目标同样按已存在处理(EEXIST),
824
+ * 不跟随、不覆盖。EEXIST 时只删临时文件,final 零改动。
825
+ *
826
+ * 边界:mkdir + realpath + link 防的是常规越界/覆盖,不声称抵抗“本地恶意进程在校验后并发替换父目录”
827
+ * 的 TOCTOU,也不声称文件系统级安全隔离。
828
+ *
829
+ * @param {string} dir 目标目录(绝对路径)
830
+ * @param {string} absolutePath 目标文件的绝对路径(必须与 dir 同目录)
831
+ * @param {Uint8Array|Buffer} bytes
832
+ * @param {{mode?:number}} [options]
833
+ * @returns {{ok:true,path:string,bytes:number,createdAt:number,mode:string,
834
+ * cleanup:{tempRemoved:boolean,dirFsync:boolean}}
835
+ * |{ok:false,code:'OUTPUT_EXISTS'|'OUTPUT_WRITE_FAILED',message:string,details:object}}
836
+ */
837
+ export function publishCapture(dir, absolutePath, bytes, { mode = 0o600 } = {}) {
838
+ const name = typeof absolutePath === 'string' ? basename(absolutePath) : null;
839
+ if (typeof dir !== 'string' || !isAbsolute(dir)) {
840
+ return writeFailed('capture output directory must be an absolute path', { reason: 'dir_not_absolute' });
841
+ }
842
+ if (typeof absolutePath !== 'string' || !isAbsolute(absolutePath)) {
843
+ return writeFailed('capture output path must be an absolute path', { reason: 'path_not_absolute' });
844
+ }
845
+ if (resolve(dirname(absolutePath)) !== resolve(dir)) {
846
+ return writeFailed('capture output file must live in the capture output directory', {
847
+ reason: 'cross_directory',
848
+ name,
849
+ });
850
+ }
851
+ const data = asBuffer(bytes);
852
+ if (!data || data.length === 0) {
853
+ return writeFailed('capture bytes are empty', { reason: 'empty_bytes', name });
854
+ }
855
+ const fileMode = Number.isInteger(mode) && mode > 0 && mode <= 0o777 ? mode : 0o600;
856
+ const modeString = '0' + (fileMode & 0o777).toString(8).padStart(3, '0');
857
+ const tempPath = join(dir, '.tmp-' + randomUUID() + '.png');
858
+
859
+ let fd;
860
+ try {
861
+ fd = openSync(tempPath, 'wx', fileMode);
862
+ writeSync(fd, data);
863
+ fsyncSync(fd);
864
+ closeSync(fd);
865
+ fd = undefined;
866
+ } catch (error) {
867
+ if (fd !== undefined) {
868
+ try { closeSync(fd); } catch { /* ignore */ }
869
+ }
870
+ try { unlinkSync(tempPath); } catch { /* ignore */ }
871
+ return writeFailed('capture bytes could not be written to the output directory', {
872
+ reason: 'temp_write_failed',
873
+ code: error && error.code ? String(error.code) : null,
874
+ name,
875
+ });
876
+ }
877
+
878
+ try {
879
+ linkSync(tempPath, absolutePath);
880
+ } catch (error) {
881
+ // link 失败 = 未提交:清理临时文件,final 一定没有被本次调用创建或改动。
882
+ try { unlinkSync(tempPath); } catch { /* ignore */ }
883
+ if (error && (error.code === 'EEXIST' || error.code === 'EISDIR')) {
884
+ return {
885
+ ok: false,
886
+ code: 'OUTPUT_EXISTS',
887
+ message: 'capture output already exists; this tool never overwrites it',
888
+ details: { reason: 'output_exists', name },
889
+ };
890
+ }
891
+ return writeFailed('capture file could not be published atomically', {
892
+ reason: 'publish_failed',
893
+ code: error && error.code ? String(error.code) : null,
894
+ name,
895
+ });
896
+ }
897
+
898
+ // ---- 提交点已过:以下清理的成败只记录,不改变结果,也不碰 final ----
899
+ let tempRemoved = true;
900
+ try { unlinkSync(tempPath); } catch { tempRemoved = false; }
901
+ const dirFsync = fsyncDirectory(dir);
902
+ return {
903
+ ok: true,
904
+ path: absolutePath,
905
+ bytes: data.length,
906
+ createdAt: Date.now(),
907
+ mode: modeString,
908
+ cleanup: { tempRemoved, dirFsync },
909
+ };
910
+ }