Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions components/Layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export default function Layout({
return (
<div className="flex flex-col min-h-screen mt-14">
<header className="main-header">
<div className="flex justify-between items-center min-w-0 gap-3">
<div className="header-left flex justify-between items-center min-w-0 gap-3">
<div className="flex flex-row gap-5">
{home && <Icon icon={HomeRoofIcon} link={home}/> }
{ chapters.length > 1 &&
Expand Down Expand Up @@ -140,14 +140,14 @@ export default function Layout({
</div>
)}
</div>
<div className="justify-self-center">
<div className="header-title justify-self-center">
{ collection ? "/" :
<span className="page-title">
{titleLink}
</span>
}
</div>
<div className={`flex ${collection && title ? "justify-between items-center min-w-0 gap-3" : "justify-end"}`}>
<div className={`header-right flex ${collection && title ? "justify-between items-center min-w-0 gap-3" : "justify-end"}`}>
{collection && titleLink &&
<div className="min-w-0 flex flex-1 truncate text-left flex-nowrap items-center">
<span className="inline-block min-w-0">{titleLink}</span>
Expand All @@ -156,12 +156,12 @@ export default function Layout({
}
</div>
}
<div className="flex flex-row gap-5">
<div title={user?.email || undefined}>
<div className="header-user-controls flex flex-row gap-5">
<div className="header-user-label" title={user?.email || undefined}>
{ user?.name ? `${user?.name} ${user?.surname}` : user?.email || t("user.anonymous-user") }
{ !!userGroup && `, ${userGroup}`}
</div>
<div className="flex">
<div className="header-user-control flex">
<UserDropdown
showLinkToResults={showLinkToResults}
returnLink={returnLink}
Expand Down
176 changes: 135 additions & 41 deletions e2e/book-page.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,44 @@
import fs from "node:fs/promises";
import { expect, test } from "@playwright/test";
import { expect, test, type Locator } from "@playwright/test";

const BOOK_PATH = "/mobile-baseline";
const MOBILE_PROJECT = "mobile-webkit-iphone";
const SUBPIXEL_TOLERANCE = 1;

type Bounds = NonNullable<Awaited<ReturnType<Locator["boundingBox"]>>>;

async function boundsFor(locator: Locator, name: string): Promise<Bounds> {
const bounds = await locator.boundingBox();
if (!bounds) {
throw new Error(`Missing bounds for ${name}`);
}
return bounds;
}

function overlaps(first: Bounds, second: Bounds) {
return (
first.x < second.x + second.width - SUBPIXEL_TOLERANCE &&
first.x + first.width > second.x + SUBPIXEL_TOLERANCE &&
first.y < second.y + second.height - SUBPIXEL_TOLERANCE &&
first.y + first.height > second.y + SUBPIXEL_TOLERANCE
);
}

async function paintedLineCount(locator: Locator) {
return locator.evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
const lineTops = Array.from(range.getClientRects())
.filter((rect) => rect.width > 0 && rect.height > 0)
.reduce<number[]>((tops, rect) => {
if (!tops.some((top) => Math.abs(top - rect.top) < 0.5)) {
tops.push(rect.top);
}
return tops;
}, []);
return lineTops.length;
});
}

test("renders the representative book page", async ({ page }, testInfo) => {
await page.goto(BOOK_PATH);
Expand All @@ -23,60 +59,118 @@ test("renders the representative book page", async ({ page }, testInfo) => {
page.getByText("This chapter provides ordinary paragraph text for browser smoke coverage.")
).toBeVisible();

const header = page.locator(".main-header");
const headerTitle = header.getByRole("link", {
name: "Playwright Mobile Baseline Book",
});
const userLabel = header.getByText("Anonymous User", { exact: true });
const userDropdown = header.locator(".user-dropdown");

await expect(headerTitle).toBeVisible();
await expect(userDropdown).toBeVisible();

if (testInfo.project.name !== MOBILE_PROJECT) {
await expect(userLabel).toBeVisible();
return;
}

const measurements = await page.evaluate(() => {
const boundsFor = (selector: string) => {
const rect = document.querySelector(selector)?.getBoundingClientRect();
return rect && {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
left: rect.left,
right: rect.right,
};
};
const documentElement = document.documentElement;
const body = document.body;

return {
documentElement: {
clientWidth: documentElement.clientWidth,
scrollWidth: documentElement.scrollWidth,
},
body: {
clientWidth: body.clientWidth,
scrollWidth: body.scrollWidth,
},
viewport: {
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
devicePixelRatio: window.devicePixelRatio,
},
horizontalOverflow:
documentElement.scrollWidth > documentElement.clientWidth ||
body.scrollWidth > body.clientWidth,
bounds: {
main: boundsFor("main"),
book: boundsFor(".book"),
},
};
await page.evaluate(async () => {
await document.fonts.ready;
});

const homeIcon = header.locator("svg.home-icon");
const avatarIcon = userDropdown.locator("svg");
await expect(homeIcon).toBeVisible();
await expect(avatarIcon).toBeVisible();

const relevantHeaderElements = [
["left controls", header.locator(".header-left")],
["title", headerTitle],
["right controls", header.locator(".header-right")],
["home icon", homeIcon],
["avatar", avatarIcon],
] as const;
const relevantBounds = await Promise.all(
relevantHeaderElements.map(([name, locator]) => boundsFor(locator, name))
);
const [, titleBounds, , homeBounds, avatarBounds] = relevantBounds;
const headerBounds = await boundsFor(header, "header");
const headerBottom = headerBounds.y + headerBounds.height;
const maximumVisibleChildBottom = Math.max(
...relevantBounds.map(({ y, height }) => y + height)
);
const titleLineCount = await paintedLineCount(headerTitle);
const homeTitleOverlap = overlaps(homeBounds, titleBounds);
const userLabelVisible = await userLabel.isVisible();
const { viewportWidth, horizontalOverflow } = await page.evaluate(() => ({
viewportWidth: document.documentElement.clientWidth,
horizontalOverflow:
document.documentElement.scrollWidth >
document.documentElement.clientWidth ||
document.body.scrollWidth > document.body.clientWidth,
}));

const measurements = {
viewportWidth,
horizontalOverflow,
header: {
bounds: headerBounds,
titleBounds,
titleLineCount,
homeBounds,
homeTitleOverlap,
avatarBounds,
userLabelVisible,
maximumVisibleChildBottom,
},
};

const measurementsPath = testInfo.outputPath("mobile-layout-measurements.json");
await fs.writeFile(measurementsPath, JSON.stringify(measurements, null, 2));
await testInfo.attach("mobile-layout-measurements", {
path: measurementsPath,
contentType: "application/json",
});

const screenshotPath = testInfo.outputPath("mobile-full-page.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
const fullPageScreenshotPath = testInfo.outputPath("mobile-full-page.png");
await page.screenshot({ path: fullPageScreenshotPath, fullPage: true });
await testInfo.attach("mobile-full-page-screenshot", {
path: screenshotPath,
path: fullPageScreenshotPath,
contentType: "image/png",
});

const headerScreenshotPath = testInfo.outputPath("mobile-header.png");
await page.screenshot({
path: headerScreenshotPath,
clip: {
x: 0,
y: 0,
width: viewportWidth,
height: Math.ceil(Math.max(headerBottom, maximumVisibleChildBottom) + 8),
},
});
await testInfo.attach("mobile-header-screenshot", {
path: headerScreenshotPath,
contentType: "image/png",
});

expect(titleLineCount).toBe(1);
expect(homeTitleOverlap).toBe(false);
expect(maximumVisibleChildBottom).toBeLessThanOrEqual(
headerBottom + SUBPIXEL_TOLERANCE
);
expect(userLabelVisible).toBe(false);
expect(avatarBounds.x).toBeGreaterThanOrEqual(
headerBounds.x - SUBPIXEL_TOLERANCE
);
expect(avatarBounds.x + avatarBounds.width).toBeLessThanOrEqual(
headerBounds.x + headerBounds.width + SUBPIXEL_TOLERANCE
);
expect(avatarBounds.y).toBeGreaterThanOrEqual(
headerBounds.y - SUBPIXEL_TOLERANCE
);
expect(avatarBounds.y + avatarBounds.height).toBeLessThanOrEqual(
headerBottom + SUBPIXEL_TOLERANCE
);
expect(horizontalOverflow).toBe(false);
});
26 changes: 26 additions & 0 deletions styles/globals.scss
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,32 @@ h3 {
border-bottom: 1px solid gray;
}

@media ( max-width: 680px ) {
.main-header {
grid-template-columns: auto minmax(0, 1fr) auto;

.header-title {
justify-self: stretch;
min-width: 0;
overflow: hidden;
text-align: center;
}

.page-title,
.page-title a {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.header-user-label {
display: none;
}
}
}

.chapter {
h2.chapter-title {
color: rgb(52 211 153);
Expand Down
Loading