diff --git a/src/firefox/snapshot/formatter.ts b/src/firefox/snapshot/formatter.ts
index c46429f4..d48f33ad 100644
--- a/src/firefox/snapshot/formatter.ts
+++ b/src/firefox/snapshot/formatter.ts
@@ -60,6 +60,14 @@ export function formatSnapshotTree(
attrs.push(`tag=${node.tag}`);
}
+ if (node.type) {
+ attrs.push(`type=${node.type}`);
+ }
+
+ if (node.checked !== undefined) {
+ attrs.push(node.checked ? 'checked' : 'unchecked');
+ }
+
// Value
if (node.value) {
attrs.push(`value="${truncate(node.value, maxAttrLength)}"`);
diff --git a/src/firefox/snapshot/injected/attributeCollector.ts b/src/firefox/snapshot/injected/attributeCollector.ts
index 67b891dd..d2a416bd 100644
--- a/src/firefox/snapshot/injected/attributeCollector.ts
+++ b/src/firefox/snapshot/injected/attributeCollector.ts
@@ -20,13 +20,11 @@ export function getElementName(el: Element): string | undefined {
return el.getAttribute('aria-label') || undefined;
}
- // label for input
- const htmlEl = el as HTMLElement;
- const elId = htmlEl.id;
- if (elId) {
- const label = document.querySelector(`label[for="${elId}"]`);
- if (label?.textContent) {
- return label.textContent.trim();
+ const label = (el as HTMLInputElement).labels?.[0];
+ if (label) {
+ const name = getLabelText(label);
+ if (name) {
+ return name;
}
}
@@ -54,6 +52,18 @@ export function getElementName(el: Element): string | undefined {
return undefined;
}
+/**
+ * Text of a label without the controls it wraps, so that a select does not name itself after its
+ * options
+ */
+function getLabelText(label: HTMLLabelElement): string | undefined {
+ const copy = label.cloneNode(true) as HTMLLabelElement;
+ for (const control of copy.querySelectorAll('input, select, textarea, button')) {
+ control.remove();
+ }
+ return copy.textContent?.replace(/\s+/g, ' ').trim() || undefined;
+}
+
/**
* Get direct text content (not from deep children)
*/
diff --git a/src/firefox/snapshot/injected/treeWalker.ts b/src/firefox/snapshot/injected/treeWalker.ts
index 12774e8b..d023e27d 100644
--- a/src/firefox/snapshot/injected/treeWalker.ts
+++ b/src/firefox/snapshot/injected/treeWalker.ts
@@ -159,7 +159,9 @@ export function walkTree(
const roleAttr = el.getAttribute('role');
const nameAttr = getElementName(el);
const textAttr = getTextContent(el);
- const valueAttr = (htmlEl as any).value;
+ const inputType = tag === 'input' ? (el as HTMLInputElement).type : undefined;
+ const isToggle = inputType === 'checkbox' || inputType === 'radio';
+ const valueAttr = inputType === 'password' ? undefined : (htmlEl as any).value;
const hrefAttr = (htmlEl as any).href;
const srcAttr = (htmlEl as any).src;
const ariaAttr = getAriaAttributes(el);
@@ -171,6 +173,8 @@ export function walkTree(
...(idAttr && { id: idAttr }),
...(roleAttr && { role: roleAttr }),
...(nameAttr && { name: nameAttr }),
+ ...(inputType && inputType !== 'text' && { type: inputType }),
+ ...(isToggle && { checked: (el as HTMLInputElement).checked }),
...(valueAttr && { value: valueAttr }),
...(hrefAttr && { href: hrefAttr }),
...(srcAttr && { src: srcAttr }),
diff --git a/src/firefox/snapshot/types.ts b/src/firefox/snapshot/types.ts
index 2ce022bb..c302fe55 100644
--- a/src/firefox/snapshot/types.ts
+++ b/src/firefox/snapshot/types.ts
@@ -41,6 +41,10 @@ export interface SnapshotNode {
id?: string;
role?: string;
name?: string;
+ /** Input type, left out for text inputs */
+ type?: string;
+ /** Checked state of a checkbox or radio button */
+ checked?: boolean;
value?: string;
href?: string;
src?: string;
diff --git a/tests/integration/firefox/snapshot.test.ts b/tests/integration/firefox/snapshot.test.ts
index f2441e7d..7620f722 100644
--- a/tests/integration/firefox/snapshot.test.ts
+++ b/tests/integration/firefox/snapshot.test.ts
@@ -233,4 +233,73 @@ describe('Snapshot Integration Tests', () => {
expect(hasInvisibleButton).toBe(false);
}, 10000);
+
+ describe('form controls', () => {
+ const CONTROLS_PAGE =
+ 'data:text/html,' +
+ '' +
+ '' +
+ '' +
+ '';
+
+ function lineOf(text: string, name: string): string | undefined {
+ return text.split('\n').find((line) => line.includes(`"${name}"`));
+ }
+
+ it('should name a control after the label that wraps it', async () => {
+ await firefox.navigate(CONTROLS_PAGE);
+
+ const { text } = await firefox.takeSnapshot();
+
+ expect(lineOf(text, 'Username')).toContain('input');
+ expect(lineOf(text, 'Remember me')).toContain('input');
+ }, 10000);
+
+ it('should leave the options out of the name of a select', async () => {
+ await firefox.navigate(CONTROLS_PAGE);
+
+ const { text } = await firefox.takeSnapshot();
+
+ expect(lineOf(text, 'Team')).toContain('select');
+ }, 10000);
+
+ it('should still name a control after a label pointing at it by id', async () => {
+ await firefox.navigate(CONTROLS_PAGE);
+
+ const { text } = await firefox.takeSnapshot();
+
+ expect(lineOf(text, 'Mail')).toContain('input');
+ }, 10000);
+
+ it('should show the input type', async () => {
+ await firefox.navigate(CONTROLS_PAGE);
+
+ const { text } = await firefox.takeSnapshot();
+
+ expect(lineOf(text, 'Mail')).toContain('type=email');
+ expect(lineOf(text, 'Remember me')).toContain('type=checkbox');
+ }, 10000);
+
+ it('should show whether a checkbox is checked', async () => {
+ await firefox.navigate(CONTROLS_PAGE);
+
+ const before = await firefox.takeSnapshot();
+ const { uid } = findNodeInSnapshot(before.json.root, (n) => n.id === 'remember')!;
+ await firefox.clickByUid(uid);
+ const after = await firefox.takeSnapshot();
+
+ expect(lineOf(before.text, 'Remember me')).toContain('unchecked');
+ expect(lineOf(after.text, 'Remember me')).toMatch(/ checked( |$)/);
+ expect(lineOf(after.text, 'Remember me')).not.toContain('unchecked');
+ }, 10000);
+
+ it('should keep the value of a password field out of the snapshot', async () => {
+ await firefox.navigate(CONTROLS_PAGE);
+
+ const { text } = await firefox.takeSnapshot();
+
+ expect(lineOf(text, 'Password')).toContain('type=password');
+ expect(text).not.toContain('secret');
+ }, 10000);
+ });
});