Source code
Revision control
Copy as Markdown
Other Tools
Test Info:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>ThemePicker Tests</title>
<link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
<link
rel="stylesheet"
/>
<script>
const DEFAULT_THEME_ID = "default-theme@mozilla.org";
// Loaded in add_setup so the tasks below can exercise a specific controller.
let ThemePicker;
let ThemePickerStorybookController;
let ThemePickerDirectController;
let ThemePickerRemoteController;
const PREF_SYSTEM_USES_DARK = "ui.systemUsesDarkTheme";
const PREF_NATIVE_THEME = "browser.theme.native-theme";
const PREF_ACTIVE_THEME_ID = "extensions.activeThemeID";
const { AppConstants } = ChromeUtils.importESModule(
"resource://gre/modules/AppConstants.sys.mjs"
);
let originalCreateController;
// The theme-picker element picks its controller in its constructor based on
// whether platform APIs are available. In this chrome test they always are,
// so we override the factory to deterministically exercise each controller.
function useController(ControllerClass) {
ThemePicker.createController = host => new ControllerClass(host);
}
async function waitForCondition(condition, message) {
for (let i = 0; i < 100; i++) {
if (condition()) {
return;
}
await new Promise(resolve => setTimeout(resolve, 20));
}
ok(false, `Timed out waiting for condition: ${message}`);
}
function setThemePrefs({ systemUsesDark, nativeTheme, activeThemeId }) {
if (systemUsesDark === null) {
Services.prefs.clearUserPref(PREF_SYSTEM_USES_DARK);
} else {
Services.prefs.setIntPref(PREF_SYSTEM_USES_DARK, systemUsesDark);
}
Services.prefs.setBoolPref(PREF_NATIVE_THEME, nativeTheme);
Services.prefs.setCharPref(PREF_ACTIVE_THEME_ID, activeThemeId);
}
async function renderThemePicker({
layout = "full",
installsource,
} = {}) {
await customElements.whenDefined("theme-picker");
let content = document.getElementById("content");
content.textContent = "";
let picker = document.createElement("theme-picker");
picker.layout = layout;
if (installsource) {
picker.setAttribute("installsource", installsource);
}
content.append(picker);
// The direct controller loads its theme list asynchronously, so wait for
// it before asserting on rendered state.
await waitForCondition(
() => picker.themes.length,
"The theme picker is populated with themes."
);
await picker.updateComplete;
return picker;
}
add_setup(async function () {
({ ThemePicker } =
await import("chrome://global/content/elements/theme-picker.mjs"));
({ ThemePickerStorybookController } =
await import("chrome://global/content/elements/theme-picker-storybook-controller.mjs"));
({ ThemePickerDirectController } =
await import("chrome://global/content/elements/theme-picker-direct-controller.mjs"));
({ ThemePickerRemoteController } =
await import("chrome://global/content/elements/theme-picker-remote-controller.mjs"));
originalCreateController = ThemePicker.createController;
let originalActiveThemeId = Services.prefs.getCharPref(
PREF_ACTIVE_THEME_ID,
DEFAULT_THEME_ID
);
SimpleTest.registerCleanupFunction(() => {
ThemePicker.createController = originalCreateController;
Services.prefs.clearUserPref(PREF_SYSTEM_USES_DARK);
Services.prefs.clearUserPref(PREF_NATIVE_THEME);
Services.prefs.setCharPref(
PREF_ACTIVE_THEME_ID,
originalActiveThemeId
);
});
});
// Storybook controller tests: the mocked controller populates the picker
// with themes and default state and mirrors themechange events back onto
// the host, without touching prefs or AddonManager.
add_task(async function testStorybookInitialState() {
useController(ThemePickerStorybookController);
let picker = await renderThemePicker();
ok(picker.themes.length, "Theme picker is populated with themes.");
is(
picker.activeThemeId,
DEFAULT_THEME_ID,
"The default theme is active initially."
);
is(picker.appearance, "device", "Device appearance is set initially.");
ok(!picker.nativeTheme, "Native theme is off initially.");
let items = picker.shadowRoot.querySelectorAll(
"moz-visual-picker-item"
);
is(
items.length,
picker.themes.length,
"A picker item is rendered for each theme."
);
let segmentedControl = picker.shadowRoot.querySelector(
"moz-segmented-control"
);
is(
segmentedControl.value,
"device",
"The segmented control reflects the current appearance."
);
});
// Changing appearance, theme, and native theme each dispatch a
// themechange event with the expected detail, and the mocked controller
// reflects the change back onto the picker.
add_task(async function testStorybookThemeChangeEvents() {
useController(ThemePickerStorybookController);
let picker = await renderThemePicker();
let events = [];
picker.addEventListener("themechange", e => events.push(e.detail));
picker.appearanceChange({ target: { value: "dark" } });
await picker.updateComplete;
is(picker.appearance, "dark", "Appearance is updated to dark.");
let secondTheme = picker.themes[1];
picker.themeChange({ target: { value: secondTheme.id } });
await picker.updateComplete;
is(
picker.activeThemeId,
secondTheme.id,
"The active theme is updated."
);
picker.nativeThemeChange({ target: { checked: true } });
await picker.updateComplete;
ok(picker.nativeTheme, "Native theme is turned on.");
SimpleTest.isDeeply(
events,
[
{ property: "appearance", value: "dark" },
{ property: "theme", value: secondTheme.id },
{ property: "nativeTheme", value: true },
],
"themechange events are dispatched with the expected details."
);
});
// The native theme checkbox is only enabled while the default theme is
// active.
add_task(async function testStorybookNativeThemeCheckboxDisabled() {
useController(ThemePickerStorybookController);
let picker = await renderThemePicker();
picker.showNativeThemeOption = true;
await picker.updateComplete;
let checkbox = picker.shadowRoot.querySelector("moz-checkbox");
ok(
!checkbox.disabled,
"The native theme checkbox is enabled for the default theme."
);
picker.themeChange({ target: { value: picker.themes[1].id } });
await picker.updateComplete;
ok(
checkbox.disabled,
"The native theme checkbox is disabled for non-default themes."
);
picker.themeChange({ target: { value: DEFAULT_THEME_ID } });
await picker.updateComplete;
ok(
!checkbox.disabled,
"The native theme checkbox is re-enabled for the default theme."
);
});
add_task(async function testNativeThemeCheckboxHiddenWhenOptionOff() {
useController(ThemePickerStorybookController);
let picker = await renderThemePicker();
picker.showNativeThemeOption = false;
await picker.updateComplete;
ok(
!picker.shadowRoot.querySelector("moz-checkbox"),
"The native theme checkbox is hidden when the option is off."
);
});
add_task(async function testNativeThemeCheckboxShownWhenOptionOn() {
useController(ThemePickerStorybookController);
let picker = await renderThemePicker();
picker.showNativeThemeOption = true;
await picker.updateComplete;
let checkbox = picker.shadowRoot.querySelector("moz-checkbox");
ok(
checkbox,
"The native theme checkbox is shown when the option is on."
);
ok(!checkbox.checked, "The checkbox reflects nativeTheme when off.");
picker.nativeTheme = true;
await picker.updateComplete;
ok(checkbox.checked, "The checkbox reflects nativeTheme when on.");
});
add_task(async function testDirectNativeThemeCheckboxLinuxOnly() {
useController(ThemePickerDirectController);
setThemePrefs({
systemUsesDark: null,
nativeTheme: false,
activeThemeId: DEFAULT_THEME_ID,
});
let picker = await renderThemePicker();
is(
!!picker.shadowRoot.querySelector("moz-checkbox"),
AppConstants.platform === "linux",
"The native theme checkbox is present only on Linux."
);
});
// Direct controller tests: the controller reads its initial state from
// prefs and writes appearance/native-theme changes back to them.
add_task(async function testDirectInitialState() {
useController(ThemePickerDirectController);
setThemePrefs({
systemUsesDark: null,
nativeTheme: false,
activeThemeId: DEFAULT_THEME_ID,
});
let picker = await renderThemePicker();
ok(picker.themes.length, "Theme picker is populated with themes.");
is(
picker.activeThemeId,
DEFAULT_THEME_ID,
"The active theme id is read from the pref."
);
is(
picker.appearance,
"device",
"Device appearance is used when the pref has no user value."
);
ok(!picker.nativeTheme, "Native theme reflects the pref value.");
let items = picker.shadowRoot.querySelectorAll(
"moz-visual-picker-item"
);
is(
items.length,
picker.themes.length,
"A picker item is rendered for each theme."
);
});
// The controller maps the appearance and native-theme prefs onto the host.
add_task(async function testDirectReflectsPrefs() {
useController(ThemePickerDirectController);
setThemePrefs({
systemUsesDark: 1,
nativeTheme: true,
activeThemeId: "nova-sun@mozilla.org",
});
let darkPicker = await renderThemePicker();
is(darkPicker.appearance, "dark", "systemUsesDark=1 maps to dark.");
ok(darkPicker.nativeTheme, "Native theme pref is reflected.");
is(
darkPicker.activeThemeId,
"nova-sun@mozilla.org",
"The active theme id pref is reflected."
);
setThemePrefs({
systemUsesDark: 0,
nativeTheme: false,
activeThemeId: DEFAULT_THEME_ID,
});
let lightPicker = await renderThemePicker();
is(lightPicker.appearance, "light", "systemUsesDark=0 maps to light.");
});
// Appearance changes are written to ui.systemUsesDarkTheme and mirrored
// back onto the host.
add_task(async function testDirectAppearanceChange() {
useController(ThemePickerDirectController);
setThemePrefs({
systemUsesDark: null,
nativeTheme: false,
activeThemeId: DEFAULT_THEME_ID,
});
let picker = await renderThemePicker();
let appearanceChooser = picker.shadowRoot.querySelector(
"moz-segmented-control"
);
let [light, dark, device] = [
...appearanceChooser.querySelectorAll("moz-segmented-control-item"),
];
synthesizeMouseAtCenter(dark, {});
await picker.updateComplete;
is(
Services.prefs.getIntPref(PREF_SYSTEM_USES_DARK),
1,
"Dark appearance sets the pref to 1."
);
is(
picker.appearance,
"dark",
"The host appearance is updated to dark."
);
synthesizeMouseAtCenter(light, {});
await picker.updateComplete;
is(
Services.prefs.getIntPref(PREF_SYSTEM_USES_DARK),
0,
"Light appearance sets the pref to 0."
);
is(
picker.appearance,
"light",
"The host appearance is updated to light."
);
synthesizeMouseAtCenter(device, {});
await picker.updateComplete;
ok(
!Services.prefs.prefHasUserValue(PREF_SYSTEM_USES_DARK),
"Device appearance clears the pref."
);
is(
picker.appearance,
"device",
"The host appearance is updated to device."
);
});
// The native theme checkbox is written to browser.theme.native-theme.
// The direct controller only shows the checkbox on Linux.
add_task(async function testDirectNativeThemeChange() {
if (AppConstants.platform !== "linux") {
ok(true, "The native theme checkbox is Linux-only.");
return;
}
useController(ThemePickerDirectController);
setThemePrefs({
systemUsesDark: null,
nativeTheme: false,
activeThemeId: DEFAULT_THEME_ID,
});
let picker = await renderThemePicker();
let checkbox = picker.shadowRoot.querySelector("moz-checkbox");
synthesizeMouseAtCenter(checkbox, {});
await picker.updateComplete;
ok(
Services.prefs.getBoolPref(PREF_NATIVE_THEME),
"The native theme pref is turned on."
);
ok(picker.nativeTheme, "The host native theme is turned on.");
synthesizeMouseAtCenter(checkbox, {});
await picker.updateComplete;
ok(
!Services.prefs.getBoolPref(PREF_NATIVE_THEME),
"The native theme pref is turned off."
);
ok(!picker.nativeTheme, "The host native theme is turned off.");
});
add_task(
async function testRemoteControllerForwardsHostLayoutAndSource() {
let host = document.createElement("div");
host.addController = () => {};
host.layout = "compact";
host.setAttribute("installsource", "test-source");
let details = new Map();
for (const eventType of [
"ThemePickerUpdateTheme",
"ThemePickerUpdateAppearance",
"ThemePickerUpdateNativeTheme",
"ThemePickerShown",
]) {
host.addEventListener(eventType, event => {
details.set(eventType, event.detail);
});
}
let controller = new ThemePickerRemoteController(host);
controller.hostConnected();
controller.onThemechange({
property: "theme",
value: "nova-sun@mozilla.org",
});
controller.onThemechange({ property: "appearance", value: "dark" });
controller.onThemechange({ property: "nativeTheme", value: true });
host.dispatchEvent(new CustomEvent("themepickershown"));
SimpleTest.isDeeply(
details.get("ThemePickerUpdateTheme"),
{ themeId: "nova-sun@mozilla.org", layout: "compact" },
"Theme updates carry the host layout."
);
SimpleTest.isDeeply(
details.get("ThemePickerUpdateAppearance"),
{
appearance: "dark",
installsource: "test-source",
layout: "compact",
},
"Appearance updates carry the host source and layout."
);
SimpleTest.isDeeply(
details.get("ThemePickerUpdateNativeTheme"),
{
nativeTheme: true,
installsource: "test-source",
layout: "compact",
},
"Native-theme updates carry the host source and layout."
);
SimpleTest.isDeeply(
details.get("ThemePickerShown"),
{ source: "test-source", layout: "compact" },
"Shown events carry the host source and layout."
);
controller.hostDisconnected();
}
);
add_task(async function testThemePickerShownDispatchesSemanticEvent() {
useController(ThemePickerStorybookController);
let picker = await renderThemePicker();
let content = document.getElementById("content");
let receivedEvent;
content.addEventListener(
"themepickershown",
event => {
receivedEvent = event;
},
{ once: true }
);
picker.shown();
ok(receivedEvent, "themepickershown bubbles to an ancestor.");
ok(receivedEvent?.bubbles, "themepickershown is configured to bubble.");
ok(receivedEvent?.composed, "themepickershown is composed.");
is(
receivedEvent?.detail,
null,
"themepickershown carries no telemetry detail."
);
});
add_task(
async function testDirectControllerRecordsShownOncePerDocument() {
useController(ThemePickerDirectController);
let picker = await renderThemePicker({
layout: "compact",
installsource: "test-source",
});
let secondPicker = document.createElement("theme-picker");
secondPicker.layout = "full";
secondPicker.setAttribute("installsource", "second-source");
document.getElementById("content").append(secondPicker);
await waitForCondition(
() => secondPicker.themes.length,
"The second theme picker is populated with themes."
);
await secondPicker.updateComplete;
await Services.fog.testFlushAllChildren();
Services.fog.testResetFOG();
picker.shown();
secondPicker.shown();
await Services.fog.testFlushAllChildren();
let events = Glean.themePicker.shown.testGetValue();
is(events?.length, 1, "theme_picker.shown is recorded once.");
is(events?.[0].extra.source, "test-source", "Source is recorded.");
is(events?.[0].extra.layout, "compact", "Layout is recorded.");
}
);
// Selecting a theme delegates to the themes manager and reflects the
// resulting active theme. The manager's install/enable behavior (which
// talks to AddonManager) is covered by FirefoxThemesList tests, so it is
// stubbed here to keep the test focused on the controller.
add_task(async function testDirectThemeChange() {
useController(ThemePickerDirectController);
setThemePrefs({
systemUsesDark: null,
nativeTheme: false,
activeThemeId: DEFAULT_THEME_ID,
});
let picker = await renderThemePicker();
let requested = [];
picker.controller.themesManager.updateThemeState = async (
themeId,
enabled,
options
) => {
requested.push({ themeId, enabled, options });
Services.prefs.setCharPref(PREF_ACTIVE_THEME_ID, themeId);
return true;
};
let themeItems = picker.shadowRoot.querySelectorAll(
"moz-visual-picker-item"
);
let novaSunItem = [...themeItems].find(
item => item.value === "nova-sun@mozilla.org"
);
synthesizeMouseAtCenter(novaSunItem, {});
await waitForCondition(
() => picker.activeThemeId == "nova-sun@mozilla.org",
"The active theme is updated after selecting a theme."
);
SimpleTest.isDeeply(
requested,
[
{
themeId: "nova-sun@mozilla.org",
enabled: true,
options: { layout: "full" },
},
],
"The themes manager is asked to enable the selected theme with the host layout."
);
});
// Compact mode renders exactly 6 themes in a fixed order,
// without appearance chooser or native theme checkbox.
add_task(async function testCompactModeRendering() {
useController(ThemePickerDirectController);
setThemePrefs({
systemUsesDark: null,
nativeTheme: false,
activeThemeId: DEFAULT_THEME_ID,
});
let picker = await renderThemePicker({ layout: "compact" });
ok(
!picker.shadowRoot.querySelector("moz-segmented-control"),
"Compact mode does not render the appearance chooser."
);
ok(
!picker.shadowRoot.querySelector("moz-checkbox"),
"Compact mode does not render the native theme checkbox."
);
let items = picker.shadowRoot.querySelectorAll(
"moz-visual-picker-item"
);
is(items.length, 6, "Compact mode renders exactly 6 themes.");
is(
picker.themes[0].id,
DEFAULT_THEME_ID,
"The default theme is always in the first position."
);
ok(
items[0].checked,
"The default theme item is selected when it is active."
);
});
// Themes can be changed in compact mode. The same 6 themes are always
// shown, and the selected theme is highlighted if it's one of them.
add_task(async function testCompactModeThemeChange() {
useController(ThemePickerDirectController);
setThemePrefs({
systemUsesDark: null,
nativeTheme: false,
activeThemeId: DEFAULT_THEME_ID,
});
let picker = await renderThemePicker({ layout: "compact" });
let requested = [];
picker.controller.themesManager.updateThemeState = async (
themeId,
enabled,
options
) => {
requested.push({ themeId, enabled, options });
Services.prefs.setCharPref(PREF_ACTIVE_THEME_ID, themeId);
return true;
};
let secondTheme = picker.themes[1];
let themeItems = picker.shadowRoot.querySelectorAll(
"moz-visual-picker-item"
);
let secondItem = [...themeItems].find(
item => item.value === secondTheme.id
);
synthesizeMouseAtCenter(secondItem, {});
await waitForCondition(
() => picker.activeThemeId == secondTheme.id,
"The active theme is updated after clicking."
);
SimpleTest.isDeeply(
requested,
[
{
themeId: secondTheme.id,
enabled: true,
options: { layout: "compact" },
},
],
"The themes manager is asked to enable the selected theme with the compact layout."
);
let picker2 = await renderThemePicker({ layout: "compact" });
is(
picker2.themes.length,
6,
"Compact mode always shows the same 6 themes."
);
is(
picker2.themes[0].id,
DEFAULT_THEME_ID,
"The default theme is always first."
);
let items2 = picker2.shadowRoot.querySelectorAll(
"moz-visual-picker-item"
);
let selectedItem = [...items2].find(
item => item.value === secondTheme.id
);
ok(
selectedItem?.checked,
"The newly selected theme is highlighted in the fixed list."
);
});
add_task(async function testShowLabels() {
useController(ThemePickerDirectController);
setThemePrefs({
systemUsesDark: null,
nativeTheme: false,
activeThemeId: DEFAULT_THEME_ID,
});
let picker = await renderThemePicker();
ok(picker.showLabels, "showLabels defaults to true.");
let item = picker.shadowRoot.querySelector("moz-visual-picker-item");
ok(
!item.getAttribute("data-l10n-id").endsWith("-aria-label"),
"Uses base l10n ID when showLabels is true."
);
picker.showLabels = false;
await picker.updateComplete;
ok(
item.getAttribute("data-l10n-id").endsWith("-aria-label"),
"Uses aria-label l10n ID when showLabels is false."
);
});
add_task(
async function testDirectControllerUpdatesColorsOnOSThemeChange() {
useController(ThemePickerDirectController);
setThemePrefs({
systemUsesDark: null,
nativeTheme: false,
activeThemeId: "nova-sun@mozilla.org",
});
let picker = await renderThemePicker();
is(picker.appearance, "device", "Appearance starts in device mode.");
let themeItem = picker.shadowRoot.querySelector(
"moz-visual-picker-item[value='nova-sun@mozilla.org']"
);
let themePreview = themeItem.querySelector(".theme-preview");
let initialColor = getComputedStyle(themePreview).backgroundColor;
ok(initialColor, "Theme preview has an initial background color.");
Services.prefs.setIntPref(PREF_SYSTEM_USES_DARK, 1);
await waitForCondition(
() =>
getComputedStyle(themePreview).backgroundColor !== initialColor,
"Theme swatch color changes after OS switches to dark mode."
);
let darkColor = getComputedStyle(themePreview).backgroundColor;
isnot(
darkColor,
initialColor,
"Theme swatch shows dark variant color in dark mode."
);
Services.prefs.setIntPref(PREF_SYSTEM_USES_DARK, 0);
await waitForCondition(
() =>
getComputedStyle(themePreview).backgroundColor === initialColor,
"Theme swatch color changes back after OS switches to light mode."
);
let lightColor = getComputedStyle(themePreview).backgroundColor;
is(
lightColor,
initialColor,
"Theme swatch shows original light variant color."
);
}
);
</script>
</head>
<body>
<p id="display"></p>
<div id="content"></div>
<pre id="test"></pre>
</body>
</html>