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); + }); });