Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save tobiashochguertel/bf7bc60f8946c7ecbe0d14782b4a8c99 to your computer and use it in GitHub Desktop.

Select an option

Save tobiashochguertel/bf7bc60f8946c7ecbe0d14782b4a8c99 to your computer and use it in GitHub Desktop.
Reusable Playwright visibility debug utility with CLI screenshots and occlusion diagnostics
# playwright-visibility-debug
Playwright visibility diagnostics CLI
Bun-first TypeScript CLI for debugging UI visibility with:
• DOM presence, viewport, style, and occlusion analysis
• Multi-match selector handling with best-candidate selection
• JSON reports, screenshots, presets, and Taskfile integration
Install: curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/bf7bc60f8946c7ecbe0d14782b4a8c99/raw/install.sh | bash
node_modules/
dist/
.idea/
.vscode/
.DS_Store
coverage/
playwright-visibility-debug-output/
artifacts/
bun.lockb

Playwright Visibility Debug

playwright-visibility-debug is a small standalone debugging utility for Playwright-based UI investigations. It is built around one question:

Is this element actually visible and usable in the browser viewport, and if not, what is blocking it?

It goes beyond locator.isVisible() and reports on:

  • DOM presence and match count
  • multi-match selector handling
  • viewport intersection
  • computed styles such as display, visibility, opacity, and pointer-events
  • clipping by overflow ancestors
  • overlap/occlusion sampled via document.elementFromPoint()
  • before/after screenshots and a JSON report

Runtime model

  • Bun-first CLI workflow: the source CLI is designed to run comfortably with Bun and Crust.
  • Node-friendly build/test flow: the project also ships a normal TypeScript build and Vitest setup, and the emitted dist/cli.js is meant to stay runnable with Node where possible.

Install

One-shot installer

curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/bf7bc60f8946c7ecbe0d14782b4a8c99/raw/install.sh | bash

By default this installs into:

  • repo clone: ~/.taskfiles/taskscripts/playwright-visibility-debug
  • command shims: ~/.local/bin/playwright-visibility-debug and ~/.local/bin/pw-visibility-debug
  • deployed Taskfile: ~/.taskfiles/taskscripts/Taskfile.playwright-visibility-debug.yml
  • orchestrator include: ~/.taskfiles/Taskfile.taskscripts.yml

Local dev clone

mkdir -p ~/gists
git clone https://gist.github.com/bf7bc60f8946c7ecbe0d14782b4a8c99.git ~/gists/playwright-visibility-debug-gist
cd ~/gists/playwright-visibility-debug-gist
git remote set-url origin git@gist.github.com:bf7bc60f8946c7ecbe0d14782b4a8c99.git
bun install

CLI

Inspect an element

playwright-visibility-debug inspect \
  --url http://localhost:53940/?q=documentation \
  --selector '[aria-label="Theme mode"]' \
  --device-preset iphone-11-custom \
  --click '[aria-label="Open filters"]' \
  --wait 250

Useful flags:

  • --url required target URL
  • --selector required selector to inspect
  • --label optional report label
  • --click repeatable action selector
  • --wait wait in milliseconds after navigation/actions
  • --device-preset built-in preset name
  • --browser chromium|firefox|webkit
  • --no-headless run headed
  • --no-full-page disable full-page screenshots
  • --json print the raw report to stdout

Device presets

playwright-visibility-debug presets list
playwright-visibility-debug presets show iphone-11-custom

Environment check

playwright-visibility-debug doctor

Built-in preset

iphone-11-custom

  • viewport: 414x896
  • DPR: 2
  • mobile Safari-style user agent
  • mobile/touch context enabled

This matches the custom iPhone 11 emulation profile used during diagnosis of the mobile theme-control issue in the parent project.

Gist-compatible layout

GitHub Gists do not support nested directories, so the TypeScript source and Vitest files live at the gist root:

  • cli.ts
  • diagnostics.ts
  • format.ts
  • index.ts
  • logger.ts
  • presets.ts
  • schemas.ts
  • types.ts
  • version.ts
  • format.test.ts
  • presets.test.ts

Development

bun install
bun run typecheck
bun run build
bun run test
bun run cli.ts doctor
node dist/cli.js doctor

Taskfile include

This gist ships Taskfile.playwright-visibility-debug.yml. If you keep personal task includes, add something like this to your shared Taskfile registry:

includes:
  playwright-visibility-debug:
    taskfile: ~/.taskfiles/taskscripts/playwright-visibility-debug/Taskfile.playwright-visibility-debug.yml
    optional: true

Output artifacts

The inspect command writes:

  1. before-actions.png
  2. after-actions.png
  3. <label-or-selector>.json

Default output directory:

./playwright-visibility-debug-output

AGENTS

Intent

This gist is a standalone debugging utility for Playwright-driven UI diagnosis. Preserve the core goal: answer why an element is or is not effectively visible in the browser viewport.

Guardrails

  1. Keep the multi-match selector behavior. When a selector matches multiple nodes, inspect several candidates and prefer the best visible one instead of always reporting the first match.
  2. Keep the CLI honest about runtime support. The workflow is Bun-first because Crust fits that model best, but Node-compatible build/test paths should keep working when possible.
  3. Preserve artifact output:
    • before-actions screenshot
    • after-actions screenshot
    • JSON report
  4. Prefer additive diagnostics over vague summaries. If a visibility regression is caused by clipping, occlusion, or display:none, the report should say that explicitly.
  5. Keep the public CLI stable:
    • inspect
    • presets list
    • presets show
    • doctor

Validation expectations

Run these before pushing:

bun install
bun run typecheck
bun run build
bun run test
bun run cli.ts doctor
node dist/cli.js doctor
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "playwright-visibility-debug",
"dependencies": {
"@crustjs/core": "0.0.16",
"@crustjs/plugins": "0.0.22",
"playwright": "1.59.1",
"tslog": "4.10.2",
"zod": "4.3.6",
},
"devDependencies": {
"@types/node": "24.10.0",
"typescript": "6.0.3",
"vite": "8.0.10",
"vitest": "4.1.5",
},
},
},
"packages": {
"@crustjs/core": ["@crustjs/core@0.0.16", "https://npm.registry.hochguertel.work/@crustjs/core/-/core-0.0.16.tgz", { "peerDependencies": { "typescript": "^6.0.2" } }, "sha512-2yybZnmBjdNH9XisCtDAWUVuBQSobcrdvhVcW8O4GumCF1oM6FHsBVq+gsxOM5PemEqtdpEo78h0rzI7clVcAA=="],
"@crustjs/plugins": ["@crustjs/plugins@0.0.22", "https://npm.registry.hochguertel.work/@crustjs/plugins/-/plugins-0.0.22.tgz", { "dependencies": { "@crustjs/style": "0.1.0" }, "peerDependencies": { "@crustjs/core": "0.0.16", "typescript": "^6.0.3" } }, "sha512-aWgC1cvS9DeCT7Ynv/oahUZFdB7mQwVGVl37GMHU76zENjI8lVj9d92jsHgBY+fdltAa9OWEte5vyLD13SlmEg=="],
"@crustjs/style": ["@crustjs/style@0.1.0", "https://npm.registry.hochguertel.work/@crustjs/style/-/style-0.1.0.tgz", { "peerDependencies": { "typescript": "^6.0.3" } }, "sha512-nIg+YWiwnpNE8Va/B6LnuJcbsisBr/EpEcP/xaxtn/PsuMTHkKUkalR/OocLAwqzKl2IM2Y0m26lIwGoFJNaTg=="],
"@emnapi/core": ["@emnapi/core@1.10.0", "https://npm.registry.hochguertel.work/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "https://npm.registry.hochguertel.work/@emnapi/runtime/-/runtime-1.10.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "https://npm.registry.hochguertel.work/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://npm.registry.hochguertel.work/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "https://npm.registry.hochguertel.work/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@oxc-project/types": ["@oxc-project/types@0.127.0", "https://npm.registry.hochguertel.work/@oxc-project/types/-/types-0.127.0.tgz", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", { "os": "linux", "cpu": "arm" }, "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", { "os": "linux", "cpu": "x64" }, "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", { "os": "linux", "cpu": "x64" }, "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", { "os": "none", "cpu": "arm64" }, "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", { "os": "win32", "cpu": "x64" }, "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "https://npm.registry.hochguertel.work/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://npm.registry.hochguertel.work/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://npm.registry.hochguertel.work/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/chai": ["@types/chai@5.2.3", "https://npm.registry.hochguertel.work/@types/chai/-/chai-5.2.3.tgz", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "https://npm.registry.hochguertel.work/@types/deep-eql/-/deep-eql-4.0.2.tgz", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "https://npm.registry.hochguertel.work/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@24.10.0", "https://npm.registry.hochguertel.work/@types/node/-/node-24.10.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
"@vitest/expect": ["@vitest/expect@4.1.5", "https://npm.registry.hochguertel.work/@vitest/expect/-/expect-4.1.5.tgz", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="],
"@vitest/mocker": ["@vitest/mocker@4.1.5", "https://npm.registry.hochguertel.work/@vitest/mocker/-/mocker-4.1.5.tgz", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "https://npm.registry.hochguertel.work/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="],
"@vitest/runner": ["@vitest/runner@4.1.5", "https://npm.registry.hochguertel.work/@vitest/runner/-/runner-4.1.5.tgz", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.5", "https://npm.registry.hochguertel.work/@vitest/snapshot/-/snapshot-4.1.5.tgz", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="],
"@vitest/spy": ["@vitest/spy@4.1.5", "https://npm.registry.hochguertel.work/@vitest/spy/-/spy-4.1.5.tgz", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="],
"@vitest/utils": ["@vitest/utils@4.1.5", "https://npm.registry.hochguertel.work/@vitest/utils/-/utils-4.1.5.tgz", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="],
"assertion-error": ["assertion-error@2.0.1", "https://npm.registry.hochguertel.work/assertion-error/-/assertion-error-2.0.1.tgz", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"chai": ["chai@6.2.2", "https://npm.registry.hochguertel.work/chai/-/chai-6.2.2.tgz", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"convert-source-map": ["convert-source-map@2.0.0", "https://npm.registry.hochguertel.work/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"detect-libc": ["detect-libc@2.1.2", "https://npm.registry.hochguertel.work/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"es-module-lexer": ["es-module-lexer@2.1.0", "https://npm.registry.hochguertel.work/es-module-lexer/-/es-module-lexer-2.1.0.tgz", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
"estree-walker": ["estree-walker@3.0.3", "https://npm.registry.hochguertel.work/estree-walker/-/estree-walker-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"expect-type": ["expect-type@1.3.0", "https://npm.registry.hochguertel.work/expect-type/-/expect-type-1.3.0.tgz", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fdir": ["fdir@6.5.0", "https://npm.registry.hochguertel.work/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "https://npm.registry.hochguertel.work/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"lightningcss": ["lightningcss@1.32.0", "https://npm.registry.hochguertel.work/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://npm.registry.hochguertel.work/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "https://npm.registry.hochguertel.work/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "https://npm.registry.hochguertel.work/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "https://npm.registry.hochguertel.work/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "https://npm.registry.hochguertel.work/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "https://npm.registry.hochguertel.work/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "https://npm.registry.hochguertel.work/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "https://npm.registry.hochguertel.work/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "https://npm.registry.hochguertel.work/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "https://npm.registry.hochguertel.work/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://npm.registry.hochguertel.work/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"magic-string": ["magic-string@0.30.21", "https://npm.registry.hochguertel.work/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"nanoid": ["nanoid@3.3.11", "https://npm.registry.hochguertel.work/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"obug": ["obug@2.1.1", "https://npm.registry.hochguertel.work/obug/-/obug-2.1.1.tgz", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"pathe": ["pathe@2.0.3", "https://npm.registry.hochguertel.work/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "https://npm.registry.hochguertel.work/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "https://npm.registry.hochguertel.work/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"playwright": ["playwright@1.59.1", "https://npm.registry.hochguertel.work/playwright/-/playwright-1.59.1.tgz", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
"playwright-core": ["playwright-core@1.59.1", "https://npm.registry.hochguertel.work/playwright-core/-/playwright-core-1.59.1.tgz", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
"postcss": ["postcss@8.5.12", "https://npm.registry.hochguertel.work/postcss/-/postcss-8.5.12.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="],
"rolldown": ["rolldown@1.0.0-rc.17", "https://npm.registry.hochguertel.work/rolldown/-/rolldown-1.0.0-rc.17.tgz", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="],
"siginfo": ["siginfo@2.0.0", "https://npm.registry.hochguertel.work/siginfo/-/siginfo-2.0.0.tgz", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"source-map-js": ["source-map-js@1.2.1", "https://npm.registry.hochguertel.work/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["stackback@0.0.2", "https://npm.registry.hochguertel.work/stackback/-/stackback-0.0.2.tgz", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@4.1.0", "https://npm.registry.hochguertel.work/std-env/-/std-env-4.1.0.tgz", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
"tinybench": ["tinybench@2.9.0", "https://npm.registry.hochguertel.work/tinybench/-/tinybench-2.9.0.tgz", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.1.1", "https://npm.registry.hochguertel.work/tinyexec/-/tinyexec-1.1.1.tgz", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"tinyglobby": ["tinyglobby@0.2.16", "https://npm.registry.hochguertel.work/tinyglobby/-/tinyglobby-0.2.16.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "https://npm.registry.hochguertel.work/tinyrainbow/-/tinyrainbow-3.1.0.tgz", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tslib": ["tslib@2.8.1", "https://npm.registry.hochguertel.work/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tslog": ["tslog@4.10.2", "https://npm.registry.hochguertel.work/tslog/-/tslog-4.10.2.tgz", {}, "sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g=="],
"typescript": ["typescript@6.0.3", "https://npm.registry.hochguertel.work/typescript/-/typescript-6.0.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.16.0", "https://npm.registry.hochguertel.work/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"vite": ["vite@8.0.10", "https://npm.registry.hochguertel.work/vite/-/vite-8.0.10.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="],
"vitest": ["vitest@4.1.5", "https://npm.registry.hochguertel.work/vitest/-/vitest-4.1.5.tgz", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "https://npm.registry.hochguertel.work/why-is-node-running/-/why-is-node-running-2.3.0.tgz", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"zod": ["zod@4.3.6", "https://npm.registry.hochguertel.work/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"playwright/fsevents": ["fsevents@2.3.2", "https://npm.registry.hochguertel.work/fsevents/-/fsevents-2.3.2.tgz", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
}
}

Changelog

[0.2.1]

Fixed

  • Corrected the Bun installer invocation so install.sh no longer fails with Script not found "install".
  • Made install.sh deploy Taskfile.playwright-visibility-debug.yml into ~/.taskfiles/taskscripts/ and auto-register the gist in ~/.taskfiles/Taskfile.taskscripts.yml.
  • Added installation verification by running the installed CLI and checking the generated Taskfile wiring when task is available.
  • Reworked #playwright-visibility-debug-GistSummary to follow the Gist guide's 10-line discovery format.

[0.2.0]

Added

  • Refactored the gist into a standalone TypeScript package with src/, tests/, package.json, tsconfig.json, and vite.config.ts.
  • Added a Crust-based CLI with inspect, presets, and doctor commands.
  • Added zod v4 validation for CLI options and device presets.
  • Added tslog-backed structured logging.
  • Added an installer script, taskfile, Gist summary file, and AGENTS.md.
  • Added Vitest coverage for preset resolution, candidate selection, and summary formatting.

Changed

  • Moved the original index.mjs / run.mjs logic into reusable TypeScript modules.
  • Kept the multi-match visibility selection behavior while making the CLI more modular and scriptable.
  • Flattened the TypeScript/test file layout to the gist root because GitHub Gists reject nested directories.

[0.1.1]

Fixed

  • Multi-match selectors now inspect several candidates and automatically choose the best visible one instead of always reporting only the first match.

[0.1.0]

Added

  • Initial reusable Playwright visibility-debug module.
  • Element diagnostics for DOM presence, computed styles, viewport bounds, clipping ancestors, and occlusion sample points.
  • CLI runner with screenshots and a built-in iphone-11-custom device preset.
#!/usr/bin/env node
import path from 'node:path'
import { Crust } from '@crustjs/core'
import { helpPlugin, versionPlugin } from '@crustjs/plugins'
import { formatVisibilitySummary } from './format.js'
import { createLogger } from './logger.js'
import { getDevicePreset, listDevicePresets } from './presets.js'
import { runInspectScenario } from './diagnostics.js'
import { inspectCommandOptionsSchema } from './schemas.js'
import { VERSION } from './version.js'
function getBunVersion(): string | null {
const runtime = globalThis as typeof globalThis & {
Bun?: {
version: string
}
}
return runtime.Bun?.version ?? null
}
function defaultOutputDir(): string {
return path.resolve(process.cwd(), 'playwright-visibility-debug-output')
}
function printJson(value: unknown): void {
console.log(JSON.stringify(value, null, 2))
}
const app = new Crust('playwright-visibility-debug')
.meta({
description:
'Diagnose whether a Playwright target is truly visible by checking DOM presence, styles, clipping, and occlusion.',
})
.flags({
verbose: {
type: 'boolean',
short: 'v',
inherit: true,
description: 'Enable verbose diagnostic logging',
},
logLevel: {
type: 'string',
default: 'info',
inherit: true,
description: 'Log level: debug, info, warn, or error',
},
})
.use(versionPlugin(VERSION))
.use(helpPlugin())
.command('inspect', (cmd) =>
cmd
.meta({
description: 'Open a page, optionally perform clicks, and write a visibility report for a selector.',
})
.flags({
url: {
type: 'string',
required: true,
description: 'Target page URL',
},
selector: {
type: 'string',
required: true,
description: 'Selector to inspect',
},
label: {
type: 'string',
description: 'Optional label used in output and report naming',
},
outputDir: {
type: 'string',
default: defaultOutputDir(),
description: 'Directory used for screenshots and JSON reports',
},
click: {
type: 'string',
multiple: true,
default: [],
description: 'Selector to click before inspection; pass multiple times for sequences',
},
wait: {
type: 'number',
default: 0,
description: 'Milliseconds to wait after navigation and click actions',
},
devicePreset: {
type: 'string',
description: 'Built-in device preset name',
},
browser: {
type: 'string',
default: 'chromium',
description: 'Browser engine: chromium, firefox, or webkit',
},
headless: {
type: 'boolean',
default: true,
description: 'Run the browser headlessly; use --no-headless to see the UI',
},
fullPage: {
type: 'boolean',
default: true,
description: 'Capture full-page screenshots; use --no-full-page to limit to viewport',
},
json: {
type: 'boolean',
default: false,
description: 'Print the raw JSON report to stdout',
},
})
.run(async ({ flags }) => {
const options = inspectCommandOptionsSchema.parse({
url: flags.url,
selector: flags.selector,
label: flags.label,
outputDir: flags.outputDir,
click: flags.click,
wait: flags.wait,
devicePreset: flags.devicePreset,
browser: flags.browser,
headless: flags.headless,
fullPage: flags.fullPage,
json: flags.json,
})
if (options.devicePreset) {
getDevicePreset(options.devicePreset)
}
const logger = createLogger({
verbose: flags.verbose,
logLevel: flags.logLevel,
})
const result = await runInspectScenario(options, logger)
if (options.json) {
printJson(result.report)
} else {
console.log(formatVisibilitySummary(result.report))
}
console.log(`reportPath: ${result.reportPath}`)
console.log(`beforeScreenshot: ${result.beforeScreenshotPath}`)
console.log(`afterScreenshot: ${result.afterScreenshotPath}`)
}),
)
.command('presets', (cmd) =>
cmd
.meta({
description: 'Inspect the built-in device presets.',
})
.command('list', (sub) =>
sub
.meta({ description: 'List all bundled device presets.' })
.flags({
json: {
type: 'boolean',
default: false,
description: 'Print the presets as JSON',
},
})
.run(({ flags }) => {
const presets = listDevicePresets().map((preset) => ({
name: preset.name,
description: preset.description,
viewport: preset.context.viewport,
deviceScaleFactor: preset.context.deviceScaleFactor,
}))
if (flags.json) {
printJson(presets)
return
}
for (const preset of presets) {
console.log(
`${preset.name}\t${preset.viewport?.width}x${preset.viewport?.height}\tDPR ${preset.deviceScaleFactor}\t${preset.description}`,
)
}
}),
)
.command('show', (sub) =>
sub
.meta({ description: 'Show one built-in device preset.' })
.args([{ name: 'name', type: 'string', required: true }])
.flags({
json: {
type: 'boolean',
default: false,
description: 'Print the preset as JSON',
},
})
.run(({ args, flags }) => {
const preset = getDevicePreset(args.name)
if (!preset) {
throw new Error(`No preset found for "${args.name}".`)
}
if (flags.json) {
printJson(preset)
return
}
console.log(`name: ${preset.name}`)
console.log(`description: ${preset.description}`)
printJson(preset.context)
}),
),
)
.command('doctor', (cmd) =>
cmd
.meta({
description: 'Print runtime and preset diagnostics for quick environment checks.',
})
.flags({
json: {
type: 'boolean',
default: false,
description: 'Print diagnostics as JSON',
},
})
.run(({ flags }) => {
const bunVersion = getBunVersion()
const doctor = {
version: VERSION,
runtime: bunVersion ? 'bun' : 'node',
nodeVersion: process.version,
bunVersion,
cwd: process.cwd(),
outputDir: defaultOutputDir(),
presets: listDevicePresets().map((preset) => preset.name),
}
if (flags.json) {
printJson(doctor)
return
}
console.log(`version: ${doctor.version}`)
console.log(`runtime: ${doctor.runtime}`)
console.log(`nodeVersion: ${doctor.nodeVersion}`)
if (doctor.bunVersion) {
console.log(`bunVersion: ${doctor.bunVersion}`)
}
console.log(`cwd: ${doctor.cwd}`)
console.log(`defaultOutputDir: ${doctor.outputDir}`)
console.log(`presets: ${doctor.presets.join(', ')}`)
}),
)
await app.execute()
import fs from 'node:fs/promises'
import path from 'node:path'
import {
chromium,
firefox,
webkit,
type BrowserContextOptions,
type BrowserType,
type Locator,
type Page,
} from 'playwright'
import { buildElementSummary, chooseBestCandidate, sanitizeFileStem } from './format.js'
import { browserEngineSchema, type BrowserEngine, type InspectCommandOptions } from './schemas.js'
import { getDevicePreset } from './presets.js'
import type { AppLogger, ElementDiagnostic, InspectRunResult, VisibilityReport } from './types.js'
const DEFAULT_ANCESTOR_LIMIT = 12
const DEFAULT_SAMPLE_INSET = 4
const DEFAULT_CANDIDATE_LIMIT = 5
const STYLE_KEYS = [
'display',
'visibility',
'opacity',
'position',
'zIndex',
'overflow',
'overflowX',
'overflowY',
'pointerEvents',
'transform',
'clip',
'clipPath',
'contentVisibility',
] as const
interface InspectHandleOptions {
ancestorLimit: number
sampleInset: number
}
interface DiagnoseOptions {
label?: string | undefined
ancestorLimit?: number
sampleInset?: number
candidateLimit?: number
}
type TargetLike = string | Locator
function normalizeTarget(page: Page, target: TargetLike) {
if (typeof target === 'string') {
return {
locator: page.locator(target),
selector: target,
label: target,
}
}
return {
locator: target,
selector: null,
label: 'locator',
}
}
function getBrowserType(engine: BrowserEngine): BrowserType {
const parsed = browserEngineSchema.parse(engine)
switch (parsed) {
case 'chromium':
return chromium
case 'firefox':
return firefox
case 'webkit':
return webkit
}
}
function buildContextOptions(options: InspectCommandOptions): BrowserContextOptions {
const preset = getDevicePreset(options.devicePreset)
if (!preset) return {}
const { viewport, screen, deviceScaleFactor, userAgent, isMobile, hasTouch } = preset.context
return {
viewport,
...(screen ? { screen } : {}),
deviceScaleFactor,
...(userAgent ? { userAgent } : {}),
isMobile,
hasTouch,
}
}
async function inspectHandle(
handle: Awaited<ReturnType<Locator['elementHandle']>>,
options: InspectHandleOptions,
): Promise<ElementDiagnostic> {
if (!handle) {
throw new Error('Failed to inspect element handle: no element handle was returned.')
}
const { ancestorLimit, sampleInset } = options
return handle.evaluate(
(element, input: { ancestorLimit: number; sampleInset: number; styleKeys: readonly string[] }) => {
const { ancestorLimit, sampleInset, styleKeys } = input
const rectToObject = (rect: DOMRect) => ({
x: rect.x,
y: rect.y,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
width: rect.width,
height: rect.height,
})
const pickStyle = (style: CSSStyleDeclaration) =>
Object.fromEntries(styleKeys.map((key) => [key, style[key as keyof CSSStyleDeclaration] as string]))
const describeNode = (node: Element | null) => {
if (!node) return null
const rect = node.getBoundingClientRect()
const style = getComputedStyle(node)
return {
tag: node.tagName,
id: node.id || null,
className: node.className || null,
text: (node.textContent || '').trim().slice(0, 200),
rect: rectToObject(rect),
style: pickStyle(style),
}
}
const targetRect = element.getBoundingClientRect()
const style = getComputedStyle(element)
const inViewport =
targetRect.bottom > 0 &&
targetRect.right > 0 &&
targetRect.top < window.innerHeight &&
targetRect.left < window.innerWidth
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
const safeLeft = clamp(targetRect.left + sampleInset, 0, window.innerWidth)
const safeRight = clamp(targetRect.right - sampleInset, 0, window.innerWidth)
const safeTop = clamp(targetRect.top + sampleInset, 0, window.innerHeight)
const safeBottom = clamp(targetRect.bottom - sampleInset, 0, window.innerHeight)
const centerX = clamp(targetRect.left + targetRect.width / 2, 0, window.innerWidth)
const centerY = clamp(targetRect.top + targetRect.height / 2, 0, window.innerHeight)
const samplePoints = [
{ name: 'center', x: centerX, y: centerY },
{ name: 'top-left', x: safeLeft, y: safeTop },
{ name: 'top-right', x: safeRight, y: safeTop },
{ name: 'bottom-left', x: safeLeft, y: safeBottom },
{ name: 'bottom-right', x: safeRight, y: safeBottom },
].map((point) => {
const sampleInViewport =
point.x >= 0 && point.y >= 0 && point.x <= window.innerWidth && point.y <= window.innerHeight
const hit = sampleInViewport ? document.elementFromPoint(point.x, point.y) : null
return {
...point,
inViewport: sampleInViewport,
hitTarget: Boolean(hit && (hit === element || element.contains(hit))),
hit: describeNode(hit),
}
})
const ancestors = []
const clippedBy = []
let current: Element | null = element
let remaining = ancestorLimit
while (current && remaining > 0) {
const rect = current.getBoundingClientRect()
const currentStyle = getComputedStyle(current)
const entry = {
tag: current.tagName,
id: current.id || null,
className: current.className || null,
rect: rectToObject(rect),
style: pickStyle(currentStyle),
}
ancestors.push(entry)
const clipsX = !['visible', 'unset'].includes(currentStyle.overflowX)
const clipsY = !['visible', 'unset'].includes(currentStyle.overflowY)
if (current !== element && (clipsX || clipsY)) {
const clipsTarget =
targetRect.left < rect.left ||
targetRect.right > rect.right ||
targetRect.top < rect.top ||
targetRect.bottom > rect.bottom
if (clipsTarget) {
clippedBy.push(entry)
}
}
current = current.parentElement
remaining -= 1
}
const overlappingFixedOrSticky = Array.from(document.querySelectorAll('body *'))
.filter((candidate) => candidate !== element && !candidate.contains(element))
.map((candidate) => {
const candidateStyle = getComputedStyle(candidate)
if (!['fixed', 'sticky'].includes(candidateStyle.position)) return null
const rect = candidate.getBoundingClientRect()
const overlapWidth = Math.min(targetRect.right, rect.right) - Math.max(targetRect.left, rect.left)
const overlapHeight = Math.min(targetRect.bottom, rect.bottom) - Math.max(targetRect.top, rect.top)
if (overlapWidth <= 0 || overlapHeight <= 0) return null
const description = describeNode(candidate)
if (!description) return null
return {
...description,
overlapArea: overlapWidth * overlapHeight,
}
})
.filter((candidate): candidate is NonNullable<typeof candidate> => Boolean(candidate))
.sort((left, right) => right.overlapArea - left.overlapArea)
.slice(0, 10)
return {
outerHTML: element.outerHTML.slice(0, 800),
text: (element.textContent || '').trim().slice(0, 400),
rect: rectToObject(targetRect),
style: pickStyle(style),
inViewport,
attributes: Object.fromEntries(
Array.from(element.attributes).map((attribute) => [attribute.name, attribute.value]),
),
ancestors,
clippedBy,
samplePoints,
overlappingFixedOrSticky,
}
},
{
ancestorLimit,
sampleInset,
styleKeys: STYLE_KEYS,
},
)
}
export async function diagnoseElementVisibility(
page: Page,
target: TargetLike,
options: DiagnoseOptions = {},
): Promise<VisibilityReport> {
const ancestorLimit = options.ancestorLimit ?? DEFAULT_ANCESTOR_LIMIT
const sampleInset = options.sampleInset ?? DEFAULT_SAMPLE_INSET
const candidateLimit = options.candidateLimit ?? DEFAULT_CANDIDATE_LIMIT
const normalized = normalizeTarget(page, target)
const matchCount = await normalized.locator.count()
const pageContext = {
url: page.url(),
title: await page.title(),
...(await page.evaluate(() => ({
viewport: {
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
outerWidth: window.outerWidth,
outerHeight: window.outerHeight,
devicePixelRatio: window.devicePixelRatio,
scrollX: window.scrollX,
scrollY: window.scrollY,
},
}))),
}
if (matchCount === 0) {
return {
found: false,
selector: normalized.selector,
label: options.label ?? normalized.label,
page: pageContext,
matchCount,
candidateCount: 0,
chosenCandidateIndex: 0,
candidates: [],
element: null,
summary: buildElementSummary(null),
}
}
const candidates: ElementDiagnostic[] = []
for (let index = 0; index < Math.min(matchCount, candidateLimit); index += 1) {
const handle = await normalized.locator.nth(index).elementHandle({ timeout: 0 })
const candidate = await inspectHandle(handle, { ancestorLimit, sampleInset })
candidate.index = index
candidate.summary = buildElementSummary(candidate)
candidates.push(candidate)
}
const chosenCandidate = chooseBestCandidate(candidates)
return {
found: true,
selector: normalized.selector,
label: options.label ?? normalized.label,
page: pageContext,
matchCount,
candidateCount: candidates.length,
chosenCandidateIndex: chosenCandidate?.index ?? 0,
candidates,
element: chosenCandidate ?? null,
summary: buildElementSummary(chosenCandidate ?? null),
}
}
export async function writeVisibilityArtifacts(
report: VisibilityReport,
options: { outputDir?: string; basename?: string } = {},
): Promise<{ reportPath: string }> {
const outputDir = options.outputDir ?? process.cwd()
const stem = sanitizeFileStem(options.basename ?? report.label ?? report.selector ?? 'element')
await fs.mkdir(outputDir, { recursive: true })
const reportPath = path.join(outputDir, `${stem}.json`)
await fs.writeFile(reportPath, JSON.stringify(report, null, 2), 'utf8')
return { reportPath }
}
export async function runInspectScenario(
options: InspectCommandOptions,
logger?: AppLogger,
): Promise<InspectRunResult> {
const outputDir = path.resolve(options.outputDir)
await fs.mkdir(outputDir, { recursive: true })
const browser = await getBrowserType(options.browser).launch({ headless: options.headless })
const context = await browser.newContext(buildContextOptions(options))
const page = await context.newPage()
logger?.info('Opening page', { url: options.url, browser: options.browser, preset: options.devicePreset })
try {
await page.goto(options.url, { waitUntil: 'networkidle' })
if (options.wait > 0 && options.click.length === 0) {
await page.waitForTimeout(options.wait)
}
const beforeScreenshotPath = path.join(outputDir, 'before-actions.png')
await page.screenshot({ path: beforeScreenshotPath, fullPage: options.fullPage })
for (const selector of options.click) {
logger?.debug('Clicking selector', selector)
await page.locator(selector).first().click()
if (options.wait > 0) {
await page.waitForTimeout(options.wait)
}
}
const afterScreenshotPath = path.join(outputDir, 'after-actions.png')
await page.screenshot({ path: afterScreenshotPath, fullPage: options.fullPage })
const report = await diagnoseElementVisibility(page, options.selector, {
label: options.label,
})
const { reportPath } = await writeVisibilityArtifacts(report, {
outputDir,
basename: options.label ?? options.selector,
})
logger?.info('Inspection complete', { reportPath })
return {
report,
reportPath,
beforeScreenshotPath,
afterScreenshotPath,
}
} finally {
await context.close()
await browser.close()
}
}
import { describe, expect, test } from 'vitest'
import { buildElementSummary, chooseBestCandidate, formatVisibilitySummary } from './format.js'
import type { ElementDiagnostic, VisibilityReport } from './types.js'
function createCandidate(overrides: Partial<ElementDiagnostic> = {}): ElementDiagnostic {
return {
index: 0,
outerHTML: '<button>Theme mode</button>',
text: 'Theme mode',
rect: {
x: 0,
y: 0,
top: 0,
right: 40,
bottom: 40,
left: 0,
width: 40,
height: 40,
},
style: {
display: 'block',
visibility: 'visible',
opacity: '1',
pointerEvents: 'auto',
},
inViewport: true,
attributes: {},
ancestors: [],
clippedBy: [],
samplePoints: [
{
name: 'center',
x: 20,
y: 20,
inViewport: true,
hitTarget: true,
hit: null,
},
],
overlappingFixedOrSticky: [],
...overrides,
}
}
describe('chooseBestCandidate', () => {
test('prefers a visible candidate over the first match', () => {
const blocked = createCandidate({
index: 0,
inViewport: false,
summary: buildElementSummary(
createCandidate({
inViewport: false,
}),
),
})
const visible = createCandidate({
index: 1,
summary: buildElementSummary(createCandidate()),
})
expect(chooseBestCandidate([blocked, visible])?.index).toBe(1)
})
})
describe('buildElementSummary', () => {
test('returns attention reasons for hidden elements', () => {
const summary = buildElementSummary(
createCandidate({
style: {
display: 'none',
visibility: 'hidden',
opacity: '0',
pointerEvents: 'none',
},
inViewport: false,
rect: {
x: 0,
y: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
width: 0,
height: 0,
},
}),
)
expect(summary.status).toBe('attention')
expect(summary.reasons).toContain('display:none')
expect(summary.reasons).toContain('visibility:hidden')
expect(summary.reasons).toContain('opacity:0')
})
})
describe('formatVisibilitySummary', () => {
test('includes match-count context for multi-match reports', () => {
const candidate = createCandidate({ index: 1 })
candidate.summary = buildElementSummary(candidate)
const report: VisibilityReport = {
found: true,
selector: '[aria-label="Theme mode"]',
label: 'theme-mode',
page: {
url: 'http://localhost:53940/?q=documentation',
title: 'Explorer',
viewport: {
innerWidth: 414,
innerHeight: 896,
outerWidth: 414,
outerHeight: 896,
devicePixelRatio: 2,
scrollX: 0,
scrollY: 0,
},
},
matchCount: 2,
candidateCount: 2,
chosenCandidateIndex: 1,
candidates: [createCandidate({ index: 0 }), candidate],
element: candidate,
summary: candidate.summary,
}
expect(formatVisibilitySummary(report)).toContain('matches: 2, using candidate 2')
})
})
import type { ElementDiagnostic, ElementSummary, VisibilityReport } from './types.js'
export function sanitizeFileStem(value: string): string {
return (
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'element'
)
}
export function buildElementSummary(element: ElementDiagnostic | null): ElementSummary {
if (!element) {
return {
status: 'not-found',
headline: 'Element not found',
reasons: ['Element was not found in the DOM.'],
}
}
const reasons: string[] = []
if (element.style.display === 'none') reasons.push('display:none')
if (element.style.visibility !== 'visible') reasons.push(`visibility:${element.style.visibility}`)
if (Number.parseFloat(element.style.opacity || '1') === 0) reasons.push('opacity:0')
if (element.style.pointerEvents === 'none') reasons.push('pointer-events:none')
if (element.rect.width <= 0 || element.rect.height <= 0) reasons.push('zero-size bounding box')
if (!element.inViewport) reasons.push('outside viewport')
if (element.clippedBy.length > 0) {
reasons.push(`clipped by ${element.clippedBy.length} overflow ancestor(s)`)
}
const blockedPoints = element.samplePoints.filter((point) => point.inViewport && !point.hitTarget)
if (blockedPoints.length > 0) {
const uniqueBlockers = [...new Set(blockedPoints.map((point) => point.hit?.tag ?? 'unknown'))]
reasons.push(`occluded at ${blockedPoints.length} sample point(s) by ${uniqueBlockers.join(', ')}`)
}
const status = reasons.length === 0 ? 'visible' : 'attention'
return {
status,
headline: status === 'visible' ? 'Element appears visible' : 'Element needs attention',
reasons,
}
}
export function chooseBestCandidate(candidates: ElementDiagnostic[]): ElementDiagnostic | undefined {
return (
candidates.find((candidate) => candidate.summary?.status === 'visible') ??
candidates.find((candidate) => candidate.inViewport) ??
candidates[0]
)
}
export function formatVisibilitySummary(report: VisibilityReport): string {
const lines = [
`status: ${report.summary.status}`,
`headline: ${report.summary.headline}`,
`label: ${report.label || '(unnamed)'}`,
`url: ${report.page.url}`,
]
if (!report.found || !report.element) {
lines.push('reason: element not found')
return lines.join('\n')
}
if (report.matchCount > 1) {
lines.push(`matches: ${report.matchCount}, using candidate ${report.chosenCandidateIndex + 1}`)
}
lines.push(
`rect: ${report.element.rect.left},${report.element.rect.top} ${report.element.rect.width}x${report.element.rect.height}`,
)
lines.push(`inViewport: ${report.element.inViewport}`)
lines.push(
report.summary.reasons.length > 0 ? `reasons: ${report.summary.reasons.join('; ')}` : 'reasons: none',
)
return lines.join('\n')
}
export { buildElementSummary, chooseBestCandidate, formatVisibilitySummary, sanitizeFileStem } from './format.js'
export { diagnoseElementVisibility, runInspectScenario, writeVisibilityArtifacts } from './diagnostics.js'
export { DEVICE_PRESETS, getDevicePreset, listDevicePresets } from './presets.js'
export { createLogger } from './logger.js'
export { VERSION } from './version.js'
export type {
AppLogger,
DevicePreset,
ElementDiagnostic,
ElementSummary,
InspectRunResult,
NodeSnapshot,
RectSnapshot,
SamplePoint,
VisibilityReport,
} from './types.js'
export type { BrowserEngine, InspectCommandOptions, LogLevel } from './schemas.js'
#!/usr/bin/env bash
# install.sh — install playwright-visibility-debug from GitHub Gist
#
# One-liner install:
# curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/bf7bc60f8946c7ecbe0d14782b4a8c99/raw/install.sh | bash
#
# What it does:
# 1. Clones or updates the gist repo in ~/.taskfiles/taskscripts/playwright-visibility-debug/
# 2. Installs dependencies with Bun (preferred) or Node + npm
# 3. Installs Playwright Chromium for the CLI smoke path
# 4. Deploys Taskfile.playwright-visibility-debug.yml into ~/.taskfiles/taskscripts/
# 5. Creates or updates ~/.taskfiles/Taskfile.taskscripts.yml
# 6. Installs command shims in ~/.local/bin/
# 7. Verifies the installed CLI with `doctor`
set -euo pipefail
GIST_ID="bf7bc60f8946c7ecbe0d14782b4a8c99"
GIST_URL="https://gist.github.com/${GIST_ID}.git"
TOOL_NAME="playwright-visibility-debug"
TASKFILE_NAME="Taskfile.playwright-visibility-debug.yml"
INSTALL_ROOT="${PLAYWRIGHT_VISIBILITY_DEBUG_HOME:-$HOME/.taskfiles/taskscripts/${TOOL_NAME}}"
TASKFILES_DIR="${HOME}/.taskfiles"
TASKSCRIPTS_DIR="${TASKFILES_DIR}/taskscripts"
ORCHESTRATOR="${TASKFILES_DIR}/Taskfile.taskscripts.yml"
BIN_DIR="${PLAYWRIGHT_VISIBILITY_DEBUG_BIN:-$HOME/.local/bin}"
SHIM_PATH="${BIN_DIR}/playwright-visibility-debug"
SHORT_SHIM_PATH="${BIN_DIR}/pw-visibility-debug"
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
dim() { printf '\033[2m%s\033[0m\n' "$*"; }
err() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; }
deploy_taskfile() {
local src="${INSTALL_ROOT}/${TASKFILE_NAME}"
local dest="${TASKSCRIPTS_DIR}/${TASKFILE_NAME}"
bold "📋 Deploying ${TASKFILE_NAME} ..."
if [ -f "${dest}" ]; then
local ts backup
ts="$(date +%Y%m%d_%H%M%S)"
backup="${TASKSCRIPTS_DIR}/${TASKFILE_NAME%.yml}.backup-${ts}.yml"
cp "${dest}" "${backup}"
dim " 📦 Backed up existing file → ${backup}"
fi
cp "${src}" "${dest}"
dim " ✔ ${dest}"
}
add_to_orchestrator() {
if [ ! -f "${ORCHESTRATOR}" ]; then
bold "📝 Creating ~/.taskfiles/Taskfile.taskscripts.yml ..."
cat >"${ORCHESTRATOR}" <<'ORCH_EOF'
# yaml-language-server: $schema=https://taskfile.dev/schema.json
# Taskfile.taskscripts.yml — global taskscripts orchestrator
#
# Location: ~/.taskfiles/Taskfile.taskscripts.yml
# Purpose: Groups all per-tool Taskfiles from ~/.taskfiles/taskscripts/
# under a single include so the root Taskfile can flatten them.
#
version: "3"
includes:
# ── playwright-visibility-debug — Playwright visibility diagnostics CLI ─────
# Exposes: playwright-visibility-debug:install playwright-visibility-debug:build
# playwright-visibility-debug:test playwright-visibility-debug:doctor
playwright-visibility-debug:
taskfile: taskscripts/Taskfile.playwright-visibility-debug.yml
optional: true
# ── add more tools here ───────────────────────────────────────────────────
ORCH_EOF
dim " ✔ ${ORCHESTRATOR}"
green "✅ Orchestrator created with ${TOOL_NAME}."
return
fi
if grep -q "^ ${TOOL_NAME}:" "${ORCHESTRATOR}"; then
dim " ℹ ${TOOL_NAME} already in orchestrator."
return
fi
bold "➕ Adding ${TOOL_NAME} to existing orchestrator ..."
awk '
/^ # ── add more tools here/ {
print " # ── playwright-visibility-debug — Playwright visibility diagnostics CLI ─────"
print " # Exposes: playwright-visibility-debug:install playwright-visibility-debug:build"
print " # playwright-visibility-debug:test playwright-visibility-debug:doctor"
print " playwright-visibility-debug:"
print " taskfile: taskscripts/Taskfile.playwright-visibility-debug.yml"
print " optional: true"
print ""
}
{ print }
' "${ORCHESTRATOR}" >"${ORCHESTRATOR}.tmp" && mv "${ORCHESTRATOR}.tmp" "${ORCHESTRATOR}"
dim " ✔ Added ${TOOL_NAME} to ${ORCHESTRATOR}"
green "✅ Orchestrator updated."
}
install_runtime() {
if command -v bun >/dev/null 2>&1; then
RUNTIME="bun"
bold "📦 Installing dependencies with Bun ..."
(
cd "${INSTALL_ROOT}"
bun install
bunx playwright install chromium
)
return
fi
if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
RUNTIME="node"
bold "📦 Installing dependencies with Node + npm ..."
(
cd "${INSTALL_ROOT}"
npm install
npm run build
npx --yes playwright install chromium
)
return
fi
err "install requires Bun, or Node + npm"
exit 1
}
install_shims() {
bold "🔗 Installing command shims ..."
cat >"${SHIM_PATH}" <<EOF
#!/usr/bin/env bash
set -euo pipefail
cd "$INSTALL_ROOT"
if command -v bun >/dev/null 2>&1; then
exec bun run cli.ts "\$@"
fi
exec node dist/cli.js "\$@"
EOF
cat >"${SHORT_SHIM_PATH}" <<EOF
#!/usr/bin/env bash
exec "$SHIM_PATH" "\$@"
EOF
chmod +x "${SHIM_PATH}" "${SHORT_SHIM_PATH}"
dim " ✔ ${SHIM_PATH}"
dim " ✔ ${SHORT_SHIM_PATH}"
}
verify_installation() {
bold "🔍 Verifying installation ..."
if ! "${SHIM_PATH}" doctor >/dev/null 2>&1; then
err "installation verification failed — the CLI shim could not run 'doctor'"
exit 1
fi
if command -v task >/dev/null 2>&1; then
if task --taskfile "${ORCHESTRATOR}" --list >/dev/null 2>&1; then
green "✅ Taskfile include verified."
else
yellow "⚠️ Task is installed, but the orchestrator could not be listed."
fi
else
yellow "⚠️ 'task' is not installed, skipped Taskfile verification."
fi
green "✅ Installation verified successfully."
}
check_global_taskfile() {
local global_taskfile="${TASKFILES_DIR}/Taskfile.yml"
bold "🔍 Checking global Taskfile ..."
if [ -f "${global_taskfile}" ]; then
if grep -q "Taskfile.taskscripts.yml" "${global_taskfile}"; then
green "✅ Global Taskfile already includes taskscripts orchestrator."
else
yellow "⚠️ Your global Taskfile doesn't include the orchestrator yet."
dim " Add this to ${global_taskfile}:"
echo ""
dim ' includes:'
dim ' taskscripts:'
dim ' taskfile: Taskfile.taskscripts.yml'
dim ' optional: true'
dim ' flatten: true'
dim ' dir: ~/.taskfiles'
echo ""
fi
else
yellow "⚠️ No global Taskfile found at ${global_taskfile}"
fi
}
if ! command -v git >/dev/null 2>&1; then
err "git is required but not found in PATH"
exit 1
fi
mkdir -p "${TASKSCRIPTS_DIR}" "${BIN_DIR}"
echo ""
if [ -d "${INSTALL_ROOT}/.git" ]; then
bold "↻ Updating existing installation at ${INSTALL_ROOT} ..."
git -C "${INSTALL_ROOT}" pull --ff-only
green "✅ Updated to latest."
else
bold "⬇ Cloning ${TOOL_NAME} gist to ${INSTALL_ROOT} ..."
git clone "${GIST_URL}" "${INSTALL_ROOT}"
green "✅ Installed."
fi
echo ""
install_runtime
echo ""
deploy_taskfile
echo ""
add_to_orchestrator
echo ""
install_shims
echo ""
check_global_taskfile
echo ""
verify_installation
echo ""
bold "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
bold " ${TOOL_NAME} installed successfully!"
bold "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
bold "📍 Installation Path:"
dim " ${INSTALL_ROOT}"
echo ""
bold "🔗 Commands:"
dim " playwright-visibility-debug doctor"
dim " playwright-visibility-debug presets list"
dim " pw-visibility-debug inspect --url http://localhost:53940 --selector '#app'"
echo ""
bold "🔧 Task Commands:"
dim " task ${TOOL_NAME}:install"
dim " task ${TOOL_NAME}:build"
dim " task ${TOOL_NAME}:test"
dim " task ${TOOL_NAME}:doctor"
echo ""
bold "📚 Documentation:"
dim " https://gist.github.com/${GIST_ID}"
echo ""
import { Logger } from 'tslog'
import { logLevelSchema, type LogLevel } from './schemas.js'
import type { AppLogger } from './types.js'
const LEVEL_TO_MIN: Record<LogLevel, number> = {
debug: 2,
info: 3,
warn: 4,
error: 5,
}
export function createLogger(options: { verbose?: boolean | undefined; logLevel?: string | undefined }): AppLogger {
const parsedLevel = logLevelSchema.safeParse(options.logLevel ?? 'info')
const level: LogLevel = options.verbose ? 'debug' : parsedLevel.success ? parsedLevel.data : 'info'
return new Logger({
name: 'playwright-visibility-debug',
minLevel: LEVEL_TO_MIN[level],
})
}
{
"name": "playwright-visibility-debug",
"version": "0.2.1",
"private": true,
"type": "module",
"description": "Bun-first Playwright visibility diagnostics CLI with TypeScript, Crust, zod, tslog, and Vitest.",
"bin": {
"playwright-visibility-debug": "./dist/cli.js",
"pw-visibility-debug": "./dist/cli.js"
},
"engines": {
"node": ">=20",
"bun": ">=1.2.0"
},
"packageManager": "bun@1.3.13",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "bun run cli.ts",
"doctor": "bun run cli.ts doctor",
"inspect": "bun run cli.ts inspect",
"presets": "bun run cli.ts presets list",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@crustjs/core": "0.0.16",
"@crustjs/plugins": "0.0.22",
"playwright": "1.59.1",
"tslog": "4.10.2",
"zod": "4.3.6"
},
"devDependencies": {
"@types/node": "24.10.0",
"typescript": "6.0.3",
"vite": "8.0.10",
"vitest": "4.1.5"
}
}
import { describe, expect, test } from 'vitest'
import { getDevicePreset, listDevicePresets } from './presets.js'
describe('device presets', () => {
test('returns the custom iPhone 11 preset', () => {
const preset = getDevicePreset('iphone-11-custom')
expect(preset?.context.viewport).toEqual({ width: 414, height: 896 })
expect(preset?.context.deviceScaleFactor).toBe(2)
expect(preset?.context.isMobile).toBe(true)
})
test('lists bundled presets', () => {
expect(listDevicePresets().map((preset) => preset.name)).toContain('iphone-11-custom')
})
test('throws for unknown presets', () => {
expect(() => getDevicePreset('does-not-exist')).toThrow(/Unknown device preset/)
})
})
import { devicePresetSchema } from './schemas.js'
import type { DevicePreset } from './types.js'
const rawPresets = {
'iphone-11-custom': {
description:
'Custom iPhone 11 emulation with 414x896 viewport, DPR 2, touch, and Safari-like user agent.',
context: {
viewport: { width: 414, height: 896 },
screen: { width: 414, height: 896 },
deviceScaleFactor: 2,
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 13_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.4 Mobile/15E148 Safari/604.1',
isMobile: true,
hasTouch: true,
},
},
} as const
export const DEVICE_PRESETS: Record<string, DevicePreset> = Object.freeze(
Object.fromEntries(
Object.entries(rawPresets).map(([name, preset]) => [
name,
devicePresetSchema.parse({
name,
description: preset.description,
context: preset.context,
}),
]),
),
)
export function listDevicePresets(): DevicePreset[] {
return Object.values(DEVICE_PRESETS)
}
export function getDevicePreset(name?: string): DevicePreset | undefined {
if (!name) return undefined
const preset = DEVICE_PRESETS[name]
if (!preset) {
const available = Object.keys(DEVICE_PRESETS).join(', ')
throw new Error(`Unknown device preset "${name}". Available presets: ${available}`)
}
return preset
}
import { z } from 'zod'
export const browserEngineSchema = z.enum(['chromium', 'firefox', 'webkit'])
export type BrowserEngine = z.infer<typeof browserEngineSchema>
export const logLevelSchema = z.enum(['debug', 'info', 'warn', 'error'])
export type LogLevel = z.infer<typeof logLevelSchema>
const sizeSchema = z.object({
width: z.number().int().positive(),
height: z.number().int().positive(),
})
export const devicePresetSchema = z.object({
name: z.string().min(1),
description: z.string().min(1),
context: z.object({
viewport: sizeSchema,
screen: sizeSchema.optional(),
deviceScaleFactor: z.number().positive(),
userAgent: z.string().min(1).optional(),
isMobile: z.boolean().default(false),
hasTouch: z.boolean().default(false),
}),
})
export const inspectCommandOptionsSchema = z.object({
url: z.string().url(),
selector: z.string().min(1),
label: z.string().min(1).optional(),
outputDir: z.string().min(1),
click: z.array(z.string().min(1)).default([]),
wait: z.number().int().nonnegative().default(0),
devicePreset: z.string().min(1).optional(),
browser: browserEngineSchema.default('chromium'),
headless: z.boolean().default(true),
fullPage: z.boolean().default(true),
json: z.boolean().default(false),
})
export type InspectCommandOptions = z.infer<typeof inspectCommandOptionsSchema>
version: '3'
tasks:
install:
desc: Install gist dependencies
cmds:
- bun install
typecheck:
desc: Run TypeScript type-checking
cmds:
- bun run typecheck
build:
desc: Build the CLI to dist/
deps: [typecheck]
cmds:
- bun run build
test:
desc: Run Vitest tests
cmds:
- bun run test
doctor:
desc: Print CLI runtime diagnostics
cmds:
- bun run cli.ts doctor
presets:
desc: List bundled device presets
cmds:
- bun run cli.ts presets list
inspect:
desc: Run an example visibility inspection
cmds:
- >
bun run cli.ts inspect
--url http://localhost:53940/?q=documentation
--selector '[aria-label="Theme mode"]'
--device-preset iphone-11-custom
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": ".",
"outDir": "dist",
"declaration": true,
"sourceMap": true
},
"include": ["*.ts"],
"exclude": ["dist", "node_modules", "*.test.ts"]
}
export interface RectSnapshot {
x: number
y: number
top: number
right: number
bottom: number
left: number
width: number
height: number
}
export interface NodeSnapshot {
tag: string
id: string | null
className: string | null
text?: string | null
rect: RectSnapshot
style: Record<string, string>
overlapArea?: number
}
export interface SamplePoint {
name: string
x: number
y: number
inViewport: boolean
hitTarget: boolean
hit: NodeSnapshot | null
}
export interface ElementSummary {
status: 'visible' | 'attention' | 'not-found'
headline: string
reasons: string[]
}
export interface ElementDiagnostic {
index?: number
outerHTML: string
text: string
rect: RectSnapshot
style: Record<string, string>
inViewport: boolean
attributes: Record<string, string>
ancestors: NodeSnapshot[]
clippedBy: NodeSnapshot[]
samplePoints: SamplePoint[]
overlappingFixedOrSticky: NodeSnapshot[]
summary?: ElementSummary
}
export interface PageContext {
url: string
title: string
viewport: {
innerWidth: number
innerHeight: number
outerWidth: number
outerHeight: number
devicePixelRatio: number
scrollX: number
scrollY: number
}
}
export interface VisibilityReport {
found: boolean
selector: string | null
label: string
page: PageContext
matchCount: number
candidateCount: number
chosenCandidateIndex: number
candidates: ElementDiagnostic[]
element: ElementDiagnostic | null
summary: ElementSummary
}
export interface DevicePreset {
name: string
description: string
context: {
viewport: {
width: number
height: number
}
screen?: {
width: number
height: number
} | undefined
deviceScaleFactor: number
userAgent?: string | undefined
isMobile: boolean
hasTouch: boolean
}
}
export interface InspectRunResult {
report: VisibilityReport
reportPath: string
beforeScreenshotPath: string
afterScreenshotPath: string
}
export interface AppLogger {
debug: (...args: unknown[]) => void
info: (...args: unknown[]) => void
warn: (...args: unknown[]) => void
error: (...args: unknown[]) => void
}
export const VERSION = '0.2.1'
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['*.test.ts'],
coverage: {
reporter: ['text', 'html'],
},
},
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment