Input에 대해 input validity기반 a11y 요소 기반 테스트 메서드인 expect(locator).toHaveAccessibleErrorMessage()
를 추가해달라는 요청이었다.
3주 동안 w3c 스펙을 기반으로 에러 메시지를 반환하는 경우
의 정의에 대한 토론과 추상화 및 피드백을 진행하였다.
요구사항이 생각보다 많이 복잡했다.
추상화를 진행하기 전, 아래의 것들을 파악했다.
- w3c Spec
- 다른 라이브러리의 추상화 방식
aria 속성과 에러메시지를 사용하여 이를 해결하려 했지만, maintainer님이 Input Validity의 속성도 파악하시길 원했다.
올바른 role 파악 > aria-invalid 필드기반 토큰 반환 > inputValidity 파악 > 둘 중 문제가 하나라도 있는 경우 유효하지 않는 상태로 파악
3주 동안 추상화 방식와 테스트코드가 계속 변하였고 최종적으로 나오게된 피쳐는 아래와 같다.
TC에 대한 오버헤드를 우려해 엣지케이스에 대한 테스트들은 전부 확인한 뒤 제거한 코드이다.
에러 메시지가 여러개인 경우, aria-invalid가 반환하는 여러가지 토큰들, inputValidity 자체의 값.
이것들이 errorMessage를 반환하는 의사결정의 분기에 의존성이 있어 단순히 머릿속으로 이해한 뒤 추상화 하기엔 조금 어려웠다.
머리를 오래 쓰기 싫어서 Spec 및 토의결과 기반으로 TDD를 진행했고, 오랜만에 그것의 효용성을 느끼게 되었다.
(복잡한 Spec 및 많은 의사결정 분기 기반 피쳐는 TDD가 확실히 편한듯 하다)
test('toHaveAccessibleErrorMessage', async ({ page }) => {
await page.setContent(`
<form>
<input role="textbox" aria-invalid="true" aria-errormessage="error-message" />
<div id="error-message">Hello</div>
<div id="irrelevant-error">This should not be considered.</div>
</form>
`);
const locator = page.locator('input[role="textbox"]');
await expect(locator).toHaveAccessibleErrorMessage('Hello');
await expect(locator).not.toHaveAccessibleErrorMessage('hello');
await expect(locator).toHaveAccessibleErrorMessage('hello', { ignoreCase: true });
await expect(locator).toHaveAccessibleErrorMessage(/ell\w/);
await expect(locator).not.toHaveAccessibleErrorMessage(/hello/);
await expect(locator).toHaveAccessibleErrorMessage(/hello/, { ignoreCase: true });
await expect(locator).not.toHaveAccessibleErrorMessage('This should not be considered.');
});
test('toHaveAccessibleErrorMessage should handle multiple aria-errormessage references', async ({ page }) => {
await page.setContent(`
<form>
<input role="textbox" aria-invalid="true" aria-errormessage="error1 error2" />
<div id="error1">First error message.</div>
<div id="error2">Second error message.</div>
<div id="irrelevant-error">This should not be considered.</div>
</form>
`);
const locator = page.locator('input[role="textbox"]');
await expect(locator).toHaveAccessibleErrorMessage('First error message. Second error message.');
await expect(locator).toHaveAccessibleErrorMessage(/first error message./i);
await expect(locator).toHaveAccessibleErrorMessage(/second error message./i);
await expect(locator).not.toHaveAccessibleErrorMessage(/This should not be considered./i);
});
test.describe('toHaveAccessibleErrorMessage should handle aria-invalid attribute', () => {
const errorMessageText = 'Error message';
async function setupPage(page, ariaInvalidValue: string | null) {
const ariaInvalidAttr = ariaInvalidValue === null ? '' : `aria-invalid="${ariaInvalidValue}"`;
await page.setContent(`
<form>
<input id="node" role="textbox" ${ariaInvalidAttr} aria-errormessage="error-msg" />
<div id="error-msg">${errorMessageText}</div>
</form>
`);
return page.locator('#node');
}
test.describe('evaluated in false', () => {
test('no aria-invalid attribute', async ({ page }) => {
const locator = await setupPage(page, null);
await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
});
test('aria-invalid="false"', async ({ page }) => {
const locator = await setupPage(page, 'false');
await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
});
test('aria-invalid="" (empty string)', async ({ page }) => {
const locator = await setupPage(page, '');
await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
});
});
test.describe('evaluated in true', () => {
test('aria-invalid="true"', async ({ page }) => {
const locator = await setupPage(page, 'true');
await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
});
test('aria-invalid="foo" (unrecognized value)', async ({ page }) => {
const locator = await setupPage(page, 'foo');
await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
});
});
});
test.describe('toHaveAccessibleErrorMessage should handle validity state with aria-invalid', () => {
const errorMessageText = 'Error message';
test('should show error message when validity is false and aria-invalid is true', async ({ page }) => {
await page.setContent(`
<form>
<input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="true" aria-errormessage="error-msg" />
<div id="error-msg">${errorMessageText}</div>
</form>
`);
const locator = page.locator('#node');
await locator.fill('101');
await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
});
test('should show error message when validity is true and aria-invalid is true', async ({ page }) => {
await page.setContent(`
<form>
<input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="true" aria-errormessage="error-msg" />
<div id="error-msg">${errorMessageText}</div>
</form>
`);
const locator = page.locator('#node');
await locator.fill('99');
await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
});
test('should show error message when validity is false and aria-invalid is false', async ({ page }) => {
await page.setContent(`
<form>
<input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="false" aria-errormessage="error-msg" />
<div id="error-msg">${errorMessageText}</div>
</form>
`);
const locator = page.locator('#node');
await locator.fill('101');
await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
});
test('should not show error message when validity is true and aria-invalid is false', async ({ page }) => {
await page.setContent(`
<form>
<input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="false" aria-errormessage="error-msg" />
<div id="error-msg">${errorMessageText}</div>
</form>
`);
const locator = page.locator('#node');
await locator.fill('99');
await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
});
});
Maintainer님의 의견에 따라 utils에 로직을 분리하고 기존 컨벤션대로 로직 추가
// https://www.w3.org/TR/wai-aria-1.2/#aria-invalid
const kAriaInvalidRoles = ['application', 'checkbox', 'combobox', 'gridcell', 'listbox', 'radiogroup', 'slider', 'spinbutton', 'textbox', 'tree', 'columnheader', 'rowheader', 'searchbox', 'switch', 'treegrid'];
function getAriaInvalid(element: Element): 'false' | 'true' | 'grammar' | 'spelling' {
const role = getAriaRole(element) || '';
if (!role || !kAriaInvalidRoles.includes(role))
return 'false';
const ariaInvalid = element.getAttribute('aria-invalid');
if (!ariaInvalid || ariaInvalid.trim() === '' || ariaInvalid.toLocaleLowerCase() === 'false')
return 'false';
if (ariaInvalid === 'true' || ariaInvalid === 'grammar' || ariaInvalid === 'spelling')
return ariaInvalid;
return 'true';
}
function getValidityInvalid(element: Element) {
if ('validity' in element){
const validity = element.validity as ValidityState | undefined;
return validity?.valid === false;
}
return false;
}
export function getElementAccessibleErrorMessage(element: Element): string {
// SPEC: https://w3c.github.io/aria/#aria-errormessage
//
// TODO: support https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/validationMessage
const cache = cacheAccessibleErrorMessage;
let accessibleErrorMessage = cacheAccessibleErrorMessage?.get(element);
if (accessibleErrorMessage === undefined) {
accessibleErrorMessage = '';
const isAriaInvalid = getAriaInvalid(element) !== 'false';
const isValidityInvalid = getValidityInvalid(element);
if (isAriaInvalid || isValidityInvalid) {
const errorMessageId = element.getAttribute('aria-errormessage');
const errorMessages = getIdRefs(element, errorMessageId);
// Ideally, this should be a separate "embeddedInErrorMessage", but it would follow the exact same rules.
// Relevant vague spec: https://w3c.github.io/core-aam/#ariaErrorMessage.
const parts = errorMessages.map(errorMessage => asFlatString(
getTextAlternativeInternal(errorMessage, {
visitedElements: new Set(),
embeddedInDescribedBy: { element: errorMessage, hidden: isElementHiddenForAria(errorMessage) },
})
));
accessibleErrorMessage = parts.join(' ').trim();
}
cache?.set(element, accessibleErrorMessage);
}
return accessibleErrorMessage;
}
export function toHaveAccessibleErrorMessage(
this: ExpectMatcherState,
locator: LocatorEx,
expected: string | RegExp,
options?: { timeout?: number; ignoreCase?: boolean },
) {
return toMatchText.call(this, 'toHaveAccessibleErrorMessage', locator, 'Locator', async (isNot, timeout) => {
const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase, normalizeWhiteSpace: true });
return await locator._expect('to.have.accessible.error.message', { expectedText: expectedText, isNot, timeout });
}, expected, options);
}
말한대로 3주 동안 추상화 방식, 에러 판단 방식 등 계속해서 변경되었다.
추상화 > 리뷰 요청 > 피드백 > ... 반복 > merged
3주 간의 41개의 커뮤니케이션 끝에 merged 되었다.
그래도 공식 스펙과 버젼을 인지하고 추상화하는 연습을 잘 할 수 있었던 PR이라 다음엔 더 나은 코드로 보답하는걸로...!