- core 协议:Plugin 接口/注册器(重名拒绝/生命周期正序逆序/dispose幂等/错误隔离不连坐)+受控面 PluginContext(禁直透 littlejsengine)——前任agent核心设计17测绿收编(死于收尾的工具调用解析失败,续作字节级未碰) - _example 空插件样例五件全(克隆母本)+PLUGIN-TEMPLATE 六节规格+manifest schema+零依赖校验器(负例自检逮6违规) - scripts:esbuild锁参build/增量法size/test.sh遍历;browser-evidence harness纯计算口径冻结(FNV-1a/直方图/几何,CDP四函数=集成段占位) - 22/22 测全绿(RESULT.txt 留档);ESM JS+JSDoc+手写d.ts 零工具链纪律全程 - rc 状态:待 Gate0.1 受控面补丁(input/audio+per-plugin派生RNG+useContext守卫)后冻结 core-protocol-v0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
236 lines
12 KiB
JavaScript
236 lines
12 KiB
JavaScript
/**
|
||
* test/harness/browser-evidence.cjs — 浏览器视觉证据 harness(骨架契约,core-protocol-v0)
|
||
* owner:T1b-α lane-core(Gate0 奠基件)|消费方:集成段(mini-desktop Chrome)+ lane-vfx/lane-sys 视觉/渲染证据
|
||
*
|
||
* ════════════════════════════════════════════════════════════════════════════
|
||
* 【本阶段交付边界 —— 重要】
|
||
* Gate0 **只交本骨架 + 契约 + 使用说明**,**不要求本机真跑**。理由(执行版 §1/§5/§2):
|
||
* · 真跑需 CDP 连真 Chrome(headless)+ 渲染真 canvas → **仅集成段(mini-desktop)**;
|
||
* · 本机(6c6g)禁启 headless Chrome(OOM/exit144,CLAUDE.md 红线);
|
||
* · vfx/audio 的渲染/发声证据按 spec **必须**在集成段浏览器出,**禁 node-canvas 兜底**。
|
||
* 因此本文件中「连 CDP / 抓 ImageData / 截图」均为**契约占位(throw NOT_IMPLEMENTED)**,
|
||
* 集成段 lane 接真 CDP 后实现这些占位即可;而**纯计算口径**(FNV-1a 哈希 / 非空 / 色彩分布 /
|
||
* 几何断言)是**完整可跑实现**(不依赖浏览器),集成段直接复用,保证证据口径在 Gate0 即冻结。
|
||
*
|
||
* 【固定取证环境(集成段实现时必须照此设,§2 第6条)】
|
||
* viewport = 390×844(移动竖屏基准);DPR = 2;
|
||
* seed = 注入受控随机源种子(复现);时间源 mock = 注入虚拟时钟驱动帧(复现,不依赖真实墙钟)。
|
||
* → 同一 build + 同 seed + 同 mock 时钟 ⇒ 渲染逐像素确定 ⇒ ImageData 哈希可作回归断言。
|
||
*
|
||
* 【证据四件(与参考件/集成段口径对齐,§4/§6)】
|
||
* ① ImageData FNV-1a 哈希(逐像素,回归对照);② 非空断言(画面有不透明像素,非全透);
|
||
* ③ 色彩分布(直方图,防「画了但全黑」);④ 几何断言(指定区域命中预期像素,验「画在了该画的位置」);
|
||
* 截图落 evidence/<round|integration>/<name>.png(路径约定见 §6)。
|
||
*
|
||
* 【哈希口径】FNV-1a 32-bit —— 与 P10 RuntimeProbe 的哈希链同口径(§3),全发行版证据用同一哈希族。
|
||
*
|
||
* 用法(集成段,伪流程):
|
||
* const H = require('./browser-evidence.cjs');
|
||
* const cdp = await H.connectCdp({ url, viewport:{w:390,h:844}, dpr:2, seed, mockClock:true });
|
||
* await H.driveFrames(cdp, 60); // 用 mock 时钟推进 60 帧
|
||
* const img = await H.captureImageData(cdp, 'canvas'); // 抓 canvas 像素
|
||
* const ev = H.buildEvidence(img, { geometry:[{x,y,w,h,expect:'non-empty'}] });
|
||
* await H.saveScreenshot(cdp, H.screenshotPath('integration','first-paint'));
|
||
* // ev = { hash, nonEmpty, histogramTop, geometry:[...] } → 落 evidence + sha256-manifest
|
||
* ════════════════════════════════════════════════════════════════════════════
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const path = require('node:path');
|
||
|
||
/** 占位错误:集成段需实现的浏览器侧能力,本阶段调用即抛(不静默假成功)。 */
|
||
function notImplemented(what) {
|
||
const e = new Error(
|
||
`[browser-evidence] ${what} 为集成段占位 —— 本骨架不在本机真跑(须 mini-desktop Chrome via CDP)。` +
|
||
` 集成段 lane 实现本函数后即可启用。`
|
||
);
|
||
e.code = 'NOT_IMPLEMENTED_IN_GATE0';
|
||
return e;
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────────────────────
|
||
* 一、固定取证环境常量(集成段实现 connectCdp 时必须照此设)
|
||
* ────────────────────────────────────────────────────────────────────────── */
|
||
|
||
/** 固定取证环境(§2 第6条)。集成段建会话时照此设置,保证证据可复现。 */
|
||
const FIXED_ENV = Object.freeze({
|
||
viewport: Object.freeze({ width: 390, height: 844 }), // 移动竖屏基准
|
||
deviceScaleFactor: 2, // DPR 2
|
||
// seed / mockClock 由调用方按用例传入;此处声明「必须可注入」这一契约。
|
||
requiresSeed: true,
|
||
requiresMockClock: true,
|
||
});
|
||
|
||
/* ──────────────────────────────────────────────────────────────────────────
|
||
* 二、纯计算口径(不依赖浏览器,Gate0 即完整实现并冻结口径,集成段直接复用)
|
||
* ────────────────────────────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* FNV-1a 32-bit 哈希(与 P10 probe 哈希链同口径)。对 ImageData 的 RGBA 字节序列逐字节哈希。
|
||
* @param {Uint8Array|Buffer|number[]} bytes 字节序列(如 ImageData.data)
|
||
* @returns {string} 8 位十六进制哈希串(如 'a3f1c0de')
|
||
*/
|
||
function fnv1a32(bytes) {
|
||
let h = 0x811c9dc5; // FNV offset basis
|
||
for (let i = 0; i < bytes.length; i++) {
|
||
h ^= bytes[i] & 0xff;
|
||
// FNV prime 16777619,用 Math.imul 保 32 位乘法
|
||
h = Math.imul(h, 0x01000193) >>> 0;
|
||
}
|
||
return (h >>> 0).toString(16).padStart(8, '0');
|
||
}
|
||
|
||
/**
|
||
* 非空断言:画面是否「有内容」(防「成功抓图但全透明,啥也没画」)。
|
||
* 判据:存在任一不透明像素(alpha>0)即视为非空。
|
||
* 注:本断言只判「画面非全透」;「画面非纯一色」(如背景同色没画东西)由 colorHistogram
|
||
* 的主色集中度 + assertGeometry 的区域命中覆盖,不混入本断言,避免「画了单色物体反被判空」。
|
||
* @param {{ data:(Uint8Array|number[]), width:number, height:number }} img ImageData 形
|
||
* @returns {boolean}
|
||
*/
|
||
function assertNonEmpty(img) {
|
||
const d = img.data;
|
||
for (let i = 0; i < d.length; i += 4) {
|
||
if (d[i + 3] > 0) return true; // 存在不透明像素 → 画面有内容
|
||
}
|
||
return false; // 全透明 → 视为空
|
||
}
|
||
|
||
/**
|
||
* 色彩分布直方图:把 RGB 量化到粗粒度桶,统计 topN 主色(防「画了但全黑/全白」)。
|
||
* @param {{ data:(Uint8Array|number[]) }} img
|
||
* @param {number} [topN=5] 返回前 N 主色
|
||
* @returns {Array<{ color:string, count:number }>} 形如 [{color:'r0g128b255', count:1234}, ...]
|
||
*/
|
||
function colorHistogram(img, topN = 5) {
|
||
const d = img.data;
|
||
/** @type {Map<string, number>} */
|
||
const buckets = new Map();
|
||
// 量化到 32 级/通道(>>3),降桶数;只统计不透明像素。
|
||
for (let i = 0; i < d.length; i += 4) {
|
||
if (d[i + 3] === 0) continue;
|
||
const key = `r${d[i] >> 3}g${d[i + 1] >> 3}b${d[i + 2] >> 3}`;
|
||
buckets.set(key, (buckets.get(key) || 0) + 1);
|
||
}
|
||
return Array.from(buckets.entries())
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, topN)
|
||
.map(([color, count]) => ({ color, count }));
|
||
}
|
||
|
||
/**
|
||
* 几何断言:检查指定矩形区域是否命中预期('non-empty' = 区域内有不透明像素)。
|
||
* 用于验「画在了该画的位置」(如:飞机应出现在屏幕下方中央矩形内)。
|
||
* @param {{ data:(Uint8Array|number[]), width:number, height:number }} img
|
||
* @param {Array<{ x:number, y:number, w:number, h:number, expect:'non-empty'|'empty' }>} regions
|
||
* @returns {Array<{ region:object, pass:boolean, opaquePixels:number }>}
|
||
*/
|
||
function assertGeometry(img, regions) {
|
||
const d = img.data;
|
||
const W = img.width;
|
||
return (regions || []).map((reg) => {
|
||
let opaque = 0;
|
||
for (let yy = reg.y; yy < reg.y + reg.h; yy++) {
|
||
for (let xx = reg.x; xx < reg.x + reg.w; xx++) {
|
||
const idx = (yy * W + xx) * 4;
|
||
if (d[idx + 3] > 0) opaque++;
|
||
}
|
||
}
|
||
const hasContent = opaque > 0;
|
||
const pass = reg.expect === 'empty' ? !hasContent : hasContent;
|
||
return { region: reg, pass, opaquePixels: opaque };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 把一张 ImageData 汇成「证据对象」(四件套:哈希/非空/色彩分布/几何)。
|
||
* 这是集成段把渲染结果落证的统一入口;纯计算,Gate0 即可用。
|
||
* @param {{ data:(Uint8Array|number[]), width:number, height:number }} img
|
||
* @param {{ geometry?: Array<object> }} [opts]
|
||
* @returns {{ hash:string, nonEmpty:boolean, histogramTop:Array, geometry:Array, width:number, height:number }}
|
||
*/
|
||
function buildEvidence(img, opts = {}) {
|
||
return {
|
||
hash: fnv1a32(img.data),
|
||
nonEmpty: assertNonEmpty(img),
|
||
histogramTop: colorHistogram(img, 5),
|
||
geometry: assertGeometry(img, opts.geometry || []),
|
||
width: img.width,
|
||
height: img.height,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 截图路径约定(§6):evidence/<bucket>/<name>.png。
|
||
* @param {'integration'|`round-${number}`|string} bucket 证据桶(集成段=integration;参考件=round-N)
|
||
* @param {string} name 截图名(不含扩展名)
|
||
* @returns {string} 相对仓根的截图路径
|
||
*/
|
||
function screenshotPath(bucket, name) {
|
||
return path.posix.join('evidence', bucket, `${name}.png`);
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────────────────────
|
||
* 三、浏览器侧能力(集成段占位 —— 本阶段抛 NOT_IMPLEMENTED,不在本机跑)
|
||
* ────────────────────────────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* 连接 CDP 并按固定环境建会话(集成段实现)。
|
||
* 集成段须:用 FIXED_ENV 设 viewport/DPR;注入 seed 到发行版受控随机源;
|
||
* 注入 mock 时钟接管 onFrame 驱动(不走真实 RAF/墙钟)。
|
||
* @param {{ url:string, seed:number, viewport?:object, dpr?:number, mockClock?:boolean }} _opts
|
||
* @returns {Promise<object>} CDP 会话句柄(集成段定义形状)
|
||
*/
|
||
async function connectCdp(_opts) {
|
||
throw notImplemented('connectCdp');
|
||
}
|
||
|
||
/**
|
||
* 用 mock 时钟推进 N 帧(集成段实现:驱动发行版 onFrame,确定步进)。
|
||
* @param {object} _cdp connectCdp 返回的会话
|
||
* @param {number} _frames 帧数
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function driveFrames(_cdp, _frames) {
|
||
throw notImplemented('driveFrames');
|
||
}
|
||
|
||
/**
|
||
* 抓取指定 canvas 的 ImageData(集成段实现:CDP Runtime.evaluate 读 getImageData)。
|
||
* 返回 { data, width, height },交 buildEvidence 汇证。
|
||
* @param {object} _cdp 会话
|
||
* @param {string} _selector canvas 选择器
|
||
* @returns {Promise<{ data:Uint8Array, width:number, height:number }>}
|
||
*/
|
||
async function captureImageData(_cdp, _selector) {
|
||
throw notImplemented('captureImageData');
|
||
}
|
||
|
||
/**
|
||
* 保存截图到约定路径(集成段实现:CDP Page.captureScreenshot 落盘)。
|
||
* @param {object} _cdp 会话
|
||
* @param {string} _outPath screenshotPath() 给出的路径
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function saveScreenshot(_cdp, _outPath) {
|
||
throw notImplemented('saveScreenshot');
|
||
}
|
||
|
||
module.exports = {
|
||
// 常量
|
||
FIXED_ENV,
|
||
// 纯计算口径(Gate0 完整实现,集成段复用)
|
||
fnv1a32,
|
||
assertNonEmpty,
|
||
colorHistogram,
|
||
assertGeometry,
|
||
buildEvidence,
|
||
screenshotPath,
|
||
// 浏览器侧占位(集成段实现)
|
||
connectCdp,
|
||
driveFrames,
|
||
captureImageData,
|
||
saveScreenshot,
|
||
};
|