Chrome extension accessibility snapshot evidence helps QA engineers attach clearer context to accessibility bugs. Instead of filing a vague issue such as “button is not accessible,” a tester can capture the page URL, visible headings, likely interactive controls, accessible labels, focus targets, ARIA roles, and a short note about the expected behavior.

This tutorial shows how to build a small Manifest V3 Chrome extension side panel for tester-approved evidence capture. It is not a replacement for screen-reader testing, Chrome DevTools accessibility inspection, axe-style scanning, WCAG review, or human accessibility expertise. It is a lightweight helper for collecting observable DOM and ARIA metadata so bug reports are easier for developers to reproduce and triage.

Why Accessibility Snapshot Evidence Helps QA

Accessibility defects often fail because the report is missing context. A developer needs to know which page was tested, which element was involved, what label or role was exposed, where keyboard focus went, and what the tester expected to happen.

A Chrome extension is useful here because the tester can start capture from the current tab, review the data before saving it, and attach a concise evidence summary to a bug report. Chrome extension documentation supports this kind of workflow through content scripts, user-triggered scripting, the activeTab permission, extension storage, and the sidePanel API.

What This Helper Will Capture

Keep the first version intentionally small. Capture evidence that is useful and low risk:

  • Page URL and document title
  • Visible heading structure
  • Buttons, links, inputs, and controls with labels
  • Explicit ARIA roles and selected ARIA attributes
  • The currently focused element summary
  • A tester note explaining expected and actual behavior

Avoid capturing passwords, hidden form values, cookies, full page text dumps, authentication tokens, customer data, or large DOM snapshots. Accessibility evidence should make the bug easier to understand, not create a privacy problem.

Step 1: Create the Manifest

Create a folder named qa-a11y-snapshot. Add this manifest.json:

{
  "manifest_version": 3,
  "name": "QA Accessibility Snapshot",
  "version": "0.1.0",
  "description": "Capture tester-approved accessibility evidence for bug reports.",
  "permissions": ["activeTab", "scripting", "storage", "sidePanel"],
  "action": {
    "default_title": "Capture accessibility evidence"
  },
  "side_panel": {
    "default_path": "sidepanel.html"
  },
  "background": {
    "service_worker": "service-worker.js"
  }
}

The activeTab permission keeps the workflow tester-triggered. Chrome documentation describes it as temporary access to the active tab after a user gesture. That fits a QA evidence helper because the tester chooses when to inspect the current page.

Step 2: Open the Side Panel from the Extension Action

Add service-worker.js:

chrome.action.onClicked.addListener(async (tab) => {
  if (!tab.id) return;
  await chrome.sidePanel.open({ tabId: tab.id });
});

The side panel gives the tester a persistent place to capture, review, edit, and copy evidence while the page stays visible. This is better than immediately exporting raw data because it adds a human review step before anything goes into a defect.

Step 3: Build the Side Panel UI

Add sidepanel.html:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>QA Accessibility Snapshot</title>
    <link rel="stylesheet" href="sidepanel.css" />
  </head>
  <body>
    <main>
      <h1>Accessibility Snapshot</h1>
      <button id="capture">Capture current tab</button>
      <label for="note">Tester note</label>
      <textarea id="note" rows="5" placeholder="Expected result, actual result, keyboard or screen-reader observation"></textarea>
      <section id="summary" aria-live="polite"></section>
      <button id="copy">Copy bug report evidence</button>
    </main>
    <script src="sidepanel.js"></script>
  </body>
</html>

Add a minimal sidepanel.css so the panel stays readable:

body {
  font-family: system-ui, sans-serif;
  margin: 0;
  color: #17202a;
}

main {
  padding: 16px;
}

button,
textarea {
  box-sizing: border-box;
  width: 100%;
  margin: 8px 0 14px;
}

button {
  min-height: 36px;
  cursor: pointer;
}

textarea {
  resize: vertical;
}

.card {
  border: 1px solid #d8dee4;
  border-radius: 6px;
  padding: 10px;
  margin: 8px 0;
}

.small {
  color: #59636e;
  font-size: 12px;
}

Step 4: Capture Observable Accessibility Metadata

Add sidepanel.js. This script asks Chrome to inject a function into the active tab. The injected function reads observable DOM and ARIA metadata, then returns a small summary.

async function getActiveTab() {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  return tab;
}

function captureAccessibilitySnapshot() {
  const clean = (value) => String(value || "").replace(/\s+/g, " ").trim().slice(0, 160);

  const labelFor = (element) => {
    const aria = element.getAttribute("aria-label");
    if (aria) return clean(aria);

    const labelledBy = element.getAttribute("aria-labelledby");
    if (labelledBy) {
      return clean(labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent).join(" "));
    }

    if (element.labels?.length) {
      return clean(Array.from(element.labels).map((label) => label.textContent).join(" "));
    }

    return clean(element.innerText || element.textContent || element.getAttribute("title") || element.getAttribute("alt"));
  };

  const controls = Array.from(document.querySelectorAll("button, a[href], input, select, textarea, [role], [tabindex]"))
    .slice(0, 60)
    .map((element) => ({
      tag: element.tagName.toLowerCase(),
      role: clean(element.getAttribute("role")),
      label: labelFor(element),
      ariaExpanded: clean(element.getAttribute("aria-expanded")),
      ariaSelected: clean(element.getAttribute("aria-selected")),
      ariaDisabled: clean(element.getAttribute("aria-disabled")),
      tabIndex: element.tabIndex
    }))
    .filter((item) => item.label || item.role || item.tabIndex >= 0);

  const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"))
    .slice(0, 30)
    .map((heading) => ({
      level: heading.tagName.toLowerCase(),
      text: clean(heading.textContent)
    }))
    .filter((heading) => heading.text);

  const active = document.activeElement;
  const focus = active ? {
    tag: active.tagName.toLowerCase(),
    role: clean(active.getAttribute("role")),
    label: labelFor(active),
    tabIndex: active.tabIndex
  } : null;

  return {
    url: location.href,
    title: document.title,
    capturedAt: new Date().toISOString(),
    headings,
    focus,
    controls
  };
}

function render(snapshot) {
  const summary = document.getElementById("summary");
  summary.innerHTML = "";

  const meta = document.createElement("div");
  meta.className = "card";
  meta.innerHTML = `<strong>${snapshot.title || "Untitled page"}</strong><div class="small">${snapshot.url}</div>`;
  summary.append(meta);

  const headings = document.createElement("div");
  headings.className = "card";
  headings.innerHTML = `<strong>Headings</strong><pre>${JSON.stringify(snapshot.headings, null, 2)}</pre>`;
  summary.append(headings);

  const controls = document.createElement("div");
  controls.className = "card";
  controls.innerHTML = `<strong>Controls and labels</strong><pre>${JSON.stringify(snapshot.controls, null, 2)}</pre>`;
  summary.append(controls);
}

let latestSnapshot = null;

document.getElementById("capture").addEventListener("click", async () => {
  const tab = await getActiveTab();
  if (!tab?.id) return;

  const [result] = await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    func: captureAccessibilitySnapshot
  });

  latestSnapshot = result.result;
  await chrome.storage.local.set({ latestAccessibilitySnapshot: latestSnapshot });
  render(latestSnapshot);
});

document.getElementById("copy").addEventListener("click", async () => {
  if (!latestSnapshot) return;
  const note = document.getElementById("note").value.trim();
  const text = [
    "Accessibility evidence",
    `URL: ${latestSnapshot.url}`,
    `Title: ${latestSnapshot.title}`,
    `Captured: ${latestSnapshot.capturedAt}`,
    "",
    "Tester note:",
    note || "Add expected result, actual result, and assistive technology observation.",
    "",
    "Headings:",
    JSON.stringify(latestSnapshot.headings, null, 2),
    "",
    "Focused element:",
    JSON.stringify(latestSnapshot.focus, null, 2),
    "",
    "Controls and labels:",
    JSON.stringify(latestSnapshot.controls, null, 2)
  ].join("\n");

  await navigator.clipboard.writeText(text);
});

Step 5: Test the Extension Locally

Open chrome://extensions, enable Developer mode, choose Load unpacked, and select the extension folder. Open the page you want to test, click the extension action, and choose Capture current tab.

For the first test, use a simple page with a form, a few headings, and a button. Confirm that the side panel shows the expected URL, heading list, labels, focus summary, and controls. Then test a page with a known accessibility issue, such as an icon-only button without a useful label. The snapshot should make that gap easier to describe in a bug report.

What to Put in the Bug Report

A good accessibility bug report should still be human-readable. Use the extension output as supporting evidence, not as the whole report.

Title: Checkout submit button has no accessible label

Environment: Chrome, staging checkout page
URL: https://example.test/checkout

Expected:
Keyboard and screen-reader users should identify the submit action from the button name.

Actual:
The visible icon button submits the form, but the captured accessibility snapshot shows no clear label for the focused button.

Evidence:
- Focused element: button, role empty, label empty, tabIndex 0
- Nearby heading: h1 Checkout
- Tester note: Reproduced with keyboard tab navigation

Impact:
Users relying on accessible names may not understand the control purpose.

Privacy and Accuracy Guardrails

Before sharing evidence, review it in the side panel. Remove anything that looks like customer information, internal identifiers, private URLs, or environment secrets. Do not capture full page text by default. Do not store long-term evidence unless your team has a retention rule for QA artifacts.

Also be precise about what this extension can and cannot prove. DOM attributes and ARIA metadata are useful clues, but they do not fully represent the computed accessibility tree in every situation. Chrome DevTools can inspect accessibility properties directly, and real assistive technology testing may still be required for the final defect assessment.

Screenshot Plan

For a tutorial or team SOP, capture these screenshots:

  • The extension folder with manifest.json, side panel files, and service worker.
  • Chrome Extensions page showing the unpacked QA Accessibility Snapshot extension loaded.
  • A test page with keyboard focus on the problematic element.
  • The side panel after capture, showing headings and controls.
  • The final bug report with expected result, actual result, and evidence summary.

How to Improve the Helper Later

Once the basic workflow is stable, add features carefully. Useful next steps include export to Markdown, a redaction checkbox, a maximum item count setting, a focus-order capture mode, and a team template for Jira or Azure DevOps. Keep the tool small enough that testers trust and review the evidence before filing it.

Conclusion

Chrome extension accessibility snapshot evidence gives QA teams a practical way to attach roles, labels, headings, focus details, and tester notes to accessibility bugs. Use it as a lightweight evidence helper, then confirm important findings with Chrome DevTools, accessibility testing tools, assistive technology checks, and human review.

Sources Reviewed