TL;DR: Stop writing "button doesn't work" in bug reports. Start capturing the actual DOM state. This post shows how to integrate runtime snapshots into QA workflows — from manual bug reports to fully automated Playwright pipelines.
The Bug Report That Wastes Everyone's Time
Every QA engineer has written this:
Bug: Submit button not clickable
URL: /checkout
Steps: Fill form → click Submit → nothing happens
Expected: Form submits
Actual: Button appears active but doesn't respond
Developer response: "Works on my machine."
Two hours of back-and-forth later, someone finally discovers: the button has pointer-events: none from a CSS conflict that only triggers after async validation.
The problem isn't QA's description. It's that LLMs and developers can't see what QA saw.
Level 1: Bug Reports With Context
The simplest improvement: capture DOM state alongside the bug.
Instead of describing what you see, capture what's actually there:
{
"metadata": { "url": "https://app.example.com/checkout" },
"nodes": {
"#submit-btn": {
"tag": "BUTTON",
"text": "Submit",
"attrs": { "disabled": "true" }
}
},
"details": {
"#submit-btn": {
"styles": { "pointer-events": "none", "opacity": "0.5" },
"hints": { "interactive": false, "focusable": false }
}
}
}
Now the developer sees immediately:
-
disabled="true"— button is programmatically disabled -
pointer-events: none— CSS blocks clicks -
interactive: false— computed accessibility state
No reproduction needed. The state is captured.
Bug Report Template
## Bug: Submit button not clickable
**URL:** https://app.example.com/checkout
**Browser:** Firefox 120
**DOM Context (SiFR):**
[paste JSON here]
**Analysis:** Button has disabled=true and pointer-events: none.
Likely cause: async validation hasn't completed.
Time saved per bug: 30 minutes to 2 hours.
Level 2: AI-Assisted Debugging
With structured DOM context, LLMs stop guessing.
The Prompt Pattern
Here's the DOM context of a problematic element in SiFR format.
Analyze and tell me:
1. Why is this element not interactive?
2. What CSS/JS changes would fix it?
3. Are there accessibility violations?
[SiFR JSON]
What Changes
Without context:
"The button might be disabled. Check if there's a
disabledattribute or CSSpointer-events. Also verify JavaScript event handlers..."
With SiFR context:
"The button is disabled because:
disabled="true"attribute is setpointer-events: nonein computed styles- Parent form has
aria-busy="true"Fix: The async validation sets
aria-busybut never clears it on success. Addform.removeAttribute('aria-busy')after validation resolves."
QA Prompt Library
| Task | Prompt |
|---|---|
| Debug interactivity | "Why is this element not responding to clicks?" |
| Layout issues | "Why is this element positioned incorrectly? Analyze the layout data." |
| Accessibility audit | "Check this element for WCAG 2.1 AA violations." |
| Selector generation | "Generate a stable CSS selector for this element that won't break with minor DOM changes." |
| Regression detection | "Compare these two snapshots and list all functional differences." |
Level 3: Playwright Integration
Here's where it gets interesting. Runtime snapshots + test automation = regression detection that actually works.
Capturing SiFR in Playwright
async function captureSiFR(page, selector = 'body', preset = 'normal') {
return await page.evaluate(({ selector, preset }) => {
return new Promise((resolve, reject) => {
const requestId = Date.now().toString();
const timeout = setTimeout(() => reject(new Error('Capture timeout')), 5000);
document.addEventListener('e2llm-capture-response', (e) => {
if (e.detail.requestId === requestId) {
clearTimeout(timeout);
resolve(e.detail.data);
}
}, { once: true });
document.dispatchEvent(new CustomEvent('e2llm-capture-request', {
detail: { requestId, selector, options: { preset } }
}));
});
}, { selector, preset });
}
Use Case 1: Selector Generation
Flaky selectors kill test suites. SiFR captures what makes elements unique:
async function getStableSelectors(page, url) {
await page.goto(url);
const sifr = await captureSiFR(page);
// Extract selectors for high-salience interactive elements
return Object.entries(sifr.nodes)
.filter(([_, node]) => node.salience === 'HIGH')
.filter(([id]) => sifr.details[id]?.hints?.interactive)
.map(([id, node]) => ({
selector: id,
tag: node.tag,
text: node.text?.slice(0, 50),
dataOid: node.oid
}));
}
// Result:
// [
// { selector: "#checkout-submit", tag: "BUTTON", text: "Complete Purchase" },
// { selector: "[data-oid='nav-cart']", tag: "A", text: "Cart (3)" }
// ]
Use Case 2: DOM Regression Testing
Catch structural changes before they hit production:
async function detectRegressions(page, url, baseline) {
await page.goto(url);
const current = await captureSiFR(page);
const diff = { added: [], removed: [], changed: [] };
// Compare interactive elements
const baseButtons = new Set(baseline.summary?.interactive?.buttons || []);
const currButtons = new Set(current.summary?.interactive?.buttons || []);
for (const btn of currButtons) {
if (!baseButtons.has(btn)) {
diff.added.push({ type: 'button', selector: btn });
}
}
for (const btn of baseButtons) {
if (!currButtons.has(btn)) {
diff.removed.push({ type: 'button', selector: btn });
}
}
// Compare patterns (lists, grids)
const basePatterns = baseline.summary?.patterns || {};
const currPatterns = current.summary?.patterns || {};
for (const [id, pattern] of Object.entries(currPatterns)) {
const base = basePatterns[id];
if (base && pattern.count !== base.count) {
diff.changed.push({
type: 'pattern_count',
id,
was: base.count,
now: pattern.count
});
}
}
return diff;
}
Use Case 3: Layout Regression
Detect when elements shift unexpectedly:
async function checkLayoutRegression(page, url, baselineDetails, threshold = 5) {
await page.goto(url);
const sifr = await captureSiFR(page, 'body', 'visual');
const issues = [];
for (const [id, baseline] of Object.entries(baselineDetails)) {
const current = sifr.details[id];
if (!current?.layout) continue;
const dx = Math.abs(current.layout.x - baseline.layout.x);
const dy = Math.abs(current.layout.y - baseline.layout.y);
const dw = Math.abs(current.layout.w - baseline.layout.w);
const dh = Math.abs(current.layout.h - baseline.layout.h);
if (dx > threshold || dy > threshold) {
issues.push({
element: id,
issue: 'position_shift',
delta: { x: dx, y: dy }
});
}
if (dw > threshold || dh > threshold) {
issues.push({
element: id,
issue: 'size_change',
delta: { width: dw, height: dh }
});
}
}
return issues;
}
Use Case 4: Accessibility Scanning
Automated a11y checks from runtime state:
async function scanAccessibility(page, url) {
await page.goto(url);
const sifr = await captureSiFR(page);
const violations = [];
for (const [id, node] of Object.entries(sifr.nodes)) {
const details = sifr.details[id];
// Interactive element without accessible name
if (details?.hints?.interactive && !node.text && !node.attrs?.['aria-label']) {
violations.push({
element: id,
rule: 'missing-accessible-name',
severity: 'serious'
});
}
// Image without alt text
if (node.tag === 'IMG' && !node.attrs?.alt) {
violations.push({
element: id,
rule: 'img-alt',
severity: 'critical'
});
}
// Form input without label
if (['INPUT', 'SELECT', 'TEXTAREA'].includes(node.tag)) {
const hasLabel = sifr.relations?.some(r =>
r.to === id && r.type === 'labels'
);
if (!hasLabel && !node.attrs?.['aria-label'] && !node.attrs?.['aria-labelledby']) {
violations.push({
element: id,
rule: 'label-missing',
severity: 'serious'
});
}
}
}
return violations;
}
The Pipeline
Put it together:
PR opened
↓
Playwright runs against staging
↓
SiFR captures key pages
↓
Compare against baseline snapshots
↓
Report: "3 buttons removed, 2 layout shifts detected, 1 new a11y violation"
↓
Block merge or flag for review
No pixel diffing. No screenshot comparison. Just semantic changes that actually matter.
What This Enables
For manual QA:
- Bug reports that developers can act on immediately
- No more "works on my machine"
- AI assistance that's actually useful
For automation:
- Selectors that don't break every sprint
- Regression detection beyond visual diffing
- Accessibility scanning from runtime state
For everyone:
- Faster debugging cycles
- Fewer production issues
- Less time wasted on reproduction
Try It
The format is implemented in Element to LLM — a free browser extension:
For Playwright integration, you'll need to load the extension in your test browser. The event-based API (e2llm-capture-request / e2llm-capture-response) works in any automation context.
Series Index
- Runtime Snapshots #1: Taking a "fine" signup form and making it work
- Runtime Snapshots #2: a11y starts with runtime context
- 🧩 Runtime Snapshots #3 — QA That Speaks JSON
- 🧩 Runtime Snapshots #4 — The Invisible JSON: when hidden state breaks your LLM
- 🧩 Runtime Snapshots #5 — The Real Thing: How We Actually Use It
- 🧩 Runtime Snapshots #6 — The Hidden Reason Your LLM UI Agents Cost Too Much
- Runtime Snapshots #7 — Inside SiFR: The Schema That Makes LLMs See Web UIs
Using SiFR in your QA pipeline? I'd love to hear what's working and what's missing. Drop a comment or open an issue.
Top comments (0)