diff --git a/components/baseline-indicator/sandbox.js b/components/baseline-indicator/sandbox.js index d95aa229d..8df21100d 100644 --- a/components/baseline-indicator/sandbox.js +++ b/components/baseline-indicator/sandbox.js @@ -17,20 +17,11 @@ const SUPPORT_FULL = { }; /** @type {import("@rari").Support} */ -const SUPPORT_NO_WEBKIT = { - chrome: "111", - chrome_android: "111", - edge: "111", +const SUPPORT_ONLY_FIREFOX = { firefox: "113", firefox_android: "113", }; -/** @type {import("@rari").Support} */ -const SUPPORT_WEBKIT_ONLY = { - safari: "15.4", - safari_ios: "15.4", -}; - /** @type {import("@rari").Alternative[]} */ const ALTERNATIVES = [ { @@ -69,7 +60,7 @@ function feature(overrides) { function notAvailable(overrides) { return { baseline: false, - support: SUPPORT_NO_WEBKIT, + support: SUPPORT_ONLY_FIREFOX, feature: feature(), ...overrides, }; @@ -99,6 +90,7 @@ function discouragedFeature(overrides) { /** * @type {{ + * id: string; * name: string; * status: import("@rari").BaselineStatus; * baseline?: import("@rari").Baseline; @@ -106,36 +98,43 @@ function discouragedFeature(overrides) { */ const CASES = [ { + id: "high", name: "high", status: "high", baseline: available("high"), }, { + id: "high-asterisk", name: "high, asterisk", status: "high", baseline: available("high", { asterisk: true }), }, { + id: "low", name: "low", status: "low", baseline: available("low"), }, { + id: "low-asterisk", name: "low, asterisk", status: "low", baseline: available("low", { asterisk: true }), }, { + id: "limited", name: "limited", status: "limited", baseline: notAvailable(), }, { + id: "limited-asterisk", name: "limited, asterisk", status: "limited", - baseline: notAvailable({ support: SUPPORT_WEBKIT_ONLY, asterisk: true }), + baseline: notAvailable({ asterisk: true }), }, { + id: "limited-developer-signals", name: "limited, developer signals", status: "limited", baseline: notAvailable({ @@ -145,11 +144,22 @@ const CASES = [ }), }, { + id: "discouraged", name: "discouraged", status: "discouraged", baseline: notAvailable({ feature: discouragedFeature() }), }, { + id: "discouraged-asterisk", + name: "discouraged, asterisk ignored", + status: "discouraged", + baseline: notAvailable({ + asterisk: true, + feature: discouragedFeature(), + }), + }, + { + id: "discouraged-one-alternative", name: "discouraged, one alternative", status: "discouraged", baseline: notAvailable({ @@ -158,6 +168,7 @@ const CASES = [ }), }, { + id: "discouraged-several-alternatives", name: "discouraged, several alternatives", status: "discouraged", baseline: notAvailable({ @@ -166,15 +177,18 @@ const CASES = [ }), }, { + id: "discouraged-no-compatibility-data", name: "discouraged, no compatibility data", status: "discouraged", }, { + id: "removing", name: "removing", status: "removing", baseline: notAvailable({ feature: discouragedFeature() }), }, { + id: "removing-one-alternative", name: "removing, one alternative", status: "removing", baseline: notAvailable({ @@ -183,6 +197,7 @@ const CASES = [ }), }, { + id: "removing-several-alternatives", name: "removing, several alternatives", status: "removing", baseline: notAvailable({ @@ -212,8 +227,10 @@ export class BaselineIndicatorSandbox extends SandboxComponent { baseline: testCase.baseline, }, }); - return html`

${testCase.name}

- ${BaselineIndicator.render(docContext)}`; + return html`
+

${testCase.name}

+ ${BaselineIndicator.render(docContext)} +
`; })} `; } diff --git a/test/pageobjects/baseline-indicator.page.js b/test/pageobjects/baseline-indicator.page.js new file mode 100644 index 000000000..f2cf8dcb9 --- /dev/null +++ b/test/pageobjects/baseline-indicator.page.js @@ -0,0 +1,32 @@ +import { $, browser } from "@wdio/globals"; + +import BaselineIndicator from "./components/baseline-indicator.js"; +import SandboxPage from "./sandbox.page.js"; + +class BaselineIndicatorPage { + open(locale = "en-US") { + return SandboxPage.open("baseline-indicator", locale); + } + + /** @param {string} id */ + fixture(id) { + return $(`#${id}`); + } + + /** @param {string} id */ + indicator(id) { + return new BaselineIndicator(() => + this.fixture(id).$(".baseline-indicator"), + ); + } + + async clearStoredState() { + await browser.execute(() => localStorage.removeItem("baseline-indicator")); + } + + async getStoredState() { + return browser.execute(() => localStorage.getItem("baseline-indicator")); + } +} + +export default new BaselineIndicatorPage(); diff --git a/test/pageobjects/components/baseline-indicator.js b/test/pageobjects/components/baseline-indicator.js new file mode 100644 index 000000000..e8b7ec456 --- /dev/null +++ b/test/pageobjects/components/baseline-indicator.js @@ -0,0 +1,100 @@ +import { browser } from "@wdio/globals"; + +import Component from "./component.js"; + +export default class BaselineIndicator extends Component { + get summary() { + return this.element.$("summary"); + } + + get statusIcon() { + return this.summary.$(".indicator"); + } + + get title() { + return this.summary.$(".status-title"); + } + + get pill() { + return this.summary.$(".pill"); + } + + get asterisk() { + return this.summary.$(".asterisk"); + } + + get browsers() { + return this.summary.$(".browsers"); + } + + /** @param {"chrome" | "edge" | "firefox" | "safari"} name */ + browser(name) { + return this.browsers.$(`.${name}`); + } + + /** @param {number} index */ + engine(index) { + return this.browsers.$(`.engine:nth-child(${index + 1})`); + } + + get extra() { + return this.element.$(".extra"); + } + + get asteriskNote() { + return this.extra.$(".asterisk-note"); + } + + get reason() { + return this.extra.$('p span[lang="en-US"]'); + } + + get reasonCode() { + return this.reason.$("code"); + } + + get signalsLink() { + return this.extra.$('[data-glean-id="baseline_link_signals"]'); + } + + /** @param {string} name */ + alternativeLink(name) { + return this.extra.$( + `[data-glean-id="baseline_link_alternatives: ${name}"]`, + ); + } + + get alternativeLinks() { + return this.extra.$$('[data-glean-id^="baseline_link_alternatives:"]'); + } + + get compatibilityLink() { + return this.extra.$('[data-glean-id="baseline_link_bcd_table"]'); + } + + get learnMoreLink() { + return this.extra.$('[data-glean-id="baseline_link_learn_more"]'); + } + + async isOpen() { + return Boolean(await this.element.getProperty("open")); + } + + /** @param {boolean} open */ + async setOpen(open) { + if ((await this.isOpen()) !== open) { + await this.summary.click(); + } + await browser.waitUntil(async () => (await this.isOpen()) === open, { + timeoutMsg: `Baseline indicator did not ${open ? "open" : "close"}`, + }); + } + + async open() { + await this.setOpen(true); + } + + async close() { + await this.setOpen(false); + } +} diff --git a/test/pageobjects/components/component.js b/test/pageobjects/components/component.js new file mode 100644 index 000000000..d82d6e903 --- /dev/null +++ b/test/pageobjects/components/component.js @@ -0,0 +1,12 @@ +/** @import { ChainablePromiseElement } from "webdriverio" */ + +export default class Component { + /** @param {() => ChainablePromiseElement} elementFactory */ + constructor(elementFactory) { + this.elementFactory = elementFactory; + } + + get element() { + return this.elementFactory(); + } +} diff --git a/test/pageobjects/sandbox.page.js b/test/pageobjects/sandbox.page.js new file mode 100644 index 000000000..2731cd896 --- /dev/null +++ b/test/pageobjects/sandbox.page.js @@ -0,0 +1,16 @@ +import Page from "./page.js"; + +class SandboxPage extends Page { + /** + * Opens a component's sandbox page. + * @param {string} component component directory name + * @param {string} [locale] page locale + */ + open(component, locale = "en-US") { + return super.open( + `${encodeURIComponent(locale)}/sandbox/${encodeURIComponent(component)}`, + ); + } +} + +export default new SandboxPage(); diff --git a/test/specs/baseline-indicator.e2e.js b/test/specs/baseline-indicator.e2e.js new file mode 100644 index 000000000..d4882532c --- /dev/null +++ b/test/specs/baseline-indicator.e2e.js @@ -0,0 +1,309 @@ +import { browser, expect } from "@wdio/globals"; + +import BaselineIndicatorPage from "../pageobjects/baseline-indicator.page.js"; + +describe("Baseline indicator", () => { + beforeEach(async () => { + await BaselineIndicatorPage.open(); + await BaselineIndicatorPage.clearStoredState(); + await browser.refresh(); + }); + + it("renders the semantic summary for every status", async () => { + const cases = [ + { + id: "high", + title: "Baseline", + pill: "WIDELY AVAILABLE", + label: "Baseline Check", + open: false, + }, + { + id: "low", + title: "Baseline 2023", + pill: "NEWLY AVAILABLE", + label: "Baseline Check", + open: false, + }, + { + id: "limited", + title: "Limited availability", + label: "Baseline Cross", + open: false, + }, + { + id: "discouraged", + title: "Deprecated", + label: "Baseline Discouraged", + open: true, + }, + { + id: "removing", + title: "Deprecated", + pill: "TO BE REMOVED", + label: "Baseline Discouraged Cross", + open: true, + }, + ]; + + for (const testCase of cases) { + const indicator = BaselineIndicatorPage.indicator(testCase.id); + await expect(indicator.element).toBeExisting(); + await expect(indicator.title).toHaveText(testCase.title); + await expect(indicator.statusIcon).toHaveAttribute( + "aria-label", + testCase.label, + ); + await expect(await indicator.isOpen()).toEqual(testCase.open); + await expect(await indicator.pill.isExisting()).toEqual( + Boolean(testCase.pill), + ); + if (testCase.pill) { + await expect(indicator.pill).toHaveText(testCase.pill); + } + } + }); + + it("renders the appropriate explanation for each availability status", async () => { + const cases = [ + { + id: "high", + text: "available across browsers since May 2023", + }, + { + id: "low", + text: "Since May 2023, this feature works across the latest devices", + }, + { + id: "limited", + text: "This feature is not Baseline because it does not work", + }, + ]; + + for (const testCase of cases) { + const indicator = BaselineIndicatorPage.indicator(testCase.id); + await indicator.open(); + await expect(indicator.extra).toHaveText( + expect.stringContaining(testCase.text), + ); + } + }); + + it("exposes browser support through accessible names and engine titles", async () => { + const high = BaselineIndicatorPage.indicator("high"); + const browserNames = /** @type {const} */ ([ + "chrome", + "edge", + "firefox", + "safari", + ]); + for (const browserName of browserNames) { + const label = `${browserName.charAt(0).toUpperCase()}${browserName.slice(1)} check`; + await expect(high.browser(browserName)).toHaveAttribute( + "aria-label", + label, + ); + } + + const limited = BaselineIndicatorPage.indicator("limited"); + await expect(limited.browser("chrome")).toHaveAttribute( + "aria-label", + "Chrome cross", + ); + await expect(limited.browser("edge")).toHaveAttribute( + "aria-label", + "Edge cross", + ); + await expect(limited.browser("firefox")).toHaveAttribute( + "aria-label", + "Firefox check", + ); + await expect(limited.browser("safari")).toHaveAttribute( + "aria-label", + "Safari cross", + ); + await expect(limited.engine(0)).toHaveAttribute( + "title", + "Not widely supported in Chrome and Edge", + ); + await expect(limited.engine(1)).toHaveAttribute( + "title", + "Supported in Firefox", + ); + await expect(limited.engine(2)).toHaveAttribute( + "title", + "Not widely supported in Safari", + ); + }); + + it("renders the asterisk annotation only when applicable", async () => { + for (const id of ["high-asterisk", "low-asterisk", "limited-asterisk"]) { + const indicator = BaselineIndicatorPage.indicator(id); + await indicator.open(); + await expect(indicator.asterisk).toHaveText("*"); + await expect(indicator.asteriskNote).toHaveText( + expect.stringContaining( + "Some parts of this feature may have varying levels of support.", + ), + ); + } + + for (const id of ["high", "low", "limited", "discouraged-asterisk"]) { + const indicator = BaselineIndicatorPage.indicator(id); + await expect(indicator.asterisk).not.toBeExisting(); + await expect(indicator.asteriskNote).not.toBeExisting(); + } + }); + + it("renders the developer signals link metadata", async () => { + const indicator = BaselineIndicatorPage.indicator( + "limited-developer-signals", + ); + await expect(await indicator.isOpen()).toEqual(true); + await expect(indicator.element).toHaveAttribute("data-open-by-default"); + await expect(indicator.signalsLink).toHaveAttribute( + "href", + "https://example.com/signals", + ); + await expect(indicator.signalsLink).toHaveAttribute("target", "_blank"); + await expect(indicator.signalsLink).toHaveAttribute("rel", "noopener"); + await expect(indicator.signalsLink).toHaveElementClass("external"); + await expect(indicator.signalsLink).toHaveAttribute( + "data-glean-id", + "baseline_link_signals", + ); + }); + + it("renders discouraged guidance and upstream reason markup", async () => { + const indicator = BaselineIndicatorPage.indicator("discouraged"); + await expect(indicator.extra).toHaveText( + expect.stringContaining("Avoid using this feature in new projects."), + ); + await expect(indicator.extra).toHaveText( + expect.stringContaining("This feature may be a candidate for removal"), + ); + await expect(indicator.reason).toHaveAttribute("lang", "en-US"); + await expect(indicator.reasonCode).toHaveText(""); + }); + + it("renders removing guidance without candidate-removal copy", async () => { + const indicator = BaselineIndicatorPage.indicator("removing"); + await expect(indicator.extra).toHaveText( + expect.stringContaining("This feature is pending removal from browsers."), + ); + const text = await indicator.extra.getText(); + await expect(text).not.toContain( + "This feature may be a candidate for removal", + ); + }); + + it("renders one or several alternatives with their metadata", async () => { + const one = BaselineIndicatorPage.indicator("discouraged-one-alternative"); + await expect(one.alternativeLinks).toBeElementsArrayOfSize(1); + await expect(one.extra).toHaveText( + expect.stringContaining("Consider using the following features instead"), + ); + await expect(one.alternativeLink("flexbox")).toHaveText("flexbox"); + await expect(one.alternativeLink("flexbox")).toHaveAttribute( + "href", + expect.stringContaining("/en-US/docs/Web/CSS/CSS_flexible_box_layout"), + ); + await expect(one.alternativeLink("flexbox")).toHaveAttribute( + "title", + "CSS flexible box layout", + ); + + const several = BaselineIndicatorPage.indicator( + "discouraged-several-alternatives", + ); + await expect(several.alternativeLinks).toBeElementsArrayOfSize(2); + await expect(several.extra).toHaveText( + expect.stringContaining("flexbox or grid"), + ); + await expect(several.alternativeLink("grid")).toHaveAttribute( + "data-glean-id", + "baseline_link_alternatives: grid", + ); + + const removing = BaselineIndicatorPage.indicator( + "removing-several-alternatives", + ); + await expect(removing.extra).toHaveText( + expect.stringContaining("Use the following features instead"), + ); + }); + + it("localizes generated links and the alternatives list", async () => { + await BaselineIndicatorPage.open("fr"); + const indicator = BaselineIndicatorPage.indicator( + "discouraged-several-alternatives", + ); + + await expect(indicator.extra).toHaveText( + expect.stringContaining("flexbox ou grid"), + ); + await expect(indicator.alternativeLink("flexbox")).toHaveAttribute( + "href", + expect.stringContaining("/fr/docs/Web/CSS/CSS_flexible_box_layout"), + ); + await expect(indicator.compatibilityLink).toHaveAttribute( + "href", + expect.stringContaining("#compatibilité_des_navigateurs"), + ); + await expect(indicator.learnMoreLink).toHaveAttribute( + "href", + expect.stringContaining("/fr/docs/Glossary/Baseline/Compatibility"), + ); + }); + + it("renders the common navigation links with telemetry metadata", async () => { + const indicator = BaselineIndicatorPage.indicator("high"); + await expect(indicator.compatibilityLink).toHaveAttribute( + "href", + expect.stringContaining("#browser_compatibility"), + ); + await expect(indicator.compatibilityLink).toHaveAttribute( + "data-glean-id", + "baseline_link_bcd_table", + ); + await expect(indicator.learnMoreLink).toHaveAttribute( + "href", + expect.stringContaining("/en-US/docs/Glossary/Baseline/Compatibility"), + ); + await expect(indicator.learnMoreLink).toHaveAttribute("target", "_blank"); + await expect(indicator.learnMoreLink).toHaveAttribute( + "data-glean-id", + "baseline_link_learn_more", + ); + }); + + it("handles mocked discouraged banner", async () => { + const discouraged = BaselineIndicatorPage.indicator( + "discouraged-no-compatibility-data", + ); + await expect(discouraged.element).toBeExisting(); + await expect(discouraged.title).toHaveText("Deprecated"); + await expect(discouraged.browsers).not.toBeExisting(); + }); + + it("persists user-controlled open state across reloads", async () => { + const indicator = BaselineIndicatorPage.indicator("high"); + await expect(await indicator.isOpen()).toEqual(false); + + await indicator.open(); + await browser.waitUntil( + async () => (await BaselineIndicatorPage.getStoredState()) === "open", + { timeoutMsg: "Opening the indicator did not persist its state" }, + ); + await browser.refresh(); + await expect(await indicator.isOpen()).toEqual(true); + + await indicator.close(); + await browser.waitUntil( + async () => (await BaselineIndicatorPage.getStoredState()) === null, + { timeoutMsg: "Closing the indicator did not clear its stored state" }, + ); + await browser.refresh(); + await expect(await indicator.isOpen()).toEqual(false); + }); +});