Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const {
injectAxe,
observeAxeMutations,
runAxe,
runAxeInWindow,
startAxeMutationObserver,
startAxeMutationObserverInWindow,
} = ChromeUtils.importESModule(
);
const tabmail = document.getElementById("tabmail");
const watchers = [];
let browser;
add_setup(async function () {
await SpecialPowers.pushPrefEnv({
set: [
["ui.useAccessibilityTheme", 0],
["ui.accentcolor", "#007aff"],
["ui.accentcolortext", "#ffffff"],
["widget.non-native-theme.use-theme-accent", true],
],
});
const tab = tabmail.openTab("contentTab", {
});
await BrowserTestUtils.browserLoaded(tab.browser);
browser = tab.browser;
registerCleanupFunction(async () => {
for (const watcher of watchers) {
await watcher.finish({ assert: false });
}
tabmail.closeOtherTabs(tabmail.tabInfo[0]);
});
});
/**
* Recreates low-contrast system and authored colors with identical RGB values.
*
* @returns {Element} The container for the contrast tests.
*/
function createContrastFixture() {
const doc = browser.contentWindow.document;
const fixture = doc.getElementById("fixture");
fixture.replaceChildren(
doc.getElementById("systemColors").content.cloneNode(true)
);
const shadow = doc
.getElementById("system-shadow")
.attachShadow({ mode: "open" });
const text = doc.createElement("span");
text.id = "shadow-text";
text.textContent = "Inherited shadow colors";
shadow.append(text);
return fixture;
}
/**
* Checks that only matched system colors receive the contrast exception.
*
* @param {boolean} inContent - Whether to use the spawned content API.
* @returns {Promise<void>} Resolves after checking the audit results.
*/
async function checkSystemColors(inContent) {
createContrastFixture();
const win = browser.contentWindow;
const doc = win.document;
const nativeStyle = win.getComputedStyle(doc.getElementById("system-direct"));
Assert.equal(
nativeStyle.color,
"rgb(255, 255, 255)",
"System text should use the overridden white color"
);
Assert.equal(
nativeStyle.backgroundColor,
"rgb(0, 122, 255)",
"System background should use the overridden blue color"
);
const options = {
context: "#fixture",
axeOptions: { runOnly: ["color-contrast", "button-name"] },
};
const results = inContent
? await runAxe(browser, { ...options, specialPowers: SpecialPowers })
: await runAxeInWindow(win, options);
if (results.skipped) {
Assert.ok(results.skippedReason, "Skipped builds should report a reason");
return;
}
const contrast = results.violations.find(
violation => violation.id == "color-contrast"
);
Assert.ok(
contrast,
"Authored colors should still cause a contrast violation"
);
const targets = contrast.nodes.map(node => node.target.flat().join(" "));
for (const id of [
"system-direct",
"system-variable",
"system-inherited",
"shadow-text",
]) {
Assert.ok(
!targets.some(target => target.includes(`#${id}`)),
`${id} should receive the matched system-color exception`
);
}
for (const id of [
"literal",
"literal-text",
"literal-background",
"mixed-text",
"text-fill",
]) {
Assert.ok(
targets.some(target => target.endsWith(`#${id}`)),
`${id} should retain its contrast failure`
);
}
Assert.ok(
results.violations.some(
violation =>
violation.id == "button-name" &&
violation.nodes.some(node => node.target.includes("#unnamed"))
),
"System-colored controls should still fail other accessibility rules"
);
}
add_task(async function test_windowSystemColors() {
await checkSystemColors(false);
});
add_task(async function test_contentSystemColors() {
await checkSystemColors(true);
});
/**
* Checks contrast during CSS transitions and after the transitions finish.
*
* @param {boolean} inContent - Whether to use the spawned content API.
* @returns {Promise<void>} Resolves after checking both audit results.
*/
async function checkColorTransitions(inContent) {
const win = browser.contentWindow;
const doc = win.document;
const fixture = doc.getElementById("fixture");
fixture.replaceChildren(
doc.getElementById("colorTransitions").content.cloneNode(true)
);
const transitions = [];
for (const element of fixture.querySelectorAll(".transitioning")) {
Assert.equal(
win.getComputedStyle(element).backgroundColor,
"rgb(0, 0, 0)",
`${element.id} should start with a black background`
);
element.classList.add("transition-end");
const [transition] = element.getAnimations();
Assert.ok(transition, `${element.id} should start a CSS transition`);
// Hold a low-contrast intermediate color until the audit completes.
transition.pause();
transition.currentTime = 990;
await transition.ready;
transitions.push(transition);
}
const options = {
context: "#fixture",
axeOptions: { runOnly: ["color-contrast", "button-name"] },
};
const run = () =>
inContent
? runAxe(browser, { ...options, specialPowers: SpecialPowers })
: runAxeInWindow(win, options);
const during = await run();
for (const transition of transitions) {
transition.finish();
await transition.finished;
}
if (during.skipped) {
Assert.ok(during.skippedReason, "Skipped builds should report a reason");
return;
}
const duringContrast = during.violations.find(
violation => violation.id == "color-contrast"
);
Assert.deepEqual(
duringContrast?.nodes.map(node => node.target.flat().join(" ")).sort(),
["#transition-static"],
"Only unrelated settled text should fail contrast during transitions"
);
Assert.ok(
during.violations.some(
violation =>
violation.id == "button-name" &&
violation.nodes.some(node =>
node.target.includes("#transition-unnamed")
)
),
"Other accessibility rules should still check transitioning elements"
);
const after = await run();
const afterContrast = after.violations.find(
violation => violation.id == "color-contrast"
);
Assert.deepEqual(
afterContrast?.nodes.map(node => node.target.flat().join(" ")).sort(),
["#transition-child", "#transition-direct", "#transition-static"],
"The scan should report low-contrast text after the transitions finish"
);
}
add_task(async function test_windowColorTransitions() {
await checkColorTransitions(false);
});
add_task(async function test_contentColorTransitions() {
await checkColorTransitions(true);
});
/**
* Checks color provenance before and after a watcher sees identical RGB values.
*
* @param {boolean} inContent - Whether to use the spawned content watcher.
* @returns {Promise<void>} Resolves after the watcher finishes.
*/
async function checkSystemColorWatcher(inContent) {
const fixture = createContrastFixture();
const text = fixture.querySelector("#system-direct");
fixture.replaceChildren(text);
const options = {
autoFinish: false,
container: "#fixture",
axeOptions: { runOnly: ["color-contrast"] },
};
const watcher = inContent
? await startAxeMutationObserver(browser, {
...options,
specialPowers: SpecialPowers,
})
: await startAxeMutationObserverInWindow(browser.contentWindow, options);
watchers.push(watcher);
const initial = await watcher.flush();
if (initial.skipped) {
Assert.ok(initial.skippedReason, "Skipped builds should report a reason");
await watcher.finish({ assert: false });
return;
}
Assert.equal(
initial.errors.length,
0,
"The native-color watcher should have no errors"
);
Assert.equal(
initial.failures.length,
0,
"The watcher should exclude the matched system pair"
);
text.style.color = "#ffffff";
const changed = await watcher.finish({ assert: false });
Assert.equal(
changed.errors.length,
0,
"The authored-color watcher should have no errors"
);
Assert.ok(
changed.failures.some(failure =>
failure.violations.some(violation => violation.id == "color-contrast")
),
"The watcher should retain an authored contrast failure after the RGB values stay unchanged"
);
}
add_task(async function test_windowSystemColorMutation() {
await checkSystemColorWatcher(false);
});
add_task(async function test_contentSystemColorMutation() {
await checkSystemColorWatcher(true);
});
/**
* Checks automatic shadow mutation runs through either public watcher API.
*
* @param {boolean} inContent - Whether to use the spawned content watcher.
* @returns {Promise<void>} Resolves after checking the final watcher report.
*/
async function checkWatcher(inContent) {
const win = browser.contentWindow;
const doc = win.document;
const fixture = doc.getElementById("fixture");
Assert.ok(doc.l10n, "The fixture should have Fluent localization");
fixture.replaceChildren();
/**
* Creates a shadow host containing a named button.
*
* @param {Element|ShadowRoot} parent - Parent for the new host.
* @returns {object} The host, open root, and button.
*/
function addHost(parent) {
const host = doc.createElement("div");
const root = host.attachShadow({ mode: "open" });
const button = doc.createElement("button");
button.textContent = "Named button";
root.append(button);
parent.append(host);
return { host, root, button };
}
const existing = addHost(fixture);
const nested = addHost(existing.root);
const lateHost = doc.createElement("div");
let watcher = null;
watchers.push({
async finish() {
if (watcher) {
await watcher.finish({ assert: false });
}
doc.l10n.disconnectRoot(existing.root);
},
});
const [initialMessage, updatedMessage] = await doc.l10n.formatMessages([
{ id: "tb-banner-show-more" },
{ id: "tb-banner-show-less" },
]);
doc.l10n.connectRoot(existing.root);
doc.l10n.setAttributes(existing.button, "tb-banner-show-more");
nested.root.append(lateHost);
await injectAxe(browser, { specialPowers: SpecialPowers });
const axe = win.wrappedJSObject.axe;
const originalRun = axe.run;
const checkedLabels = [];
let completedRuns = 0;
watchers.push({
finish() {
axe.run = originalRun;
},
});
axe.run = async (...args) => {
checkedLabels.push(existing.button.textContent);
const result = await originalRun.apply(axe, args);
completedRuns++;
return result;
};
const options = {
autoFinish: false,
container: "#fixture",
axeOptions: { runOnly: ["button-name"] },
throttleMs: 500,
};
watcher = inContent
? await startAxeMutationObserver(browser, {
...options,
specialPowers: SpecialPowers,
})
: await startAxeMutationObserverInWindow(win, options);
const initial = await watcher.flush();
if (initial.skipped) {
Assert.ok(initial.skippedReason, "Skipped builds should report a reason");
await watcher.finish({ assert: false });
axe.run = originalRun;
doc.l10n.disconnectRoot(existing.root);
return;
}
Assert.ok(
checkedLabels.length,
"Initial checks should scan the shadow button"
);
Assert.ok(
checkedLabels.every(label => label == initialMessage.value),
"Initial checks should wait for the translated shadow button label"
);
// With no DOM changes, flushing must not start another translation cycle.
const settled = await watcher.flush();
Assert.equal(
settled.mutationCount,
initial.mutationCount,
"Checks should not create mutations by translating unchanged shadow text"
);
Assert.equal(
settled.runCount,
initial.runCount + 1,
"An idle flush should run only its explicitly requested check"
);
const beforeTranslation = completedRuns;
checkedLabels.length = 0;
doc.l10n.setAttributes(existing.button, "tb-banner-show-less");
await TestUtils.waitForCondition(
() => completedRuns > beforeTranslation,
"A Fluent label change should trigger an automatic axe run"
);
Assert.ok(
checkedLabels.every(label => label == updatedMessage.value),
"Automatic checks should wait for the updated shadow button translation"
);
await watcher.flush();
/**
* Waits for an automatic axe run after changing a shadow button's label.
*
* @param {HTMLButtonElement} button - Button inside an observed shadow root.
* @returns {Promise<void>} Resolves after an automatic axe run completes.
*/
async function changeLabel(button) {
const before = completedRuns;
button.firstChild.data = "Updated label";
await TestUtils.waitForCondition(
() => completedRuns > before,
"A shadow mutation should trigger an automatic axe run"
);
}
const beforeBurst = completedRuns;
existing.button.textContent = "First change";
existing.button.textContent = "Second change";
await TestUtils.waitForTick();
Assert.equal(
completedRuns,
beforeBurst,
"The throttle should defer a burst immediately after a flush"
);
await TestUtils.waitForCondition(
() => completedRuns > beforeBurst,
"The throttled burst should eventually trigger an automatic check"
);
await changeLabel(existing.button);
await changeLabel(nested.button);
const added = addHost(doc.createElement("div"));
fixture.append(added.host.parentNode);
await watcher.flush();
await changeLabel(added.button);
const lateRoot = lateHost.attachShadow({ mode: "open" });
const lateButton = doc.createElement("button");
const beforeAttachment = completedRuns;
lateButton.textContent = "Late button";
lateRoot.append(lateButton);
await TestUtils.waitForCondition(
() => completedRuns > beforeAttachment,
"A later shadow attachment should trigger an automatic axe run"
);
await changeLabel(lateButton);
const beforeViolation = completedRuns;
lateButton.textContent = "";
await TestUtils.waitForCondition(
() => completedRuns > beforeViolation,
"An unnamed shadow button should trigger an automatic axe run"
);
const report = await watcher.finish({ assert: false });
Assert.equal(report.errors.length, 0, "The watcher should have no errors");
Assert.ok(
report.failures.some(
failure =>
failure.reason == "mutation" &&
failure.violations.some(violation => violation.id == "button-name")
),
"Automatic checks should record the unnamed shadow button"
);
const finishedRuns = completedRuns;
lateButton.textContent = "Named again";
addHost(fixture);
await TestUtils.waitForTick();
const finishedReport = await watcher.flush();
Assert.deepEqual(
finishedReport,
report,
"Finished reports should stay fixed"
);
Assert.equal(
completedRuns,
finishedRuns,
"Finished watchers should stop runs"
);
axe.run = originalRun;
doc.l10n.disconnectRoot(existing.root);
}
add_task(async function test_windowShadowMutations() {
await checkWatcher(false);
});
add_task(async function test_contentShadowMutations() {
await checkWatcher(true);
});
add_task(async function test_observationScopeAndCleanup() {
const win = browser.contentWindow;
const doc = win.document;
const fixture = doc.getElementById("fixture");
fixture.replaceChildren();
const options = { attributes: true, childList: true, subtree: true };
const originalEnabled = doc.devToolsAnonymousAndShadowEventsEnabled;
let firstCount = 0;
let secondCount = 0;
const first = observeAxeMutations(win, fixture, options, count => {
firstCount += count;
});
const second = observeAxeMutations(win, fixture, options, count => {
secondCount += count;
});
watchers.push({ finish: () => first.disconnect() });
watchers.push({ finish: () => second.disconnect() });
const host = doc.createElement("div");
fixture.append(host);
await TestUtils.waitForCondition(
() => firstCount && secondCount,
"Both observers should see the inserted host"
);
first.disconnect();
const firstStoppedCount = firstCount;
const beforeSecond = secondCount;
const root = host.attachShadow({ mode: "open" });
root.append(doc.createElement("button"));
await TestUtils.waitForCondition(
() => secondCount > beforeSecond,
"The remaining observer should see a later shadow root"
);
Assert.equal(firstCount, firstStoppedCount, "The first observer should stop");
const closedHost = doc.createElement("div");
const closedInserted = secondCount;
fixture.append(closedHost);
await TestUtils.waitForCondition(
() => secondCount > closedInserted,
"The observer should see the closed root's host insertion"
);
const eventTarget = win.docShell.chromeEventHandler;
const attached = BrowserTestUtils.waitForEvent(
eventTarget,
"shadowrootattached",
true,
event => event.target == closedHost
);
const closedRoot = closedHost.attachShadow({ mode: "closed" });
await attached;
const beforeExcluded = secondCount;
closedRoot.append(doc.createElement("button"));
doc.getElementById("outside").append(doc.createElement("button"));
host.setAttribute("aria-label", "Mutation delivery barrier");
await TestUtils.waitForCondition(
() => secondCount > beforeExcluded,
"The observer should deliver the in-scope attribute change"
);
Assert.equal(
secondCount,
beforeExcluded + 1,
"Closed roots and nodes outside the container should be excluded"
);
second.disconnect();
Assert.equal(
doc.devToolsAnonymousAndShadowEventsEnabled,
originalEnabled,
"The final observer should restore shadow event enablement"
);
});
add_task(async function test_attributeOnlyDiscovery() {
const win = browser.contentWindow;
const doc = win.document;
const fixture = doc.getElementById("fixture");
const host = doc.createElement("div");
const root = host.attachShadow({ mode: "open" });
const button = doc.createElement("button");
root.append(button);
let count = 0;
const observer = observeAxeMutations(
win,
fixture,
{ attributes: true, subtree: true },
changes => {
count += changes;
}
);
watchers.push({ finish: () => observer.disconnect() });
fixture.append(host);
// Mutation delivery must discover the inserted root before its next change.
await TestUtils.waitForTick();
button.setAttribute("aria-label", "Named button");
await TestUtils.waitForCondition(
() => count,
"Attribute-only observers should discover roots in inserted hosts"
);
Assert.equal(count, 1, "Discovery-only child changes should not be reported");
const lateHost = doc.createElement("div");
fixture.append(lateHost);
const attached = BrowserTestUtils.waitForEvent(
win.docShell.chromeEventHandler,
"shadowrootattached",
true,
event => event.originalTarget == lateHost
);
const lateRoot = lateHost.attachShadow({ mode: "open" });
await attached;
Assert.equal(
count,
1,
"Attribute-only observers should exclude shadow attachments"
);
const lateButton = doc.createElement("button");
lateRoot.append(lateButton);
lateButton.setAttribute("aria-label", "Late button");
await TestUtils.waitForCondition(
() => count > 1,
"Attribute-only observers should observe changes inside later roots"
);
Assert.equal(
count,
2,
"Only the requested attribute changes should be reported"
);
observer.disconnect();
});
add_task(async function test_chromeWindowAttachment() {
const host = document.createElementNS("http://www.w3.org/1999/xhtml", "div");
let count = 0;
const observer = observeAxeMutations(
window,
host,
{ childList: true, subtree: true },
changes => {
count += changes;
}
);
watchers.push({
finish() {
observer.disconnect();
host.remove();
},
});
document.documentElement.append(host);
host.attachShadow({ mode: "open" });
await TestUtils.waitForCondition(
() => count,
"The top-level chrome window should report a shadow attachment"
);
Assert.equal(
count,
1,
"The chrome window should report one shadow attachment"
);
observer.disconnect();
host.remove();
});