| name | frontend-validator |
| description | Validates live UI via Playwright + axe-core. Detects Playwright; installs if missing (with user consent). Spawns dev server, navigates critical routes on mobile+desktop, captures console errors, network failures, a11y violations, screenshots, layout shifts. Structured JSON output for the reviewer to parse. |
| triggers | ["validate UI","run playwright","check accessibility","smoke test frontend","validate live interface"] |
Skill: jdi-frontend-validator
Runs UI validation in a real browser. Without Playwright installed, installs with consent. Output always JSON in .jdi/cache/ui-findings.json for the parent reviewer to consume.
When to apply
Reviewer calls at gate 7. Preconditions in PROJECT.md:
frontend:
has_frontend: true
frontend_url: http://localhost:5173
dev_command: pnpm dev
critical_paths:
- /
- /login
- /dashboard
Missing any key -> abort with descriptive error.
Procedure
Step 1: Pre-flight
test -d .jdi/ || { echo "No .jdi/. Run /jdi-new"; exit 1; }
test -f .jdi/PROJECT.md || { echo "PROJECT.md missing"; exit 1; }
mkdir -p .jdi/cache/screenshots
grep -q '^\.jdi/cache/' .gitignore 2>/dev/null || echo '.jdi/cache/' >> .gitignore
# PowerShell
if (-not (Test-Path .jdi)) { Write-Error "No .jdi/. Run /jdi-new"; exit 1 }
if (-not (Test-Path .jdi/PROJECT.md)) { Write-Error "PROJECT.md missing"; exit 1 }
New-Item -ItemType Directory -Force -Path .jdi/cache/screenshots | Out-Null
if (-not (Test-Path .gitignore) -or -not (Select-String -Path .gitignore -Pattern '^\.jdi/cache/' -Quiet)) {
Add-Content .gitignore '.jdi/cache/'
}
Step 2: Detect package manager
Lockfile detection (more reliable than which):
if [ -f pnpm-lock.yaml ]; then PKG_MGR=pnpm
elif [ -f yarn.lock ]; then PKG_MGR=yarn
elif [ -f bun.lockb ] || [ -f bun.lock ]; then PKG_MGR=bun
elif [ -f package-lock.json ]; then PKG_MGR=npm
else PKG_MGR=npm
fi
# PowerShell
if (Test-Path pnpm-lock.yaml) { $PKG_MGR = "pnpm" }
elseif (Test-Path yarn.lock) { $PKG_MGR = "yarn" }
elseif ((Test-Path bun.lockb) -or (Test-Path bun.lock)) { $PKG_MGR = "bun" }
elseif (Test-Path package-lock.json) { $PKG_MGR = "npm" }
else { $PKG_MGR = "npm" }
Corresponding install command:
| Pkg mgr | Install dev dep | Run binary |
|---|
| npm | npm install --save-dev <pkg> | npx <bin> |
| pnpm | pnpm add -D <pkg> | pnpm exec <bin> or pnpm dlx <bin> |
| yarn | yarn add -D <pkg> | yarn <bin> |
| bun | bun add -d <pkg> | bunx <bin> |
Step 3: Detect Playwright
PW_BIN="npx --no-install playwright"
[ "$PKG_MGR" = "pnpm" ] && PW_BIN="pnpm exec playwright"
[ "$PKG_MGR" = "yarn" ] && PW_BIN="yarn playwright"
[ "$PKG_MGR" = "bun" ] && PW_BIN="bunx playwright"
if ! $PW_BIN --version >/dev/null 2>&1; then
PLAYWRIGHT_MISSING=1
fi
if [ -f package.json ] && ! grep -q '@axe-core/playwright' package.json; then
AXE_MISSING=1
fi
# PowerShell - simplified
$pwExists = $false
try {
$null = & npx --no-install playwright --version 2>$null
if ($LASTEXITCODE -eq 0) { $pwExists = $true }
} catch {}
if (-not $pwExists) { $env:PLAYWRIGHT_MISSING = "1" }
$axeExists = $false
if (Test-Path package.json) {
if (Select-String -Path package.json -Pattern '@axe-core/playwright' -Quiet) { $axeExists = $true }
}
if (-not $axeExists) { $env:AXE_MISSING = "1" }
Step 4: If missing, ask consent + install
Use AskUserQuestion (or prompt fallback if runtime doesn't support it):
Playwright not installed in this project.
Install now?
- [Yes, install with Chromium] (~150MB, 2-5min, recommended)
- [Yes, install with all browsers] (~500MB, 5-10min)
- [No, skip gate 7 this time] (gate returns SKIPPED)
- [Cancel entire review]
Choice mapping:
Yes, Chromium:
$INSTALL_CMD @playwright/test @axe-core/playwright
$PLAYWRIGHT_INSTALL chromium
Where:
$INSTALL_CMD = pnpm add -D / npm install --save-dev / etc based on PKG_MGR
$PLAYWRIGHT_INSTALL = $PW_BIN install
Yes, all browsers:
$INSTALL_CMD @playwright/test @axe-core/playwright
$PLAYWRIGHT_INSTALL
No, skip:
Returns { "status": "SKIPPED", "reason": "user declined Playwright install" } - reviewer marks gate 7 as SKIPPED (not BLOCK).
Cancel review:
Returns error code, reviewer aborts.
Step 5: Generate temporary Playwright spec
Creates .jdi/cache/playwright-check.spec.js (gitignored). Content:
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
const fs = require('fs');
const path = require('path');
const URL = process.env.JDI_FRONTEND_URL;
const ROUTES = (process.env.JDI_ROUTES || '/').split(',').map(r => r.trim()).filter(Boolean);
const OUT = process.env.JDI_OUT || '.jdi/cache/ui-findings.json';
const SCREENSHOT_DIR = process.env.JDI_SCREENSHOT_DIR || '.jdi/cache/screenshots';
const VIEWPORTS = [
{ name: 'mobile', width: 375, height: 667 },
{ name: 'desktop', width: 1280, height: 720 }
];
const findings = {
metadata: { url: URL, routes: ROUTES, timestamp: new Date().toISOString() },
console: [],
network: [],
a11y: [],
layout: [],
screenshots: [],
navigationFailures: []
};
for (const route of ROUTES) {
for (const viewport of VIEWPORTS) {
test(`${route} @ ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
page.on('console', msg => {
if (msg.type() === 'error') {
findings.console.push({
route,
viewport: viewport.name,
text: msg.text(),
location: msg.location()
});
}
});
page.on('requestfailed', req => {
findings.network.push({
route,
viewport: viewport.name,
url: req.url(),
method: req.method(),
failure: req.failure()?.errorText,
severity: 'requestfailed'
});
});
page.on('response', res => {
if (res.status() >= 500) {
findings.network.push({
route,
viewport: viewport.name,
url: res.url(),
status: res.status(),
severity: '5xx'
});
} else if (res.status() >= 400 && res.status() !== 404) {
findings.network.push({
route,
viewport: viewport.name,
url: res.url(),
status: res.status(),
severity: '4xx'
});
}
});
const targetUrl = `${URL}${route}`;
let response;
try {
response = await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 30000 });
} catch (err) {
findings.navigationFailures.push({
route,
viewport: viewport.name,
error: err.message
});
return;
}
if (!response || !response.ok()) {
findings.navigationFailures.push({
route,
viewport: viewport.name,
status: response?.status() ?? 'no-response',
url: targetUrl
});
return;
}
const hasHScroll = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth + 1;
});
if (hasHScroll) {
findings.layout.push({
route,
viewport: viewport.name,
issue: 'horizontal_scroll'
});
}
try {
const axeResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa', 'best-practice'])
.analyze();
for (const v of axeResults.violations) {
findings.a11y.push({
route,
viewport: viewport.name,
id: v.id,
impact: v.impact,
help: v.help,
helpUrl: v.helpUrl,
nodes: v.nodes.length,
sample: v.nodes.slice(0, 3).map(n => ({
target: n.target,
html: n.html.slice(0, 200)
}))
});
}
} catch (err) {
findings.a11y.push({
route,
viewport: viewport.name,
error: `axe-core failed: ${err.message}`
});
}
const safeName = (route === '/' ? 'root' : route.replace(/^\//, '').replace(/\//g, '_'));
const screenshotPath = path.join(SCREENSHOT_DIR, `${safeName}_${viewport.name}.png`);
await page.screenshot({ path: screenshotPath, fullPage: true });
findings.screenshots.push({ route, viewport: viewport.name, path: screenshotPath });
});
}
}
test.afterAll(() => {
fs.writeFileSync(OUT, JSON.stringify(findings, null, 2));
});
And inline config .jdi/cache/playwright.config.js:
module.exports = {
testDir: '.jdi/cache',
testMatch: 'playwright-check.spec.js',
timeout: 60000,
retries: 0,
workers: 1,
reporter: [['line']],
use: {
headless: true,
ignoreHTTPSErrors: true
}
};
Step 6: Spawn dev server
DEV_LOG=.jdi/cache/dev-server.log
DEV_PID_FILE=.jdi/cache/dev-server.pid
nohup $DEV_COMMAND > $DEV_LOG 2>&1 &
echo $! > $DEV_PID_FILE
READY=0
for i in $(seq 1 60); do
if curl -sSf -o /dev/null --max-time 2 "$FRONTEND_URL"; then
READY=1
break
fi
sleep 1
done
if [ $READY -eq 0 ]; then
kill $(cat $DEV_PID_FILE) 2>/dev/null
echo '{"status":"INCONCLUSIVE","reason":"dev server failed to start in 60s","logs":"'$DEV_LOG'"}' > .jdi/cache/ui-findings.json
exit 0
fi
# PowerShell
$DEV_LOG = ".jdi/cache/dev-server.log"
$DEV_PID_FILE = ".jdi/cache/dev-server.pid"
$proc = Start-Process -FilePath pwsh -ArgumentList "-NoProfile", "-Command", $DEV_COMMAND `
-RedirectStandardOutput $DEV_LOG -RedirectStandardError $DEV_LOG `
-PassThru -WindowStyle Hidden
$proc.Id | Out-File -FilePath $DEV_PID_FILE
$ready = $false
for ($i = 0; $i -lt 60; $i++) {
try {
$r = Invoke-WebRequest -Uri $FRONTEND_URL -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop
if ($r.StatusCode -eq 200) { $ready = $true; break }
} catch {}
Start-Sleep -Seconds 1
}
if (-not $ready) {
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
$err = @{ status="INCONCLUSIVE"; reason="dev server failed to start in 60s"; logs=$DEV_LOG } | ConvertTo-Json
$err | Out-File .jdi/cache/ui-findings.json
exit 0
}
Step 7: Run Playwright
JDI_FRONTEND_URL="$FRONTEND_URL" \
JDI_ROUTES="$(echo $CRITICAL_PATHS | tr '\n' ',')" \
JDI_OUT=".jdi/cache/ui-findings.json" \
JDI_SCREENSHOT_DIR=".jdi/cache/screenshots" \
$PW_BIN test --config=.jdi/cache/playwright.config.js 2>&1 | tee .jdi/cache/playwright.log
# PowerShell
$env:JDI_FRONTEND_URL = $FRONTEND_URL
$env:JDI_ROUTES = ($CRITICAL_PATHS -join ',')
$env:JDI_OUT = ".jdi/cache/ui-findings.json"
$env:JDI_SCREENSHOT_DIR = ".jdi/cache/screenshots"
& npx playwright test --config=.jdi/cache/playwright.config.js 2>&1 | Tee-Object -FilePath .jdi/cache/playwright.log
Step 8: Kill dev server (always, even on failure)
if [ -f $DEV_PID_FILE ]; then
PID=$(cat $DEV_PID_FILE)
pkill -P $PID 2>/dev/null
kill $PID 2>/dev/null
rm $DEV_PID_FILE
fi
# PowerShell
if (Test-Path $DEV_PID_FILE) {
$pid = Get-Content $DEV_PID_FILE
# Kill children first
Get-CimInstance Win32_Process -Filter "ParentProcessId=$pid" -ErrorAction SilentlyContinue |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
# Kill the parent
Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue
Remove-Item $DEV_PID_FILE
}
Step 9: Return to parent reviewer
Skill doesn't write REVIEW.md. Only writes .jdi/cache/ui-findings.json + screenshots.
Reviewer reads the JSON, classifies severities, and writes "UI Validation" section in REVIEW.md.
Finding classification (reference for the reviewer)
| Finding | Severity |
|---|
console.error on any route | BLOCK |
network.5xx on critical_path | BLOCK |
network.4xx on critical_path | WARN |
network.requestfailed (CORS/abort/etc) | WARN |
navigationFailures (404/timeout/etc on critical_path) | BLOCK |
a11y.impact=critical | BLOCK |
a11y.impact=serious | BLOCK |
a11y.impact=moderate | WARN |
a11y.impact=minor | INFO |
layout.horizontal_scroll on mobile | BLOCK |
layout.horizontal_scroll on desktop | INFO |
axe-core failed (technical error) | WARN |
INCONCLUSIVE (dev server timeout) | WARN |
SKIPPED (user declined install) | WARN |
Expected inputs
From PROJECT.md (passed as environment variables by the reviewer):
frontend.frontend_url -> FRONTEND_URL
frontend.dev_command -> DEV_COMMAND
frontend.critical_paths -> CRITICAL_PATHS (list)
Outputs
Files created in .jdi/cache/ (gitignored):
ui-findings.json - structured findings
screenshots/*.png - 1 per route x viewport
dev-server.log - dev server log
playwright.log - Playwright run log
playwright-check.spec.js - generated spec
playwright.config.js - generated config
NEVER commit .jdi/cache/.
Anti-patterns
- Running against prod URL - dev local only. Prod is out of scope for this gate
- Testing flows that require login - MVP doesn't support auth setup. Critical paths must be public OR pre-authenticated manually (cookie/session passed via PROJECT.md in follow-up)
- Blocking review if Playwright install fails - degrade to SKIPPED
- Leaving dev server alive after gate - always kill, even on error
- Committing screenshots - .gitignore guaranteed in pre-flight
- Running parallel (workers > 1) - local dev server doesn't scale, and race conditions confuse findings
- Using
--headed in CI - always headless
- Trusting Playwright exit code - findings come from afterAll, even with test failure
References
references/playwright-setup.md - Detailed install per package manager + troubleshoot
references/dev-server-detection.md - Heuristics for detecting ready (curl, wait-on, polling)
references/axe-rules.md - Mapping of axe rule IDs -> WCAG -> severity
references/auth-flows.md - Roadmap for authenticated flows (future)
Examples
Example 1: Vite + React, Playwright missing, user accepts install
1. Reviewer triggers gate 7
2. Skill detects `npx playwright --version` -> exit 1
3. Lockfile = pnpm-lock.yaml -> PKG_MGR=pnpm
4. AskUserQuestion -> user picks "Yes, Chromium"
5. pnpm add -D @playwright/test @axe-core/playwright
6. pnpm exec playwright install chromium
7. Spawns `pnpm dev` in bg, PID 12345
8. Waits http://localhost:5173 -> ready in 4s
9. Runs Playwright on /, /login, /dashboard x mobile + desktop = 6 navigations
10. Findings:
- 1 console error (uncaught promise) on /dashboard mobile + desktop
- 0 network errors
- 2 a11y serious on /login (missing label + contrast)
- 1 horizontal scroll on /dashboard mobile
11. Kill PID 12345 + children
12. Writes .jdi/cache/ui-findings.json
13. Reviewer reads JSON, marks gate 7 = BLOCK (3 issues), writes REVIEW.md
Example 2: API-only, has_frontend=false
Skill is not even loaded. Reviewer skips gate 7 with SKIPPED.
Example 3: Dev server fails to start
1. Spawns `pnpm dev` -> process dies after 2s (port 5173 occupied)
2. Poll of 60s expires without 200 OK
3. Cleanup of PID
4. Writes {"status":"INCONCLUSIVE","reason":"dev server failed to start in 60s"}
5. Reviewer marks gate 7 = WARN with link to dev-server.log
6. Review not blocked, but user alerted
Example 4: User declines to install Playwright
1. AskUserQuestion -> "No, skip gate 7"
2. Writes {"status":"SKIPPED","reason":"user declined Playwright install"}
3. Reviewer marks gate 7 = SKIPPED (warn not block)
4. REVIEW.md notes "UI Validation: SKIPPED - run /jdi-verify again when you accept installing"