展示截图与移动端复验 capture-showcases
scripts/capture-showcases.mjs 是本技能(skill)质量工具链中的渲染验证脚本:它以无头 Microsoft Edge 为渲染引擎,通过原生 WebSocket 直连 Chrome DevTools Protocol(CDP),在精确的桌面视口(1440×900)和可选的移动端视口(390×844)下渲染五个 showcase 样本页,采集布局指标并对横向溢出(horizontal overflow)做出硬性失败判定(非零退出码),同时把每页截图落盘到 assets/showcases/screenshots/。
Purpose and Scope
本页覆盖"展示截图 + 移动端复验"这一能力的端到端实现:
- 截图脚本
scripts/capture-showcases.mjs的完整控制流(启动 Edge → 建立 CDP 会话 → 设备仿真 → 导航 → 字体就绪等待 → 指标采集 → 截图落盘 → 溢出判定) - 内建的极简 CDP 客户端
CdpClient(无任何第三方依赖,仅用 Node 原生WebSocket) - 与页面运行时
assets/showcases/showcase.js之间的depth=complexURL 参数协议闭环 - 产物目录布局、CLI 参数、环境变量、退出码语义
- 失败模式(Edge 未安装、CDP 就绪前退出、字体未就绪、横向溢出)与运维注意事项
以下相关主题有意留给兄弟页面,本页不展开:
- 推广图(promo)生成:
scripts/capture-promos.mjs基于本脚本产出的截图二次加工社交推广图集,见其对应页面 - HTML/CSS 审计工具:
scripts/audit-ark-ui.mjs,属于质量工具链的另一个叶子页 - showcase 样本页本身的视觉设计与 CSS 证据提取(
analyze-css-evidence.py),同样属于独立页面
Overview
这个脚本存在的目的是回答一个具体问题:"这五个 showcase 样本在真实浏览器引擎里、在指定视口下,会不会横向溢出?" —— 并且要以机器可判定的方式回答(进程退出码),从而可以嵌入 CI 或技能自检流程。
三个关键设计决策值得先点出:
-
零第三方依赖。脚本没有引入 Playwright / Puppeteer,而是直接用 Node 18+ 内置的
WebSocket手写了一个约 50 行的 CDP 客户端CdpClient。这意味着该 skill 分发时不需要npm install,只要目标机器上有 Edge 二进制即可运行。 -
视口即契约。
Emulation.setDeviceMetricsOverride把视口宽高、deviceScaleFactor: 1、mobile标志一次性固化,随后断言scrollWidth <= innerWidth。截图(PNG)与指标(控制台日志行)同时产出,使"视觉证据"与"数值证据"可以互相对照。 -
depth=complex参数协议。脚本导航时给每个样本页追加?depth=complex查询参数;页面里的showcase.js读取该参数并写入根元素的dataset.depth;截图前的指标采集又把dataset.depth回读出来。这构成一条"导航参数 → 页面状态 → 回读验证"的闭环,确保截图确实是在预期的内容深度下拍摄的(而不是默认态或上一次的状态残留)。
移动端复验指的是:传入 --mobile 时额外追加 390×844(iPhone 12/13/14 逻辑分辨率)的移动 profile,并把截图写到 screenshots/mobile/ 子目录。桌面 profile 始终执行,移动 profile 是增量叠加。
Architecture
图中的数据流解释:
- CLI/Env 层决定两件事:
--mobile决定profiles数组是否包含移动 profile;ARK_UI_EDGE_BIN决定用哪个 Edge 二进制(默认是 macOS 的 Edge 安装路径)。 launchEdge()用spawn启动无头 Edge,并从其stderr中用正则/DevTools listening on (ws:\/\/[^\s]+)/提取 CDP WebSocket 端点 —— 这是不引入 HTTP 版/json/version端点查询的简化做法。CdpClient是唯一与浏览器对话的通道:命令走pendingMap(按自增id关联 Promise),事件走listenersMap(按sessionId:method复合键分发)。createPage()通过Target.createTarget+Target.attachToTarget(flatten: true)拿到sessionId,后续所有页面级命令都带这个sessionId。capture()在同一sessionId内完成"仿真 → 导航 → 等待 → 采集 → 截图",并通过 URL 参数与页面内的showcase.js形成状态闭环。- 产物层按 profile 分目录落盘,并在控制台输出带
overflow=pass|FAIL的指标行;溢出时把process.exitCode置 1。
Main Content
样本清单与视口 Profile
脚本把"要截图的页面"与"用什么视口截图"拆成两个独立数组,再在 main() 里做笛卡尔积遍历:
1const samples = [
2 ['ark', 'ark.html'],
3 ['endfield', 'endfield.html'],
4 ['exa', 'exa.html'],
5 ['popucom', 'popucom.html'],
6 ['corporate', 'corporate.html'],
7];
8
9const profiles = [
10 { label: 'desktop', width: 1440, height: 900, mobile: false },
11 ...(includeMobile ? [{ label: 'mobile', width: 390, height: 844, mobile: true }] : []),
12];Source: capture-showcases.mjs
设计意图:双数组分离让"新增样本"(改 samples)与"新增视口"(改 profiles)互不干扰;而 includeMobile 用展开运算符条件插入,避免了命令行里出现 --no-mobile 这类否定型标志。遍历顺序是外层 profiles、内层 samples,意味着所有样本先跑完桌面再进入移动复验 —— 一旦移动端出现系统性溢出,能尽早暴露而不是被五个桌面截图"稀释"在长日志里。
五个样本分别对应 assets/showcases/ 下的同名 HTML:ark.html、endfield.html、exa.html、popucom.html、corporate.html。
极简 CDP 客户端:CdpClient
这是脚本的核心抽象,职责是把"命令-响应"与"事件监听"两类 CDP 通信统一到一条 WebSocket 上:
1class CdpClient {
2 constructor(url) {
3 this.socket = new WebSocket(url);
4 this.nextId = 1;
5 this.pending = new Map();
6 this.listeners = new Map();
7 }
8
9 async open() {
10 await new Promise((resolve, reject) => {
11 this.socket.addEventListener('open', resolve, { once: true });
12 this.socket.addEventListener('error', reject, { once: true });
13 });
14 this.socket.addEventListener('message', (event) => {
15 const message = JSON.parse(event.data);
16 if (message.id && this.pending.has(message.id)) {
17 const { resolve, reject } = this.pending.get(message.id);
18 this.pending.delete(message.id);
19 if (message.error) reject(new Error(message.error.message));
20 else resolve(message.result);
21 return;
22 }
23 const key = `${message.sessionId || 'browser'}:${message.method}`;
24 const listeners = this.listeners.get(key) || [];
25 listeners.forEach((listener) => listener(message.params));
26 });
27 }Source: capture-showcases.mjs
消息分发的判定逻辑是:带 id 且在 pending 中的是命令响应(resolve/reject 后立即从 Map 删除,保证一次性);其余都是事件,按 sessionId:method 复合键查找监听器。用 sessionId 做键前缀是必须的 —— 在 Target.attachToTarget 的 flatten: true 模式下,同一 WebSocket 上会混杂浏览器级事件与多个 session 级事件,不带前缀会导致事件串台。
命令发送是典型的自增 ID + Promise 挂起:
1 command(method, params = {}, sessionId) {
2 const id = this.nextId++;
3 const payload = { id, method, params };
4 if (sessionId) payload.sessionId = sessionId;
5 const promise = new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
6 this.socket.send(JSON.stringify(payload));
7 return promise;
8 }
9
10 once(method, sessionId) {
11 const key = `${sessionId || 'browser'}:${method}`;
12 return new Promise((resolve) => {
13 const listener = (params) => {
14 this.listeners.set(key, (this.listeners.get(key) || []).filter((item) => item !== listener));
15 resolve(params);
16 };
17 this.listeners.set(key, [...(this.listeners.get(key) || []), listener]);
18 });
19 }Source: capture-showcases.mjs
once() 的实现值得注意:注册时先构造一个会自我注销的闭包(触发后把自己从监听器数组里过滤掉),然后才追加进数组 —— 这个顺序保证了同一事件不会重复触发。
无头 Edge 的启动与端点发现
1function launchEdge(userDataDirectory) {
2 const processHandle = spawn(edgeBinary, [
3 '--headless=new',
4 '--disable-gpu',
5 '--hide-scrollbars',
6 '--remote-debugging-port=0',
7 `--user-data-dir=${userDataDirectory}`,
8 'about:blank',
9 ], { stdio: ['ignore', 'ignore', 'pipe'] });
10
11 const webSocketUrl = new Promise((resolve, reject) => {
12 let stderr = '';
13 processHandle.stderr.setEncoding('utf8');
14 processHandle.stderr.on('data', (chunk) => {
15 stderr += chunk;
16 const match = stderr.match(/DevTools listening on (ws:\/\/[^\s]+)/);
17 if (match) resolve(match[1]);
18 });
19 processHandle.once('exit', (code) => reject(new Error(`Edge exited before CDP was ready (${code}).`)));
20 processHandle.once('error', reject);
21 });
22
23 return { processHandle, webSocketUrl };
24}Source: capture-showcases.mjs
几个参数的取舍:
--headless=new:新版无头模式(与旧--headless的区别在于使用与有头一致的渲染管线),保证截图与真实浏览器一致。--disable-gpu+--hide-scrollbars:前者避免无 GPU 环境下的软件渲染差异,后者防止滚动条占据视口宽度干扰innerWidth/scrollWidth的溢出判定。--remote-debugging-port=0:端口 0 表示让操作系统随机分配空闲端口,这样并行跑多份脚本不会端口冲突;随后从stderr正则提取实际ws://端点。--user-data-dir指向mkdtemp创建的一次性临时目录,避免污染用户真实 Edge profile,也避免与常驻 Edge 实例的 user-data 锁冲突。stdio: ['ignore', 'ignore', 'pipe']只保留 stderr,因为 CDP 端点信息打印在 stderr。
竞态防护体现在 processHandle.once('exit', ...):若 Edge 在 CDP 就绪前退出(例如二进制路径错误、macOS Gatekeeper 拦截),该 Promise 会以明确错误 reject,而不是让脚本永远等待 open。
页面(Target)的创建与 attach
1async function createPage(client) {
2 const { targetId } = await client.command('Target.createTarget', { url: 'about:blank' });
3 const { sessionId } = await client.command('Target.attachToTarget', { targetId, flatten: true });
4 await client.command('Page.enable', {}, sessionId);
5 return { targetId, sessionId };
6}Source: capture-showcases.mjs
flatten: true 是关键:它让该 target 的后续事件直接以扁平 sessionId 字段出现在同一 WebSocket 消息流里,而不是嵌套在 Target.receivedMessageFromTarget 里 —— 这正是 CdpClient 按 sessionId:method 键分发事件的前提。Page.enable 则是开启 Page.loadEventFired 等页面域事件的开关。
注意:整个生命周期只创建一个 target,十个截图(5 样本 × 2 profile)全部复用同一 sessionId。 这意味着视口切换靠 Emulation.setDeviceMetricsOverride 而非新建页面,能显著缩短总耗时,但代价是页面状态可能跨样本残留 —— 这正是脚本用 ?depth=complex 参数显式重置深度的原因之一。
单次截图的核心流程:capture()
1async function capture(client, sessionId, sampleName, htmlFile, profile) {
2 await client.command('Emulation.setDeviceMetricsOverride', {
3 width: profile.width,
4 height: profile.height,
5 deviceScaleFactor: 1,
6 mobile: profile.mobile,
7 screenWidth: profile.width,
8 screenHeight: profile.height,
9 }, sessionId);
10
11 const pageUrl = new URL(pathToFileURL(join(showcaseDirectory, htmlFile)).href);
12 pageUrl.searchParams.set('depth', 'complex');
13 const loaded = client.once('Page.loadEventFired', sessionId);
14 await client.command('Page.navigate', { url: pageUrl.href }, sessionId);
15 await loaded;
16 await client.command('Runtime.evaluate', {
17 expression: 'document.fonts.ready.then(() => new Promise(requestAnimationFrame))',
18 awaitPromise: true,
19 }, sessionId);
20
21 const { result } = await client.command('Runtime.evaluate', {
22 expression: `JSON.stringify({
23 innerWidth,
24 innerHeight,
25 scrollWidth: document.documentElement.scrollWidth,
26 scrollHeight: document.documentElement.scrollHeight,
27 depth: document.querySelector('.family-showcase')?.dataset.depth
28 })`,
29 returnByValue: true,
30 }, sessionId);
31 const metrics = JSON.parse(result.value);
32 const overflow = metrics.scrollWidth > metrics.innerWidth;
33
34 const { data } = await client.command('Page.captureScreenshot', {
35 format: 'png',
36 fromSurface: true,
37 captureBeyondViewport: false,
38 }, sessionId);
39 const outputDirectory = profile.mobile ? join(screenshotDirectory, 'mobile') : screenshotDirectory;
40 await mkdir(outputDirectory, { recursive: true });
41 const outputPath = join(outputDirectory, `${sampleName}-complex.png`);
42 await writeFile(outputPath, Buffer.from(data, 'base64'));
43
44 console.log(`${sampleName.padEnd(10)} ${profile.label.padEnd(7)} ${metrics.innerWidth}x${metrics.innerHeight} scroll=${metrics.scrollWidth}x${metrics.scrollHeight} overflow=${overflow ? 'FAIL' : 'pass'}`);
45 if (overflow) process.exitCode = 1;
46}Source: capture-showcases.mjs
逐步拆解每一步为什么存在:
Emulation.setDeviceMetricsOverride—— 同时设置width/height/screenWidth/screenHeight并deviceScaleFactor: 1、mobile布尔值。mobile: true会触发移动端视口元数据处理与触摸事件能力,是移动复验真实性的关键;deviceScaleFactor: 1则保证输出像素与 CSS 像素 1:1,指标可直接对比。- 构造
file://URL 并注入?depth=complex——pathToFileURL正确处理本地路径到 file URL 的转义;depth=complex是与页面showcase.js的约定参数(见下一节)。 - 先注册
Page.loadEventFired再导航 ——client.once(...)在Page.navigate之前调用,避免导航完成快于监听注册的竞态。这是事件驱动客户端的标准模式。 document.fonts.ready+requestAnimationFrame双重等待 ——document.fonts.ready等 webfont 加载完成(否则截图可能用回退字体);new Promise(requestAnimationFrame)再等一帧,确保字体生效后的重排已完成。awaitPromise: true让 CDP 等待表达式返回的 Promise。- 指标采集 —— 通过
Runtime.evaluate+returnByValue: true拿回 JSON 字符串,包含innerWidth/innerHeight/scrollWidth/scrollHeight以及.family-showcase的dataset.depth(回读验证深度参数确实生效)。 - 溢出判定 ——
overflow = scrollWidth > innerWidth,只判横向;纵向滚动对这种展示页是预期行为,不作为失败条件。 Page.captureScreenshot参数 ——fromSurface: true保证从合成后的表面取图;captureBeyondViewport: false只截视口区域,与溢出判定基于的视口度量保持一致。- 落盘与退出码 —— 移动 profile 写入
mobile/子目录;文件名固定为{sample}-complex.png(体现深度后缀);溢出时不抛异常、不中断,仅置process.exitCode = 1,让后续样本继续执行以获得完整诊断信息。
与页面运行时的 depth 参数协议闭环
样本页共享一份运行时脚本 assets/showcases/showcase.js,其入口逻辑:
1const showcaseRoot = document.querySelector('.family-showcase');
2const depthButtons = [...document.querySelectorAll('[data-set-depth]')];
3const navButtons = [...document.querySelectorAll('[data-view]')];
4const actionButtons = [...document.querySelectorAll('[data-demo-action]')];
5const liveStatus = document.querySelector('[data-live-status]');
6const allowedDepths = ['minimal', 'moderate', 'complex', 'maximal'];
7
8function setDepth(depth) {
9 const next = allowedDepths.includes(depth) ? depth : 'complex';
10 showcaseRoot.dataset.depth = next;
11 depthButtons.forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.setDepth === next)));
12 const url = new URL(location.href);
13 url.searchParams.set('depth', next);
14 history.replaceState({}, '', url);
15 if (liveStatus) liveStatus.textContent = `Application depth: ${next}. Content and accessible names remain unchanged.`;
16}Source: showcase.js
以及初始化调用:
const requestedDepth = new URLSearchParams(location.search).get('depth');
setDepth(requestedDepth || showcaseRoot.dataset.depth || 'complex');Source: showcase.js
协议闭环的三段:
| 阶段 | 参与方 | 动作 |
|---|---|---|
| 注入 | capture() | 导航 URL 带 ?depth=complex |
| 应用 | showcase.js#setDepth | 校验白名单后写入 showcaseRoot.dataset.depth,并用 history.replaceState 把参数固化回地址栏 |
| 回读 | capture() 指标采集 | document.querySelector('.family-showcase')?.dataset.depth 读回,随指标一起输出 |
allowedDepths 白名单 + 三级回退(requestedDepth || dataset.depth || 'complex')意味着即使外部传入非法值,也会收敛到 complex,而 complex 正是截图脚本所请求的档位 —— 状态永远不会落到未定义深度。history.replaceState 的作用是让用户手工打开页面切换深度后,URL 可直接分享/复现;liveStatus 更新则保证切换深度时屏幕阅读器可感知(aria-pressed 同步按钮态)。
生命周期与资源清理:main()
1async function main() {
2 const userDataDirectory = await mkdtemp(join(tmpdir(), 'ark-ui-edge-'));
3 const { processHandle, webSocketUrl } = launchEdge(userDataDirectory);
4 const client = new CdpClient(await webSocketUrl);
5
6 try {
7 await client.open();
8 const { targetId, sessionId } = await createPage(client);
9 for (const profile of profiles) {
10 for (const [sampleName, htmlFile] of samples) {
11 await capture(client, sessionId, sampleName, htmlFile, profile);
12 }
13 }
14 await client.command('Target.closeTarget', { targetId });
15 } finally {
16 client.close();
17 processHandle.kill('SIGTERM');
18 }
19}
20
21main().catch((error) => {
22 console.error(error.stack || error.message);
23 process.exitCode = 1;
24});Source: capture-showcases.mjs
清理顺序是刻意安排的:try/finally 保证无论截图循环中哪一步抛错,都会先 Target.closeTarget(正常路径)、再关 WebSocket、最后 SIGTERM 杀掉 Edge 进程。顶层 main().catch 兜底所有异常并置退出码 1,与溢出判定共用同一失败语义(CI 只需看退出码)。临时 userDataDirectory 交给操作系统 tmp 清理策略,脚本自身不删除。
Core Flow
时序要点:事件监听必须先于导航注册;字体等待必须先于指标采集与截图;溢出判定只影响退出码、不中断循环;所有清理集中在 finally。
Usage Examples
基本用法:桌面截图 + 溢出校验
README 中的质量工具链调用方式(作为技能自检流程的一部分):
node "$CODEX_HOME/skills/ark-ui/scripts/capture-showcases.mjs" --mobileSource: README.md
不传 --mobile 时仅执行桌面 profile(1440×900),产出 assets/showcases/screenshots/{sample}-complex.png 五张图。
运行时输出的指标行格式
capture() 末尾的日志行示例(格式由 padEnd 对齐):
1ark desktop 1440x900 scroll=1440x2418 overflow=pass
2endfield desktop 1440x900 scroll=1440x2205 overflow=pass
3exa desktop 1440x900 scroll=1440x2562 overflow=pass
4popucom desktop 1440x900 scroll=1440x2334 overflow=pass
5corporate desktop 1440x900 scroll=1440x2480 overflow=pass
6ark mobile 390x844 scroll=390x3112 overflow=pass
7...(以上为格式说明示例;scrollWidth/scrollHeight 实际值以运行时为准。overflow=FAIL 的行会使进程以非零码退出。)
页面侧:用 URL 参数预置深度
showcase.js 的初始化使任何带 ?depth= 的链接都能直接进入对应深度,这正是截图脚本依赖的入口:
<a href="assets/showcases/corporate.html?depth=maximal">maximal 深度直达</a>在浏览器中打开该链接时,setDepth(requestedDepth || ...) 会立即应用 maximal 并同步按钮的 aria-pressed 状态。截图脚本使用的是 complex 档({sample}-complex.png 命名即来源于此)。
Configuration Options
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
--mobile | CLI 标志 | 不启用 | 追加 390×844(mobile: true)的移动 profile;桌面 profile 始终执行 |
ARK_UI_EDGE_BIN | 环境变量 | /Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge | Edge 二进制绝对路径;Linux/CI 上需显式覆盖 |
depth 查询参数 | URL 参数(注入值固定 complex) | 页面侧回退 complex | 控制 showcase 内容深度;页面侧由 allowedDepths 白名单约束为 minimal/moderate/complex/maximal |
| 截图输出目录 | 路径常量 | assets/showcases/screenshots/ | 桌面直写该目录;移动 profile 写 mobile/ 子目录 |
| Edge user-data-dir | 运行时生成 | mkdtemp(tmpdir()/ark-ui-edge-) | 一次性临时目录,避免 profile 锁冲突 |
| CDP 调试端口 | 启动参数 | 0(随机分配) | 从 Edge stderr 正则提取实际 ws:// 端点 |
相关源码依据:
const edgeBinary = process.env.ARK_UI_EDGE_BIN
|| '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge';
const includeMobile = process.argv.includes('--mobile');Source: capture-showcases.mjs
API Reference
脚本为内部工具(非导出模块),以下列出可复用的关键函数签名。
launchEdge(userDataDirectory: string): { processHandle, webSocketUrl: Promise<string> }
启动无头 Edge 并返回进程句柄与"CDP WebSocket 端点"的 Promise。webSocketUrl 从 stderr 的 DevTools listening on ws://... 提取;若进程在就绪前退出,Promise 以 Edge exited before CDP was ready (code) reject。
Parameters:
userDataDirectory(string):传给--user-data-dir的一次性临时目录。
Returns: { processHandle: ChildProcess, webSocketUrl: Promise<string> }
createPage(client: CdpClient): Promise<{ targetId, sessionId }>
创建 about:blank target,以 flatten: true attach 得到 sessionId,并执行 Page.enable 开启页面域事件。
capture(client: CdpClient, sessionId: string, sampleName: string, htmlFile: string, profile): Promise<void>
完成一次"仿真 → 导航 → 等待 → 采集 → 截图 → 判定"。Parameters: profile 需含 label/width/height/mobile。副作用: 写 PNG、打日志;溢出时置 process.exitCode = 1(不抛异常)。
CdpClient.command(method: string, params?: object, sessionId?: string): Promise<any>
发送一条 CDP 命令并挂起等待响应;message.error 存在时 reject。
CdpClient.once(method: string, sessionId?: string): Promise<object>
注册一次性事件监听(sessionId:method 复合键),触发后自动注销并 resolve 事件参数。
Failure Modes, Edge Cases & Concurrency
| 失败场景 | 检测机制 | 脚本行为 |
|---|---|---|
| Edge 二进制不存在 / 路径错误 | spawn 的 error 事件 | webSocketUrl reject,main().catch 捕获,退出码 1,stderr 打印堆栈 |
| Edge 在 CDP 就绪前退出 | processHandle.once('exit') | reject Edge exited before CDP ready (code),明确提示启动期失败 |
横向溢出(scrollWidth > innerWidth) | 指标采集后比较 | 不中断循环,继续截其余样本;日志标 overflow=FAIL,退出码 1 |
| webfont 未加载完成即截图 | document.fonts.ready + rAF 双等待 | 等待后才采集,避免字体回退导致的假性布局 |
| 导航事件晚于监听注册的竞态 | 先 once(Page.loadEventFired) 再 Page.navigate | 结构性规避竞态 |
非法 depth 参数 | allowedDepths 白名单 + 回退 complex | 收敛到 complex,回读 dataset.depth 可在指标中核对 |
?.dataset.depth 为空(根元素缺失) | 可选链 + JSON 序列化 | depth 字段为 undefined(JSON 中被省略),不抛异常 |
| 残留状态跨截图串扰 | 每次导航都显式带 ?depth=complex | 强制重置深度;但同一 sessionId 复用意味着其他状态需样本自身管理 |
| 临时 profile 锁冲突 | mkdtemp 唯一目录 + --remote-debugging-port=0 | 支持并行多实例运行 |
| 溢出失败后的进程清理 | try/finally | 无论如何都 Target.closeTarget → 关 WebSocket → SIGTERM |
并发:脚本天然可并行(随机端口 + 独立临时 user-data-dir),但输出文件名固定({sample}-complex.png),并行多个实例写同一仓库目录会互相覆盖产物 —— 并行仅适用于不同 checkout 或不同输出场景。
Performance / Operational Notes
- 单 target 复用:10 次截图共用一个
sessionId,省去 10 次Target.createTarget/attach的开销;代价是状态复用需由 URL 参数协议保证。 - 串行执行:
for...of + await串行遍历,总耗时 ≈ 10 × (导航 + 字体等待 + 截图编码)。PNG base64 传输是主要耗时点之一。 - CI 友好性:非零退出码是唯一失败信号;日志行自带对齐的指标,可直接 grep
overflow=FAIL定位问题样本。 SKILL.md中的定位:capture-showcases.mjs被描述为"在精确的桌面与可选移动 CDP 视口渲染五个样本,并在横向溢出时失败"的质量工具(见 SKILL.md),通常与audit-ark-ui.mjs、capture-promos.mjs组成自检链。- 跨平台注意:默认二进制路径是 macOS 专属;Linux CI 需
ARK_UI_EDGE_BIN=/usr/bin/microsoft-edge(或 chromium 系兼容二进制)覆盖。
Extension Points
- 新增样本:在
samples数组追加['名称', '文件.html']即可,产物命名自动跟随;无需改动其它逻辑。 - 新增视口档位:向
profiles追加对象(如平板768×1024),Emulation.setDeviceMetricsOverride参数完全来自 profile 字段,无需改capture()。 - 新增深度档位:页面侧在
allowedDepths加入新值,再让脚本把pageUrl.searchParams.set('depth', ...)参数化(当前硬编码complex)。 - 替换浏览器:
launchEdge的启动参数对 Chromium 系浏览器通用,覆盖ARK_UI_EDGE_BIN即可切到 Chrome/Chromium。 - 复用 CDP 客户端:
CdpClient自包含(command/once/close 三方法),可直接复制到其它截图/调试脚本中复用,无外部依赖。
Related Links
- SKILL.md - 质量工具链说明
- README.md - 自检流程调用示例
- scripts/capture-showcases.mjs(本页主实现)
- assets/showcases/showcase.js(页面运行时 / depth 协议)
- 推广图生成:
scripts/capture-promos.mjs(兄弟页面:6-quality-toolchain 下的 promo capture 主题) - HTML/CSS 审计:
scripts/audit-ark-ui.mjs(兄弟页面:审计工具主题)