Repository Wiki
Brandon030722/ark-ui-skill

展示截图与移动端复验 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=complex URL 参数协议闭环
  • 产物目录布局、CLI 参数、环境变量、退出码语义
  • 失败模式(Edge 未安装、CDP 就绪前退出、字体未就绪、横向溢出)与运维注意事项

以下相关主题有意留给兄弟页面,本页不展开:

  • 推广图(promo)生成:scripts/capture-promos.mjs 基于本脚本产出的截图二次加工社交推广图集,见其对应页面
  • HTML/CSS 审计工具:scripts/audit-ark-ui.mjs,属于质量工具链的另一个叶子页
  • showcase 样本页本身的视觉设计与 CSS 证据提取(analyze-css-evidence.py),同样属于独立页面

Overview

这个脚本存在的目的是回答一个具体问题:"这五个 showcase 样本在真实浏览器引擎里、在指定视口下,会不会横向溢出?" —— 并且要以机器可判定的方式回答(进程退出码),从而可以嵌入 CI 或技能自检流程。

三个关键设计决策值得先点出:

  1. 零第三方依赖。脚本没有引入 Playwright / Puppeteer,而是直接用 Node 18+ 内置的 WebSocket 手写了一个约 50 行的 CDP 客户端 CdpClient。这意味着该 skill 分发时不需要 npm install,只要目标机器上有 Edge 二进制即可运行。

  2. 视口即契约。Emulation.setDeviceMetricsOverride 把视口宽高、deviceScaleFactor: 1、mobile 标志一次性固化,随后断言 scrollWidth <= innerWidth。截图(PNG)与指标(控制台日志行)同时产出,使"视觉证据"与"数值证据"可以互相对照。

  3. depth=complex 参数协议。脚本导航时给每个样本页追加 ?depth=complex 查询参数;页面里的 showcase.js 读取该参数并写入根元素的 dataset.depth;截图前的指标采集又把 dataset.depth 回读出来。这构成一条"导航参数 → 页面状态 → 回读验证"的闭环,确保截图确实是在预期的内容深度下拍摄的(而不是默认态或上一次的状态残留)。

移动端复验指的是:传入 --mobile 时额外追加 390×844(iPhone 12/13/14 逻辑分辨率)的移动 profile,并把截图写到 screenshots/mobile/ 子目录。桌面 profile 始终执行,移动 profile 是增量叠加。

Architecture

Loading diagram...

图中的数据流解释:

  • 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 是唯一与浏览器对话的通道:命令走 pending Map(按自增 id 关联 Promise),事件走 listeners Map(按 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() 里做笛卡尔积遍历:

javascript
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 上:

javascript
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 挂起:

javascript
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 的启动与端点发现

javascript
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

javascript
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()

javascript
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

逐步拆解每一步为什么存在:

  1. Emulation.setDeviceMetricsOverride —— 同时设置 width/height/screenWidth/screenHeight 并 deviceScaleFactor: 1、mobile 布尔值。mobile: true 会触发移动端视口元数据处理与触摸事件能力,是移动复验真实性的关键;deviceScaleFactor: 1 则保证输出像素与 CSS 像素 1:1,指标可直接对比。
  2. 构造 file:// URL 并注入 ?depth=complex —— pathToFileURL 正确处理本地路径到 file URL 的转义;depth=complex 是与页面 showcase.js 的约定参数(见下一节)。
  3. 先注册 Page.loadEventFired 再导航 —— client.once(...) 在 Page.navigate 之前调用,避免导航完成快于监听注册的竞态。这是事件驱动客户端的标准模式。
  4. document.fonts.ready + requestAnimationFrame 双重等待 —— document.fonts.ready 等 webfont 加载完成(否则截图可能用回退字体);new Promise(requestAnimationFrame) 再等一帧,确保字体生效后的重排已完成。awaitPromise: true 让 CDP 等待表达式返回的 Promise。
  5. 指标采集 —— 通过 Runtime.evaluate + returnByValue: true 拿回 JSON 字符串,包含 innerWidth/innerHeight/scrollWidth/scrollHeight 以及 .family-showcase 的 dataset.depth(回读验证深度参数确实生效)。
  6. 溢出判定 —— overflow = scrollWidth > innerWidth,只判横向;纵向滚动对这种展示页是预期行为,不作为失败条件。
  7. Page.captureScreenshot 参数 —— fromSurface: true 保证从合成后的表面取图;captureBeyondViewport: false 只截视口区域,与溢出判定基于的视口度量保持一致。
  8. 落盘与退出码 —— 移动 profile 写入 mobile/ 子目录;文件名固定为 {sample}-complex.png(体现深度后缀);溢出时不抛异常、不中断,仅置 process.exitCode = 1,让后续样本继续执行以获得完整诊断信息。

与页面运行时的 depth 参数协议闭环

样本页共享一份运行时脚本 assets/showcases/showcase.js,其入口逻辑:

javascript
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

以及初始化调用:

javascript
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()

javascript
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

Loading diagram...

时序要点:事件监听必须先于导航注册;字体等待必须先于指标采集与截图;溢出判定只影响退出码、不中断循环;所有清理集中在 finally。

Usage Examples

基本用法:桌面截图 + 溢出校验

README 中的质量工具链调用方式(作为技能自检流程的一部分):

bash
node "$CODEX_HOME/skills/ark-ui/scripts/capture-showcases.mjs" --mobile

Source: README.md

不传 --mobile 时仅执行桌面 profile(1440×900),产出 assets/showcases/screenshots/{sample}-complex.png 五张图。

运行时输出的指标行格式

capture() 末尾的日志行示例(格式由 padEnd 对齐):

text
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= 的链接都能直接进入对应深度,这正是截图脚本依赖的入口:

html
<a href="assets/showcases/corporate.html?depth=maximal">maximal 深度直达</a>

在浏览器中打开该链接时,setDepth(requestedDepth || ...) 会立即应用 maximal 并同步按钮的 aria-pressed 状态。截图脚本使用的是 complex 档({sample}-complex.png 命名即来源于此)。

Configuration Options

选项类型默认值说明
--mobileCLI 标志不启用追加 390×844(mobile: true)的移动 profile;桌面 profile 始终执行
ARK_UI_EDGE_BIN环境变量/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft EdgeEdge 二进制绝对路径;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:// 端点

相关源码依据:

javascript
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 三方法),可直接复制到其它截图/调试脚本中复用,无外部依赖。

Sources

(2 files)
assets/showcases