用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/AxGord/claude-workflow --skill browser-verify命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
Debugging meta-patterns — what to do when fixes don't stick
Game dev precision and physics gotchas
Claude Code configuration gotchas — permission rule syntax and evaluation order, settings hierarchy, plugin install scopes, hook behavior
基于 SOC 职业分类
正在显示 SKILL.md
| name | browser-verify |
| description | Browser-automation verification gotchas — stale captures, caches, headless limits |
Traps when verifying web/canvas apps through Playwright or a browser MCP. Common theme: the automated browser is not the user's browser — its pixels, caches, and performance all lie in specific, repeatable ways.
canvas.drawImage() can return stale frames for a WebGL canvasAnimated content driven by per-frame GPU updates (custom shader uniforms, skeletal animations, shader-displaced vertices) may show zero pixel diff between headless screenshots taken 100s of ms apart, even when JS-side instrumentation confirms the state is updating each frame (RAF firing, uniform buffers advancing, ticker handlers incrementing). Sampling the canvas through tmp.getContext('2d').drawImage(canvas, ...) returns the same byte-for-byte image across samples — the whole canvas is affected, not just one animation.
Likely cause: with preserveDrawingBuffer: false (the default in most WebGL frameworks, incl. Pixi), the headless compositor reads from a cached present frame rather than the live WebGL back buffer.
The same failure occurs HEADED whenever the target tab/window is occluded or was created behind another tab: page.screenshot() returns the last frame the browser's compositor ever presented for that tab. Observed: the screenshot showed a boot preloader frozen at "96%" while JS-side numeric sampling in the SAME page proved the app was live (object positions advancing every 100 ms, game logs firing). DOM inspection confirmed the preloader element was long gone — the "96%" pixels existed only in the stale compositor frame.
A second page created with ctx.newPage() and driven while another tab holds focus reproduces this; a page brought to front BEFORE its WebGL content started presenting captures real frames fine.
ctx.newPage() for a background capture tab, drive it, screenshot it while another tab stays focused — canvas pixels freeze at whatever was on screen when it lost focus (or never had it).ctx.pages()[0]) rather than stacking newPage()s for capture work.page.bringToFront() immediately after goto, BEFORE the canvas first presents — not right before the screenshot.The Playwright MCP browser keeps a persistent profile + HTTP cache between conversations (~/Library/Caches/ms-playwright-mcp/... on macOS). A plain browser_navigate does NOT clear it, so when verifying app behavior against a dev server (e.g. Vite), the MCP browser can run stale js modules (a previous session's app-logic module) while the server already serves current code. The same seed/input then produces a DIFFERENT outcome in the MCP browser vs the user's real browser — and you'll confidently report the wrong result.
logic.ts:103, user's DevTools shows :297) is NOT a version mismatch — playwright reports the transformed-module line, DevTools the sourcemapped source line. Same code.import() can be cached independently of the static import the live app uses, so the two disagree.browser_close then re-navigate relaunches a fresh browser instance — clears in-memory state and stale module instances, often enough against a dev server — but the default persistent mode reuses the same on-disk profile, so the HTTP disk cache/cookies survive. Guaranteed clean: run the MCP server with --isolated, point --user-data-dir at a throwaway dir, or delete the profile dir (~/Library/Caches/ms-playwright-mcp/mcp-<channel>-<hash> on macOS).tsc/vitest run via node against DISK code, so they stay valid for code correctness; only the BROWSER render/logic can be cache-stale. Re-verify any live finding in a freshly-cleared browser before claiming it.When optimizing a WebGL scene for mobile/Safari GPU cost (resolution cap, MSAA, blend-mode overdraw, fill-rate), driving it with Playwright headless WebKit (or Chromium) on a desktop gives a flat ~60 fps regardless of the renderer settings — the desktop GPU absorbs pixel counts a phone can't. Observed: identical frame-time distribution (mean ~16.7 ms, p50 17, p99 18, 0 frames >33 ms) across BOTH resolution=2 + MSAA on AND resolution=1.5 + MSAA off, in the same scene. A headless FPS A/B between renderer configs shows ~0 delta by construction and tells you nothing about the on-device win.
resolution 2.0→1.5 ⇒ (1.5/2)² = 0.5625 ⇒ ~44% fewer fragment-shader invocations/frame), plus draw-call / additive-blend (overdraw) counting.canvas.width / parseFloat(canvas.style.width) and expect the cap (1.5), not the raw devicePixelRatio (3) — and assert gating/visibility logic toggles via state, not via a screenshot pixel-diff.browser.newContext({ ...devices['iPhone 13'] }) gives iPhone UA + touch + DPR 3 + isMobile — but the ENGINE stays whatever you launched (the descriptor's defaultBrowserType: 'webkit' is honored only by the Playwright Test runner); launch webkit yourself to actually test WebKit.browser_evaluate returning a long-pending Promise blocks the MCP call until its idle timeout — poll with short sync evaluatesA browser_evaluate whose function returns a Promise resolves only when that Promise settles. If the resolve condition never fires (app state never reaches it), the MCP tool call hangs until the server's idle timeout aborts it — observed 1800 s (30 min) lost on one call. The page keeps running fine; only your session is stuck, and you cannot cancel from your side.
() => new Promise(res => { const check = () => { if (window.__done) res(...); else setTimeout(check, 300); }; check(); }) as the wait mechanism for app progress.window), return immediately, then poll with SHORT synchronous evaluates (() => ({ done: window.__done, n: window.__trace.length })) between shell-side waits. Each poll returns in milliseconds regardless of app state.typeof window.__probe, Object.keys(...)) BEFORE calling it inside a hook.setTimeout resolve, or a condition guaranteed by already-observed state) are fine — the rule is: never make an MCP evaluate's completion depend on app behavior you haven't yet confirmed.A sub-200ms solid-color flash during page load (e.g. an unpainted WebGL canvas compositing black, then the renderer's clear color, before the app's branded overlay mounts) is routinely MISSED by a page.screenshot() polling loop: each screenshot call has round-trip latency and forces a re-composite, yielding only ~2-5 captures/sec at unpredictable phases — the flash falls in the gaps.
page.screenshot() in a tight loop around page load and hope one capture lands on the flash frame.const cdp = await ctx.newCDPSession(page);
cdp.on('Page.screencastFrame', ev => { frames.push({ts: ev.metadata.timestamp, data: ev.data}); cdp.send('Page.screencastFrameAck', {sessionId: ev.sessionId}); });
await cdp.send('Page.startScreencast', {format: 'png', everyNthFrame: 1});
await page.goto(url, {waitUntil: 'commit'}); // start screencast BEFORE goto
Must ack every frame (Page.screencastFrameAck) or delivery stalls. Start the screencast BEFORE goto so the first paint is captured.skyblue clear color), which immediately identifies WHICH constant in code painted it.#000), and the canvas itself (composites BLACK between DOM insertion and its first render, then the renderer's clear color until the branded overlay mounts). Fixing one layer just exposes the next: enumerate every compositing layer top-down and re-capture after each fix — a single re-run "looks better" is not proof the flash chain is gone.A single test viewport can make the reported bug geometrically impossible. Observed: a camera "pull-back" re-anchor required cameraX < 0, which only happens on WIDE windows — at 1440×900 the camera clamped at its left world bound, the ball lost its screen anchor, and it jumped −209 px in ONE frame plus multi-frame drift; at the 500×700 repro viewport the camera had 21 px of slack, so the clamp never engaged and the bug could not occur. Four wrong fixes shipped before anyone resized the window.
browser_run_code_unsafe runs in the Playwright SERVER sandbox — no setTimeout, and a mid-script throw still leaves earlier side effects appliedThe snippet passed to browser_run_code_unsafe executes in the Playwright server process, not the page — timer globals aren't defined there, so await new Promise(r => setTimeout(r, ms)) throws ReferenceError: setTimeout is not defined. Use await page.waitForTimeout(ms) for delays; setTimeout inside page.evaluate(...) is fine since that runs in-page.
await BEFORE the crash already ran — dispatched events, clicks, and state changes persist in the page. A conditional early break/branch can let execution reach further than "the first timer call" before crashing. If the page later shows "impossible" state, reconstruct exactly which statements executed before the throw instead of assuming the whole script was a no-op.Vite pre-bundles linked workspace deps (@scope/* monorepo packages) into
node_modules/.vite. A dev server left running across a large edit session
can keep serving the OLD prebundle of an edited workspace package while the
app's own files are fresh — a mixed module graph. The symptom is NOT a crash:
the app runs, state machines advance, but behavior computed by the stale
package is silently wrong (observed: animations completing instantly — an
object teleporting to its end state for one frame — with a clean console,
which perfectly mimics an application bug in the new code).
rm -rf node_modules/.vite, restart with
--force, hard-navigate a fresh tab — THEN re-test; only debug the code if
the symptom survives a provably fresh serve.browser_close + navigate does NOT help — the staleness is
server-side, not browser-cache-side (compare gotcha 3, which this can
compound with).Rendering-framework-specific verification recipes (freezing a scene's tickers for a deterministic screenshot, sampling motion on the render ticker instead of your own rAF) live in the domain skills — e.g. domain-pixi for Pixi.js.