200 lines
7.4 KiB
JavaScript
200 lines
7.4 KiB
JavaScript
import { expect, test } from "@playwright/test";
|
|
|
|
/**
|
|
* Chrome tint behavior:
|
|
*
|
|
* - Top tint (status bar / notch / Dynamic Island) — iOS ≤18 / Chrome:
|
|
* <meta name="theme-color"> = HEADER_COLOR when the site header is
|
|
* in view; cleared (no content) otherwise.
|
|
*
|
|
* - Top tint — iOS 26+ (where the meta tag is ignored):
|
|
* document.body.style.backgroundColor is set to the same color.
|
|
* WebKit's live observer picks up body background changes and tints
|
|
* the chrome to match.
|
|
*
|
|
* - Bottom tint (home indicator area, behind the address bar):
|
|
* <body class="footer-in-view"> when the site footer is in view;
|
|
* absent otherwise. The CSS body::after pseudo-element picks up
|
|
* the footer color from this class. (iOS 26 only samples one color
|
|
* for the entire chrome, so the top and bottom share a color on
|
|
* iOS 26; the pseudo-element is a no-op fallback there.)
|
|
*
|
|
* Tests run against the local Herd site.
|
|
*/
|
|
|
|
const site = "http://community-works-collaborative.test/";
|
|
|
|
const HEADER_COLOR = "#032F46";
|
|
|
|
test.describe("chrome tint", () => {
|
|
test.use({ viewport: { width: 402, height: 874 } });
|
|
|
|
test("static theme-color meta is present in the document head", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(site, { waitUntil: "networkidle" });
|
|
const content = await page.evaluate(() => {
|
|
const meta = document.querySelector('meta[name="theme-color"]');
|
|
return meta ? meta.getAttribute("content") : null;
|
|
});
|
|
// Either the server-rendered value or the value set by JS on
|
|
// initial paint (the header is in view at page top) should be
|
|
// the header color.
|
|
expect(content).toBe(HEADER_COLOR);
|
|
});
|
|
|
|
test("viewport meta includes viewport-fit=cover", async ({ page }) => {
|
|
await page.goto(site, { waitUntil: "networkidle" });
|
|
const viewport = await page.evaluate(() => {
|
|
const meta = document.querySelector('meta[name="viewport"]');
|
|
return meta ? meta.getAttribute("content") : null;
|
|
});
|
|
expect(viewport).toContain("viewport-fit=cover");
|
|
});
|
|
|
|
test("theme-color meta is set when header is in view at page top", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(site, { waitUntil: "networkidle" });
|
|
// Wait for ChromeTint.js to run (it observes on DOMContentLoaded).
|
|
await page.waitForFunction(
|
|
(expected) => {
|
|
const meta = document.querySelector('meta[name="theme-color"]');
|
|
return meta && meta.getAttribute("content") === expected;
|
|
},
|
|
HEADER_COLOR,
|
|
{ timeout: 2000 },
|
|
);
|
|
const content = await page.evaluate(() =>
|
|
document.querySelector('meta[name="theme-color"]').getAttribute("content"),
|
|
);
|
|
expect(content).toBe(HEADER_COLOR);
|
|
});
|
|
|
|
test("theme-color meta clears when header scrolls out of view", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(site, { waitUntil: "networkidle" });
|
|
await page.waitForFunction(
|
|
(expected) => {
|
|
const meta = document.querySelector('meta[name="theme-color"]');
|
|
return meta && meta.getAttribute("content") === expected;
|
|
},
|
|
HEADER_COLOR,
|
|
{ timeout: 2000 },
|
|
);
|
|
await page.evaluate(() => window.scrollTo(0, 1200));
|
|
await page.waitForTimeout(300);
|
|
const content = await page.evaluate(() => {
|
|
const meta = document.querySelector('meta[name="theme-color"]');
|
|
return meta ? meta.getAttribute("content") : null;
|
|
});
|
|
expect(content).toBeNull();
|
|
});
|
|
|
|
test("body background is set to header color when header is in view (iOS 26 path)", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(site, { waitUntil: "networkidle" });
|
|
// Wait for ChromeTint.js to set the body background.
|
|
await page.waitForFunction(
|
|
() => document.body.style.backgroundColor !== "",
|
|
{ timeout: 2000 },
|
|
);
|
|
const bg = await page.evaluate(() => document.body.style.backgroundColor);
|
|
// The browser normalizes hex to rgb() — accept either form.
|
|
// #032F46 = rgb(3, 47, 70) (with sRGB rounding)
|
|
expect(bg).toMatch(/^#?032[Ff]46$|^rgb\(\s*3,\s*4[67],\s*70\s*\)$/);
|
|
});
|
|
|
|
test("body background clears when header scrolls out of view", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(site, { waitUntil: "networkidle" });
|
|
await page.waitForFunction(
|
|
() => document.body.style.backgroundColor !== "",
|
|
{ timeout: 2000 },
|
|
);
|
|
await page.evaluate(() => window.scrollTo(0, 1200));
|
|
await page.waitForFunction(
|
|
() => document.body.style.backgroundColor === "",
|
|
{ timeout: 2000 },
|
|
);
|
|
const bg = await page.evaluate(() => document.body.style.backgroundColor);
|
|
expect(bg).toBe("");
|
|
});
|
|
|
|
test("bottom tint class is absent when footer is out of view", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(site, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(200);
|
|
const hasClass = await page.evaluate(() =>
|
|
document.body.classList.contains("footer-in-view"),
|
|
);
|
|
expect(hasClass).toBe(false);
|
|
});
|
|
|
|
test("bottom tint class is added when footer enters the viewport", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(site, { waitUntil: "networkidle" });
|
|
await page.evaluate(() =>
|
|
window.scrollTo(0, document.documentElement.scrollHeight),
|
|
);
|
|
await page.waitForFunction(
|
|
() => document.body.classList.contains("footer-in-view"),
|
|
{ timeout: 2000 },
|
|
);
|
|
const hasClass = await page.evaluate(() =>
|
|
document.body.classList.contains("footer-in-view"),
|
|
);
|
|
expect(hasClass).toBe(true);
|
|
});
|
|
|
|
test("bottom tint class is removed when footer scrolls out of view", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(site, { waitUntil: "networkidle" });
|
|
await page.evaluate(() =>
|
|
window.scrollTo(0, document.documentElement.scrollHeight),
|
|
);
|
|
await page.waitForFunction(
|
|
() => document.body.classList.contains("footer-in-view"),
|
|
{ timeout: 2000 },
|
|
);
|
|
await page.evaluate(() => window.scrollTo(0, 0));
|
|
await page.waitForFunction(
|
|
() => !document.body.classList.contains("footer-in-view"),
|
|
{ timeout: 2000 },
|
|
);
|
|
const hasClass = await page.evaluate(() =>
|
|
document.body.classList.contains("footer-in-view"),
|
|
);
|
|
expect(hasClass).toBe(false);
|
|
});
|
|
|
|
test("CSS body::after fills the home indicator safe area", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(site, { waitUntil: "networkidle" });
|
|
const css = await page.evaluate(() => {
|
|
for (const sheet of Array.from(document.styleSheets)) {
|
|
try {
|
|
for (const rule of Array.from(sheet.cssRules)) {
|
|
if (rule.selectorText === "body::after") {
|
|
return rule.cssText;
|
|
}
|
|
}
|
|
} catch {
|
|
// cross-origin
|
|
}
|
|
}
|
|
return null;
|
|
});
|
|
expect(css).toContain("env(safe-area-inset-bottom)");
|
|
expect(css).toContain("position: fixed");
|
|
});
|
|
});
|
|
|