Margin Calendar: a Google Calendar client for desktop and phone

Tauri 2, React 19 and zustand on the front, Rust behind. Rust owns auth,
all HTTP to Google, the SQLite store, the sync loop, recurrence expansion
and timezone maths. TypeScript owns rendering and never talks to Google,
which keeps the content security policy locked to ipc:.

Week, day and agenda views, and no month view: it would be a second layout
engine, and the fit and fold logic that makes a day fit the window without
scrolling is the whole point of the app.

Runs on macOS, Linux, Android and iOS. Desktop catches Google's OAuth
redirect on a loopback port. A phone cannot, and Google rejects loopback
for mobile client types anyway, so it redirects to a custom URI scheme and
needs its own public OAuth clients, which docs/mobile.md covers. Refresh
tokens are sealed with XChaCha20-Poly1305 in the app data directory on
every platform, with no OS credential store in the picture.

On a phone the chrome becomes a top bar and a bottom tab bar, overlays
become sheets, hover affordances become taps, and dragging out an event
waits for a long press. Navigation moves one day at a time everywhere,
a swipe included.
This commit is contained in:
pj committed 2026-08-12 17:09:21 +05:30
commit 661100dfdc
243 files changed
+35200

No files matched your search

+342
View File
@@ -0,0 +1,342 @@
// What every spec needs: a seeded load of the real app, and the handful of measurements the grid
// has to be judged on.
//
// Nothing here reaches into the app's internals. There is no test id anywhere in `src/`, so these
// helpers read what the browser actually laid out: rectangles, computed styles, rendered text.
// That is deliberate. The bugs this suite exists to catch were all things you could see.
import { expect, type Locator, type Page } from "@playwright/test";
export interface Box {
x: number;
y: number;
width: number;
height: number;
top: number;
bottom: number;
left: number;
right: number;
}
export interface OpenOptions {
theme?: "light" | "dark";
view?: "day" | "week" | "agenda";
/** Extra localStorage entries, written before the app's first script runs. */
storage?: Record<string, string>;
}
const SEEDED = "__test-seeded";
/**
* Loads the app with a known store. The seed is written once per context rather than on every
* navigation, so a test that reloads to check what survived is not silently reset underneath it.
*/
export async function openApp(page: Page, options: OpenOptions = {}): Promise<void> {
const seed: Record<string, string> = {
"margincal-theme": options.theme ?? "light",
"margincal-view": options.view ?? "week",
// Both are what a first run gets, pinned so a stray local preference cannot change the shape
// of the week under a test.
"margincal-week-mode": "rolling",
"margincal-week-start": "1",
...(options.storage ?? {}),
};
await page.addInitScript(
({ values, flag }) => {
try {
if (localStorage.getItem(flag) === "1") return;
localStorage.clear();
for (const [key, value] of Object.entries(values)) localStorage.setItem(key, value);
localStorage.setItem(flag, "1");
} catch {
/* a context without storage is not a context this app runs in */
}
},
{ values: seed, flag: SEEDED },
);
await page.goto("/");
if ((options.view ?? "week") === "agenda") await agendaReady(page);
else await gridReady(page);
}
/** The grid has laid out and the fixture has arrived. */
export async function gridReady(page: Page): Promise<void> {
await expect(page.locator(".grid-body")).toBeVisible();
await expect(page.locator(".grid-event").first()).toBeVisible();
await settle(page);
}
export async function agendaReady(page: Page): Promise<void> {
await expect(page.locator(".agenda-row").first()).toBeVisible();
await settle(page);
}
/** Two frames, which is long enough for a ResizeObserver pass and the render it causes. */
export function settle(page: Page): Promise<void> {
return page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
export async function box(target: Locator): Promise<Box> {
const rect = await target.boundingBox();
if (!rect) throw new Error("element has no box");
return {
...rect,
top: rect.y,
bottom: rect.y + rect.height,
left: rect.x,
right: rect.x + rect.width,
};
}
export interface GridFit {
/** Pixels the body was given by the window. */
bodyHeight: number;
bodyWidth: number;
bodyBottom: number;
/** Pixels the day was drawn in. Equal to the body when the fit did its job. */
canvasHeight: number;
bodyScrollHeight: number;
rowHeight: number;
overflow: boolean;
windowHeight: number;
documentScrollHeight: number;
documentClientHeight: number;
}
export function gridFit(page: Page): Promise<GridFit> {
return page.evaluate(() => {
const body = document.querySelector<HTMLElement>(".grid-body");
const canvas = document.querySelector<HTMLElement>(".grid-canvas");
const grid = document.querySelector<HTMLElement>(".grid");
if (!body || !canvas || !grid) throw new Error("the grid is not on screen");
const rect = body.getBoundingClientRect();
return {
bodyHeight: rect.height,
bodyWidth: rect.width,
bodyBottom: rect.bottom,
canvasHeight: canvas.getBoundingClientRect().height,
bodyScrollHeight: body.scrollHeight,
rowHeight: Number.parseFloat(getComputedStyle(grid).getPropertyValue("--row-h")),
overflow: grid.hasAttribute("data-overflow"),
windowHeight: window.innerHeight,
documentScrollHeight: document.documentElement.scrollHeight,
documentClientHeight: document.documentElement.clientHeight,
};
});
}
export interface AxisLabel {
text: string;
/** Pixels from the top of the canvas, which is what "the axis did not move" means. */
y: number;
}
/** The hour labels down the gutter, in order, positioned relative to the canvas. */
export function axis(page: Page): Promise<AxisLabel[]> {
return page.evaluate(() => {
const canvas = document.querySelector(".grid-canvas");
if (!canvas) throw new Error("the grid is not on screen");
const top = canvas.getBoundingClientRect().top;
return [...document.querySelectorAll(".grid-gutter-label")].map((el) => ({
text: (el.textContent ?? "").trim(),
y: Math.round(el.getBoundingClientRect().top - top),
}));
});
}
export interface BlockInfo {
title: string;
/** What the block's accessible name comes out as, computed the way a screen reader would. */
name: string;
size: string | null;
column: number;
width: number;
height: number;
top: number;
/** True when the block's own centre is the thing a click at that point would reach. */
hittable: boolean;
/** Rendered lines of the title, from its height over its line height. */
titleLines: number;
busy: boolean;
}
/**
* Every block on the axis, measured. The accessible name is approximated the way the platform
* computes it for these elements: an explicit label, else the rendered text, else the title
* attribute. One block is checked against Playwright's real computation in the legibility spec.
*/
export function blocks(page: Page): Promise<BlockInfo[]> {
return page.evaluate(() => {
const nameOf = (el: HTMLElement): string => {
const labelled = el.getAttribute("aria-label");
if (labelled && labelled.trim()) return labelled.trim();
const text = (el.innerText ?? "").replace(/\s+/g, " ").trim();
if (text) return text;
return (el.getAttribute("title") ?? "").trim();
};
return [...document.querySelectorAll<HTMLElement>(".grid-event")].map((el) => {
const rect = el.getBoundingClientRect();
const column = el.closest<HTMLElement>(".grid-col");
const title = el.querySelector<HTMLElement>(".grid-event-title");
const lineHeight = title ? Number.parseFloat(getComputedStyle(title).lineHeight) : 0;
const hit = document.elementFromPoint(
Math.round(rect.left + rect.width / 2),
Math.round(rect.top + rect.height / 2),
);
return {
title: (title?.textContent ?? "").trim(),
name: nameOf(el),
size: el.getAttribute("data-size"),
column: Number(column?.dataset.index ?? -1),
width: rect.width,
height: rect.height,
top: rect.top,
hittable: hit !== null && (hit === el || el.contains(hit)),
titleLines:
title && lineHeight > 0
? Math.round(title.getBoundingClientRect().height / lineHeight)
: 0,
busy: el.hasAttribute("data-busy"),
};
});
});
}
/** The dates in the day bar, left to right. Paging is judged on these. */
export function headerDates(page: Page): Promise<string[]> {
return page.evaluate(() =>
[...document.querySelectorAll(".grid-head-date")].map((el) => (el.textContent ?? "").trim()),
);
}
/**
* The viewport y of a whole hour's rule, or null when that hour is folded away.
*
* Measured off the rule rather than the gutter label, because the label is nudged a pixel for
* optical alignment and a gesture wants the line the block will actually snap to.
*/
export function hourY(page: Page, label: string): Promise<number | null> {
return page.evaluate((wanted) => {
const labels = [...document.querySelectorAll(".grid-gutter-label")];
const at = labels.findIndex((el) => (el.textContent ?? "").trim() === wanted);
if (at === -1) return null;
// One whole-hour rule per label, both laid out top down, so they line up by index.
const rules = [...document.querySelectorAll(".grid-rule:not([data-half])")];
const rule = rules[at];
return rule ? rule.getBoundingClientRect().top : null;
}, label);
}
/** The centre x of a day column, for a gesture that has to land on a particular day. */
export async function columnX(page: Page, index: number): Promise<number> {
const column = await box(page.locator(`.grid-col[data-index="${index}"]`));
return column.left + column.width / 2;
}
export async function drag(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
): Promise<void> {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
// Enough intermediate moves to pass the slop that separates a drag from a click, and to look
// like a hand rather than a teleport.
await page.mouse.move((from.x + to.x) / 2, (from.y + to.y) / 2, { steps: 6 });
await page.mouse.move(to.x, to.y, { steps: 6 });
await page.mouse.up();
await settle(page);
}
/** The open dialog's accessible name, or null. Every overlay in the app is one. */
export async function openDialog(page: Page): Promise<string | null> {
return page.evaluate(() => {
const dialog = document.querySelector('[role="dialog"]');
return dialog ? dialog.getAttribute("aria-label") : null;
});
}
export interface Contrast {
ratio: number;
text: string;
background: string;
}
/**
* Contrast of a piece of text against whatever is actually behind it, compositing every
* translucent layer between the element and the page. Event surfaces are washes over paper, so
* reading one background-color would measure the wrong thing.
*/
export function contrastOf(page: Page, selector: string): Promise<Contrast | null> {
return page.evaluate((sel) => {
const el = document.querySelector<HTMLElement>(sel);
if (!el) return null;
// Resolved through a canvas rather than by reading the string, so any colour space works.
// getComputedStyle hands back whatever syntax the author wrote, and `oklch()` or
// `color-mix()` used to fall through the rgb regex as transparent, which reported a
// perfectly readable block as 1:1 and failed for the wrong reason.
const probe = document.createElement("canvas");
probe.width = 1;
probe.height = 1;
const ctx = probe.getContext("2d", { willReadFrequently: true })!;
const parse = (value: string): [number, number, number, number] => {
const match = value.match(/^rgba?\(([^)]+)\)$/);
if (match) {
const parts = match[1].split(/[,\s/]+/).filter(Boolean).map(Number);
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0, parts[3] ?? 1];
}
if (!value || value === "transparent" || value === "none") return [0, 0, 0, 0];
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = "rgba(0, 0, 0, 0)";
ctx.fillStyle = value;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
return [r, g, b, a / 255];
};
const over = (
top: [number, number, number, number],
bottom: [number, number, number],
): [number, number, number] => [
top[0] * top[3] + bottom[0] * (1 - top[3]),
top[1] * top[3] + bottom[1] * (1 - top[3]),
top[2] * top[3] + bottom[2] * (1 - top[3]),
];
const layers: [number, number, number, number][] = [];
for (let node: HTMLElement | null = el; node; node = node.parentElement) {
layers.push(parse(getComputedStyle(node).backgroundColor));
}
let background: [number, number, number] = [255, 255, 255];
for (let i = layers.length - 1; i >= 0; i--) background = over(layers[i], background);
const text = over(parse(getComputedStyle(el).color), background);
const channel = (c: number) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
const luminance = (rgb: [number, number, number]) =>
0.2126 * channel(rgb[0]) + 0.7152 * channel(rgb[1]) + 0.0722 * channel(rgb[2]);
const a = luminance(text);
const b = luminance(background);
const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
const show = (rgb: [number, number, number]) => rgb.map((n) => Math.round(n)).join(",");
return { ratio: Math.round(ratio * 100) / 100, text: show(text), background: show(background) };
}, selector);
}
/** The title of the longest-named block, which is the one that stresses a narrow column. */
export const LONG_TITLE =
"Antithesis x Bengaluru Systems Meetup: deterministic simulation testing in practice";
+90
View File
@@ -0,0 +1,90 @@
// The smallest window the app can be put in. `src-tauri/tauri.conf.json` sets minWidth 880 and
// minHeight 560, so this is not a hypothetical size: it is the corner of the range the product
// ships with, and it is the one nobody looks at.
import { expect, test } from "@playwright/test";
import { gridFit, openApp, settle } from "./app";
const MIN = { width: 880, height: 560 };
test.use({ viewport: MIN });
// FAILING in agenda view, and it is the app, not the test. `.titlebar` is a `1fr auto 1fr` grid
// and `.header-range` is a nowrap button with nothing to trim it, so the agenda's long range
// ("11 August to 9 September 2026") runs out of its column and the view switcher paints over it.
test("the header does not collide with itself in any view", async ({ page }) => {
await openApp(page);
for (const key of ["w", "d", "a"] as const) {
await page.keyboard.press(key);
await settle(page);
const measured = await page.evaluate(() => {
const range = document.querySelector(".header-range")?.getBoundingClientRect();
const views = document.querySelector(".view-switch")?.getBoundingClientRect();
const trail = document.querySelector(".trail")?.getBoundingClientRect();
if (!range || !views || !trail) throw new Error("the header is not on screen");
return {
rangeRight: range.right,
viewsLeft: views.left,
viewsRight: views.right,
trailLeft: trail.left,
};
});
// The date range is a nowrap button in a 1fr column, so when it outgrows its share it runs
// under the view switcher rather than being trimmed.
expect(measured.rangeRight, `date range overlaps the view switcher in ${key}`).toBeLessThanOrEqual(
measured.viewsLeft,
);
expect(measured.viewsRight).toBeLessThanOrEqual(measured.trailLeft);
}
});
test("the grid still fits, and still fills the window", async ({ page }) => {
await openApp(page);
const fit = await gridFit(page);
expect(fit.bodyBottom).toBeCloseTo(fit.windowHeight, 0);
expect(fit.canvasHeight).toBeCloseTo(fit.bodyHeight, 0);
expect(fit.overflow).toBe(false);
await expect(page.locator(".grid-head-day")).toHaveCount(7);
await expect(page.locator(".grid-allday")).toBeVisible();
});
test("no block is painted over by the one after it", async ({ page }) => {
await openApp(page);
// A block shorter than the minimum block height is drawn taller than its own time, which is
// right: it has to stay hittable. It must not be drawn *under* the next block, though, or the
// title it was given that height for is the thing that gets covered.
const covered = await page.evaluate(() =>
[...document.querySelectorAll<HTMLElement>(".grid-event")]
.map((el) => {
const title = el.querySelector<HTMLElement>(".grid-event-title");
if (!title) return null;
const rect = title.getBoundingClientRect();
const hit = document.elementFromPoint(
Math.round(rect.left + 4),
Math.round(rect.top + rect.height / 2),
);
return hit && el.contains(hit)
? null
: `${(title.textContent ?? "").trim()} (${Math.round(el.getBoundingClientRect().height)}px)`;
})
.filter((entry): entry is string => entry !== null),
);
expect(covered).toEqual([]);
});
test("the agenda is readable at the smallest size", async ({ page }) => {
await openApp(page, { view: "agenda" });
const rows = page.locator(".agenda-row");
expect(await rows.count()).toBeGreaterThan(5);
// Nothing spills sideways out of the window.
const spill = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
expect(spill).toBeLessThanOrEqual(0);
});
+196
View File
@@ -0,0 +1,196 @@
// The grid renders, and it fits.
//
// The whole product rests on two properties: the day you are looking at fills the window without
// scrolling, and the axis does not move under you while you navigate. Both are geometry, so both
// are measured rather than asserted about the DOM.
import { expect, test } from "@playwright/test";
import { axis, blocks, box, gridFit, gridReady, headerDates, openApp, settle } from "./app";
test.describe("the grid fits the window", () => {
test("the body is exactly the height the window left it, with nothing to scroll", async ({
page,
}) => {
await openApp(page);
const fit = await gridFit(page);
// The body reaches the bottom of the window: no dead band under the last hour.
expect(fit.bodyBottom).toBeCloseTo(fit.windowHeight, 0);
// And the day was drawn at exactly that height, which is what "the grid never scrolls" means.
expect(fit.canvasHeight).toBeCloseTo(fit.bodyHeight, 0);
expect(fit.bodyScrollHeight).toBeLessThanOrEqual(Math.ceil(fit.bodyHeight) + 1);
expect(fit.overflow).toBe(false);
expect(fit.documentScrollHeight).toBeLessThanOrEqual(fit.documentClientHeight);
// A row solved from a real window is nowhere near the floor the fit gives up at.
expect(fit.rowHeight).toBeGreaterThan(30);
});
test("resizing rescales the day rather than growing a scrollbar", async ({ page }) => {
await openApp(page);
const before = await gridFit(page);
const hoursBefore = (await axis(page)).map((entry) => entry.text);
await page.setViewportSize({ width: 1440, height: 680 });
await settle(page);
const after = await gridFit(page);
expect(after.bodyBottom).toBeCloseTo(after.windowHeight, 0);
expect(after.canvasHeight).toBeCloseTo(after.bodyHeight, 0);
expect(after.overflow).toBe(false);
// Same hours, less room, so the rows are shorter. Nothing was cut off instead.
expect(after.rowHeight).toBeLessThan(before.rowHeight);
expect((await axis(page)).map((entry) => entry.text)).toEqual(hoursBefore);
});
test("the day view fills the window too", async ({ page }) => {
await openApp(page, { view: "day" });
const fit = await gridFit(page);
expect(fit.bodyBottom).toBeCloseTo(fit.windowHeight, 0);
expect(fit.canvasHeight).toBeCloseTo(fit.bodyHeight, 0);
expect(fit.overflow).toBe(false);
});
// FAILING, and it is the app, not the test. `GridView` returns null in the agenda, so the body
// it measured is thrown away, and the ResizeObserver is installed by a `useLayoutEffect` with an
// empty dependency list: it never observes the new body. The grid comes back solved for a
// viewport of zero, sticks at the minimum row height, and no amount of resizing recovers it.
// Only a reload does.
test("coming back from the agenda leaves the grid fitted", async ({ page }) => {
await openApp(page);
const before = await gridFit(page);
await page.keyboard.press("a");
await expect(page.locator(".agenda-row").first()).toBeVisible();
await page.keyboard.press("w");
await gridReady(page);
const after = await gridFit(page);
expect(after.canvasHeight).toBeCloseTo(after.bodyHeight, 0);
expect(after.overflow).toBe(false);
expect(after.rowHeight).toBeCloseTo(before.rowHeight, 0);
});
});
test.describe("the axis holds still", () => {
test("paging a week keeps the same hours on screen", async ({ page }) => {
await openApp(page);
const before = (await axis(page)).map((entry) => entry.text);
const dates = await headerDates(page);
expect(before.length).toBeGreaterThan(6);
for (let i = 0; i < 6; i++) {
await page.keyboard.press("l");
await settle(page);
}
// The dates moved.
expect(await headerDates(page)).not.toEqual(dates);
// The hours did not. The axis itself is the assertion; the header no longer restates it,
// because the hours it printed were already on screen an inch below.
expect((await axis(page)).map((entry) => entry.text)).toEqual(before);
});
// FAILING, and it is the app, not the test. `GridAllDay` sizes itself from its content, one to
// three rows, so the band is 84px on a week with all-day events and 32px on a week without. The
// axis is solved from what the band left over, so paging past them rescales every hour on the
// grid. The comment at the top of GridAllDay.tsx says the band never does this.
test("paging a week does not move the hours on screen either", async ({ page }) => {
await openApp(page);
const before = await axis(page);
const band = (await box(page.locator(".grid-allday"))).height;
const row = (await gridFit(page)).rowHeight;
// Six days on, the fixture's all-day events are behind us. The band has nothing to show, and
// the axis below it must not notice.
for (let i = 0; i < 6; i++) {
await page.keyboard.press("l");
await settle(page);
}
expect((await box(page.locator(".grid-allday"))).height).toBeCloseTo(band, 0);
expect((await gridFit(page)).rowHeight).toBeCloseTo(row, 0);
expect(await axis(page)).toEqual(before);
});
test("paging back does not reflow them either", async ({ page }) => {
await openApp(page);
const before = (await axis(page)).map((entry) => entry.text);
for (let i = 0; i < 4; i++) {
await page.keyboard.press("h");
await settle(page);
}
expect((await axis(page)).map((entry) => entry.text)).toEqual(before);
});
test("the empty ends of the day are folded into a strip at each end", async ({ page }) => {
await openApp(page);
const strips = page.locator(".grid-strip");
await expect(strips).toHaveCount(2);
// Midnight to the first event, and the last event to midnight: both say what they cover.
await expect(strips.first()).toContainText(/12am to \d/);
await expect(strips.last()).toContainText(/to 12am/);
});
});
test.describe("the all-day band", () => {
test("a multi-day event spans exactly the days it covers", async ({ page }) => {
await openApp(page);
const chip = page.locator(".grid-chip", { hasText: "Trip to Goa" });
await expect(chip).toHaveCount(1);
// The week starts on the configured day rather than on today, so which columns the trip
// lands in depends on what day of the week it is. Read them off the header instead of
// assuming, and clip to the week, since a trip starting on a Saturday runs off the end.
const days = await page.locator(".grid-head-date").allTextContents();
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const startCol = days.indexOf(String(tomorrow.getDate()));
expect(startCol, "the trip starts on a visible day").toBeGreaterThanOrEqual(0);
const endCol = Math.min(startCol + 3, days.length - 1);
const first = await box(page.locator(`.grid-col[data-index="${startCol}"]`));
const last = await box(page.locator(`.grid-col[data-index="${endCol}"]`));
const rect = await box(chip);
expect(rect.left).toBeGreaterThanOrEqual(first.left - 4);
expect(rect.left).toBeLessThan(first.right);
expect(rect.right).toBeGreaterThan(last.left);
expect(rect.right).toBeLessThanOrEqual(last.right + 4);
});
test("the band never eats the axis: what does not fit hides behind a count", async ({ page }) => {
await openApp(page);
const band = await box(page.locator(".grid-allday"));
const more = page.locator(".grid-chip-more");
await expect(more).toHaveCount(1);
const fitBefore = await gridFit(page);
await more.click();
await settle(page);
// Expanding shows the rest without pushing the axis around underneath it.
await expect(page.locator(".grid-chip", { hasText: "Dentist" })).toBeVisible();
const fitAfter = await gridFit(page);
expect(fitAfter.rowHeight).toBeCloseTo(fitBefore.rowHeight, 0);
expect((await box(page.locator(".grid-allday"))).height).toBeCloseTo(band.height, 0);
});
});
test.describe("what is on the grid", () => {
test("today is marked and every column carries the fixture's daily events", async ({ page }) => {
await openApp(page);
// Exactly one day is today, and the column under it agrees. Which index that is depends on
// the day of the week, because the week starts on the configured day, not on today.
await expect(page.locator(".grid-head-day[data-today]")).toHaveCount(1);
const marked = await page.locator(".grid-head-day[data-today]").getAttribute("data-index");
await expect(page.locator(`.grid-col[data-index="${marked}"]`)).toHaveAttribute(
"data-today",
"true",
);
const all = await blocks(page);
const columns = new Set(all.map((b) => b.column));
// Seven columns, all of them populated: the fixture's daily habits are on every one.
expect([...columns].sort()).toEqual([0, 1, 2, 3, 4, 5, 6]);
expect(all.length).toBeGreaterThan(30);
});
});
+194
View File
@@ -0,0 +1,194 @@
// Clicking, dragging, creating, moving, resizing. The gestures the calendar is for.
//
// Every assertion here is about what came back on screen: a card naming the event you clicked, a
// block at the time you dragged out, a block that ended up an hour higher than it started.
import { expect, test, type Page } from "@playwright/test";
import { blocks, box, columnX, drag, gridFit, hourY, openApp, settle } from "./app";
/** Sub-pixel layout and a one pixel optical nudge on the gutter labels. */
const PIXEL_SLACK = 3;
const activeTag = (page: Page) => page.evaluate(() => document.activeElement?.tagName ?? null);
/** A block in a named column, which is how a title that repeats every day is addressed. */
const blockIn = (page: Page, column: number, title: string) =>
page.locator(`.grid-col[data-index="${column}"] .grid-event`, { hasText: title });
function near(actual: number, expected: number, slack = PIXEL_SLACK): void {
expect(
Math.abs(actual - expected),
`expected ${actual.toFixed(1)} to be within ${slack}px of ${expected.toFixed(1)}`,
).toBeLessThanOrEqual(slack);
}
test.describe("opening an event", () => {
test("clicking a block opens its details, and Escape closes them", async ({ page }) => {
await openApp(page);
const title = "Design review with the platform team";
const block = page.locator(".grid-event", { hasText: title }).first();
await block.click();
// The card is whatever the app puts on screen for it; what matters is that it is a dialog,
// that it names the event, and that it is not the block itself.
const card = page.getByRole("dialog");
await expect(card).toBeVisible();
await expect(card).toContainText(title);
await page.keyboard.press("Escape");
await expect(card).toHaveCount(0);
});
test("clicking a block selects it", async ({ page }) => {
await openApp(page);
const block = page.locator(".grid-event", { hasText: "Apply to 1 job" }).first();
await block.click();
await expect(block).toHaveAttribute("data-selected", "true");
});
});
test.describe("creating by dragging", () => {
test("dragging empty grid, typing a title and pressing Enter creates it there", async ({
page,
}) => {
await openApp(page);
const fit = await gridFit(page);
const column = 3;
const x = await columnX(page, column);
const top = await hourY(page, "5pm");
expect(top).not.toBeNull();
await drag(page, { x, y: top! + 3 }, { x, y: top! + fit.rowHeight - 3 });
// The create card takes the keyboard the moment it appears.
expect(await activeTag(page)).toBe("INPUT");
await page.keyboard.type("Coffee with Ana");
await page.keyboard.press("Enter");
const created = blockIn(page, column, "Coffee with Ana");
await expect(created).toHaveCount(1);
// On the day it was dragged on, at the hour it was dragged over, an hour long.
const rect = await box(created);
near(rect.top, top!);
near(rect.height, fit.rowHeight);
// And nowhere else.
await expect(page.locator(".grid-event", { hasText: "Coffee with Ana" })).toHaveCount(1);
});
test("Escape cancels the drag without creating anything", async ({ page }) => {
await openApp(page);
const before = (await blocks(page)).length;
const fit = await gridFit(page);
const x = await columnX(page, 2);
const top = await hourY(page, "5pm");
await drag(page, { x, y: top! + 3 }, { x, y: top! + fit.rowHeight - 3 });
expect(await activeTag(page)).toBe("INPUT");
await page.keyboard.press("Escape");
await settle(page);
expect(await activeTag(page)).not.toBe("INPUT");
expect((await blocks(page)).length).toBe(before);
});
});
test.describe("moving and resizing", () => {
test("the preview follows the pointer to the time it will land at", async ({ page }) => {
await openApp(page);
const fit = await gridFit(page);
const block = blockIn(page, 4, "Apply to 1 job");
const before = await box(block);
const fivePm = await hourY(page, "5pm");
const x = before.left + before.width / 2;
const y = before.top + before.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x, y - 12, { steps: 4 });
await page.mouse.move(x, y - fit.rowHeight, { steps: 6 });
// Half past six moved up an hour is half past five, and the preview says so in words as well
// as in pixels.
const ghost = page.locator(".grid-ghost");
await expect(ghost).toBeVisible();
await expect(ghost).toContainText("5pm to 5:30pm");
near((await box(ghost)).top, fivePm!);
await page.mouse.up();
});
test("dragging a block moves it to the time you dropped it at", async ({ page }) => {
await openApp(page);
const fit = await gridFit(page);
const column = 4;
const before = await box(blockIn(page, column, "Apply to 1 job"));
// Straight up by one hour: half six becomes half five, on the same day.
await drag(
page,
{ x: before.left + before.width / 2, y: before.top + before.height / 2 },
{ x: before.left + before.width / 2, y: before.top + before.height / 2 - fit.rowHeight },
);
const after = await box(blockIn(page, column, "Apply to 1 job"));
near(after.top, before.top - fit.rowHeight);
near(after.height, before.height);
// Still one of it, still on the same day.
await expect(blockIn(page, column, "Apply to 1 job")).toHaveCount(1);
});
test("dragging a block onto another day moves it there", async ({ page }) => {
await openApp(page);
const from = 4;
const to = 5;
const before = await box(blockIn(page, from, "Apply to 1 job"));
const target = await columnX(page, to);
await drag(
page,
{ x: before.left + before.width / 2, y: before.top + before.height / 2 },
{ x: target, y: before.top + before.height / 2 },
);
// The day it left has one fewer, the day it landed on has two.
await expect(blockIn(page, from, "Apply to 1 job")).toHaveCount(0);
await expect(blockIn(page, to, "Apply to 1 job")).toHaveCount(2);
});
test("dragging the bottom edge previews the new end", async ({ page }) => {
await openApp(page);
const fit = await gridFit(page);
const before = await box(blockIn(page, 4, "Apply to 1 job"));
const x = before.left + before.width / 2;
await page.mouse.move(x, before.bottom - 2);
await page.mouse.down();
await page.mouse.move(x, before.bottom + 10, { steps: 4 });
await page.mouse.move(x, before.bottom + fit.rowHeight / 2, { steps: 6 });
const ghost = page.locator(".grid-ghost");
await expect(ghost).toContainText("6pm to 7pm");
near((await box(ghost)).top, before.top);
near((await box(ghost)).height, before.height + fit.rowHeight / 2);
await page.mouse.up();
});
test("dragging the bottom edge resizes it", async ({ page }) => {
await openApp(page);
const fit = await gridFit(page);
const column = 4;
const before = await box(blockIn(page, column, "Apply to 1 job"));
const x = before.left + before.width / 2;
// Half an hour longer, which keeps it inside the hours the axis is already showing.
await drag(page, { x, y: before.bottom - 2 }, { x, y: before.bottom + fit.rowHeight / 2 });
const after = await box(blockIn(page, column, "Apply to 1 job"));
near(after.top, before.top);
near(after.height, before.height + fit.rowHeight / 2);
});
});
+183
View File
@@ -0,0 +1,183 @@
// The keyboard, which is the point of the app.
//
// Each test presses the key a user would press and then asks the screen what happened, rather than
// asking a store what it thinks happened.
import { expect, test } from "@playwright/test";
import { axis, columnX, gridFit, gridReady, headerDates, hourY, openApp, settle } from "./app";
const view = (page: import("@playwright/test").Page) =>
page.evaluate(() => document.documentElement.getAttribute("data-view"));
test.describe("navigation", () => {
test("l moves one day forward and h moves one day back", async ({ page }) => {
await openApp(page);
const before = await headerDates(page);
const range = await page.locator(".header-range").textContent();
expect(before).toHaveLength(7);
await page.keyboard.press("l");
await settle(page);
const next = await headerDates(page);
// One day, not a whole week: yesterday's second column is today's first.
expect(next.slice(0, 6)).toEqual(before.slice(1));
expect(await page.locator(".header-range").textContent()).not.toBe(range);
await page.keyboard.press("h");
await settle(page);
expect(await headerDates(page)).toEqual(before);
expect(await page.locator(".header-range").textContent()).toBe(range);
});
test("t comes back to today", async ({ page }) => {
await openApp(page);
const home = await headerDates(page);
for (let i = 0; i < 9; i++) await page.keyboard.press("l");
await settle(page);
await expect(page.locator(".grid-head-day[data-today]")).toHaveCount(0);
await page.keyboard.press("t");
await settle(page);
expect(await headerDates(page)).toEqual(home);
// Today is back on screen. It is not necessarily the first column: the week starts on the
// configured day, so today sits wherever it falls within that week.
await expect(page.locator(".grid-head-day[data-today]")).toHaveCount(1);
});
});
test.describe("views", () => {
test("d, w and a switch view", async ({ page }) => {
await openApp(page);
await expect(page.locator(".grid-col")).toHaveCount(7);
await page.keyboard.press("d");
await gridReady(page);
expect(await view(page)).toBe("day");
await expect(page.locator(".grid-col")).toHaveCount(1);
await expect(page.locator('.view-option[data-active="true"]')).toHaveText("Day");
await page.keyboard.press("a");
await expect(page.locator(".agenda-row").first()).toBeVisible();
expect(await view(page)).toBe("agenda");
await expect(page.locator(".grid")).toHaveCount(0);
await page.keyboard.press("w");
await gridReady(page);
expect(await view(page)).toBe("week");
await expect(page.locator(".grid-col")).toHaveCount(7);
});
test("z folds the band under the cursor, and it is still folded after a reload", async ({
page,
}) => {
await openApp(page);
const fit = await gridFit(page);
const evening = await hourY(page, "5pm");
expect(evening).not.toBeNull();
await expect(page.locator(".grid-strip")).toHaveCount(2);
await page.mouse.move(await columnX(page, 3), evening! + fit.rowHeight / 2);
await page.keyboard.press("z");
await settle(page);
// The hour is gone from the axis and a strip stands where it was.
await expect(page.locator(".grid-strip")).toHaveCount(3);
expect((await axis(page)).map((entry) => entry.text)).not.toContain("5pm");
await expect(page.locator(".grid-strip", { hasText: "5pm to 6pm" })).toHaveCount(1);
await page.reload();
await gridReady(page);
await expect(page.locator(".grid-strip", { hasText: "5pm to 6pm" })).toHaveCount(1);
expect((await axis(page)).map((entry) => entry.text)).not.toContain("5pm");
// And Shift-Z gives every hour back.
await page.keyboard.press("Z");
await settle(page);
expect((await axis(page)).map((entry) => entry.text)).toContain("5pm");
});
});
test.describe("summoning", () => {
test("? opens the shortcut sheet", async ({ page }) => {
await openApp(page);
await page.keyboard.press("?");
const sheet = page.getByRole("dialog", { name: "Keyboard shortcuts" });
await expect(sheet).toBeVisible();
// Generated from the binding table, so the keys it documents are the keys that work.
await expect(sheet).toContainText("Next day");
await page.keyboard.press("Escape");
await expect(sheet).toHaveCount(0);
});
test("/ opens search and it finds events", async ({ page }) => {
await openApp(page);
await page.keyboard.press("/");
const field = page.locator(".agenda-search-input");
await expect(field).toBeFocused();
await page.keyboard.type("meetup");
await expect(page.locator(".agenda-row").first()).toBeVisible();
await expect(page.locator(".agenda-mark").first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(field).toHaveCount(0);
});
test("the palette opens on the primary modifier and K", async ({ page }) => {
await openApp(page);
await page.keyboard.press("ControlOrMeta+k");
const palette = page.getByRole("dialog", { name: "Command palette" });
await expect(palette).toBeVisible();
await page.keyboard.type("agenda");
await page.keyboard.press("Enter");
await expect(page.locator(".agenda-row").first()).toBeVisible();
expect(await view(page)).toBe("agenda");
});
});
test.describe("keys stand back", () => {
test("a text field keeps its letters", async ({ page }) => {
await openApp(page);
await page.keyboard.press("/");
const field = page.locator(".agenda-search-input");
await expect(field).toBeFocused();
// d, w and a are view switches out here and letters in there.
await page.keyboard.type("dwa");
await expect(field).toHaveValue("dwa");
expect(await view(page)).toBe("week");
await page.keyboard.press("Escape");
await gridReady(page);
expect(await view(page)).toBe("week");
});
test("the palette is still reachable from inside a text field", async ({ page }) => {
await openApp(page);
await page.keyboard.press("/");
await expect(page.locator(".agenda-search-input")).toBeFocused();
await page.keyboard.press("ControlOrMeta+k");
await expect(page.getByRole("dialog", { name: "Command palette" })).toBeVisible();
});
test("the create input keeps its letters too", async ({ page }) => {
await openApp(page);
const fit = await gridFit(page);
const x = await columnX(page, 1);
const top = await hourY(page, "5pm");
await page.mouse.move(x, top! + 3);
await page.mouse.down();
await page.mouse.move(x, top! + fit.rowHeight - 3, { steps: 6 });
await page.mouse.up();
await settle(page);
await page.keyboard.type("day away");
// Not "d" for day view, not "a" for agenda: it is a title.
expect(await view(page)).toBe("week");
await page.keyboard.press("Escape");
});
});
+139
View File
@@ -0,0 +1,139 @@
// Can you read it, and can you hit it.
//
// Every bug that shipped was in here: blocks that said "Untitled", five minute events that were
// unreadable slivers, a long title broken into a column of one-word fragments. None of them was a
// crash and none of them was catchable without rendering the thing.
import { expect, test } from "@playwright/test";
import { LONG_TITLE, blocks, contrastOf, openApp } from "./app";
/** Below this a block is a rule rather than an event, whatever the fit decided. */
const MIN_BLOCK_H = 10;
/** Wider than a three-deep overlap column, so this is the width at which wrapping is a choice. */
const NARROW = 140;
/** More than this and a narrow title has become a column of fragments. */
const MAX_LINES = 3;
for (const theme of ["light", "dark"] as const) {
test.describe(`${theme} theme`, () => {
test("no block anywhere says Untitled", async ({ page }) => {
await openApp(page, { theme });
const all = await blocks(page);
const untitled = all.filter((b) => /untitled/i.test(`${b.name} ${b.title}`));
expect(untitled.map((b) => b.name)).toEqual([]);
// Not in the all-day band either, and not hiding in a tooltip.
await expect(page.locator(".grid").getByText("Untitled", { exact: false })).toHaveCount(0);
await expect(page.locator('.grid [title*="Untitled" i]')).toHaveCount(0);
});
test("every block has a non-empty accessible name", async ({ page }) => {
await openApp(page, { theme });
const all = await blocks(page);
expect(all.length).toBeGreaterThan(20);
const nameless = all.filter((b) => b.name.trim() === "");
expect(
nameless.map((b) => `${b.size} block, ${Math.round(b.height)}px, column ${b.column}`),
).toEqual([]);
// One block checked against the platform's own name computation, so the cheap approximation
// the rest of the suite uses cannot quietly drift away from it.
await expect(page.locator(".grid-event").first()).toHaveAccessibleName(/\S/);
});
test("the shortest events are still blocks you can read and hit", async ({ page }) => {
await openApp(page, { theme });
const all = await blocks(page);
// The fixture's five and ten minute events, which is where slivers came from.
const meds = all.filter((b) => b.name.includes("Meds"));
const standup = all.filter((b) => b.name.includes("Stand-up"));
expect(meds.length).toBeGreaterThan(0);
expect(standup.length).toBeGreaterThan(0);
const tooThin = all.filter((b) => b.height < MIN_BLOCK_H);
expect(tooThin.map((b) => `${b.name} at ${Math.round(b.height)}px`)).toEqual([]);
const unhittable = all.filter((b) => !b.hittable);
expect(unhittable.map((b) => `${b.name} in column ${b.column}`)).toEqual([]);
});
test("a title in a narrow column is ellipsized, not stacked one word per line", async ({
page,
}) => {
await openApp(page, { theme });
const all = await blocks(page);
// The fixture's very long title only ever lands in a three-deep overlap.
const long = all.filter((b) => b.name.includes("Antithesis"));
expect(long.length).toBeGreaterThan(0);
expect(Math.min(...long.map((b) => b.width))).toBeLessThan(NARROW);
const stacked = all
.filter((b) => b.width < NARROW && b.titleLines > MAX_LINES)
.map((b) => `${b.name} wrapped to ${b.titleLines} lines in ${Math.round(b.width)}px`);
expect(stacked).toEqual([]);
});
test("a free/busy block says what it is rather than nothing at all", async ({ page }) => {
await openApp(page, { theme });
const all = await blocks(page);
// 90% of the real account came back with no summary. Those blocks still have to say
// something, and it must not be the empty string.
const busy = all.filter((b) => b.busy);
expect(busy.length).toBeGreaterThan(0);
for (const block of busy) expect(block.name.trim()).not.toBe("");
});
test("block text has enough contrast to read", async ({ page }) => {
await openApp(page, { theme });
for (const selector of [".grid-event-title", ".grid-event-time", ".grid-head-name"]) {
const measured = await contrastOf(page, selector);
expect(measured, selector).not.toBeNull();
expect(measured!.ratio, `${selector} is ${measured!.ratio}:1`).toBeGreaterThanOrEqual(4.5);
}
});
test("the quiet layer of type is still type", async ({ page }) => {
await openApp(page, { theme });
// The hours down the gutter, the folded strips and the band's label are all deliberately
// quiet. Small text wants 4.5:1 to meet AA; this holds them to 3, which is the floor for
// text of any size at all, so a failure here is not a matter of taste.
const quiet = [
".grid-gutter-label",
".grid-strip-range",
".grid-allday-gutter",
".grid-chip-more",
];
const failures: string[] = [];
for (const selector of quiet) {
const measured = await contrastOf(page, selector);
expect(measured, selector).not.toBeNull();
if (measured!.ratio < 3) {
failures.push(
`${selector}: ${measured!.ratio}:1 (${measured!.text} on ${measured!.background})`,
);
}
}
expect(failures).toEqual([]);
});
});
}
test("the long title is shown in full somewhere it has room", async ({ page }) => {
// The grid is allowed to cut it. The agenda, which has a whole line, is not.
await openApp(page, { view: "agenda" });
await expect(page.locator(".agenda-row-title", { hasText: "Antithesis" }).first()).toHaveText(
LONG_TITLE,
);
});
test("the agenda does not say Untitled either", async ({ page }) => {
await openApp(page, { view: "agenda" });
await expect(page.locator(".agenda").getByText("Untitled", { exact: false })).toHaveCount(0);
});
+136
View File
@@ -0,0 +1,136 @@
// The overlays: summoned, then dismissed. Nothing here is resident, so the thing to check is that
// each one arrives, takes the keyboard while it is up, and goes away in the right order.
import { expect, test, type Page } from "@playwright/test";
import { openApp, settle } from "./app";
async function openCalendars(page: Page) {
await page.locator('button[title="Calendars"]').click();
}
async function openSettings(page: Page) {
await page.keyboard.press("ControlOrMeta+,");
}
const PANELS = [
{ name: "Jump to", open: (page: Page) => page.keyboard.press("m"), contains: /Today/ },
{ name: "Calendars", open: openCalendars, contains: /you@example\.com/ },
{ name: "Settings", open: openSettings, contains: /Week view/ },
{
name: "Accounts",
open: async (page: Page) => {
await openSettings(page);
await page.getByRole("button", { name: "Manage" }).click();
},
contains: /Connect an account/,
},
];
for (const panel of PANELS) {
test(`${panel.name} opens and closes on Escape`, async ({ page }) => {
await openApp(page);
await panel.open(page);
const dialog = page.getByRole("dialog", { name: panel.name });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(panel.contains);
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
// The grid is still there underneath, and still fitted.
await expect(page.locator(".grid-event").first()).toBeVisible();
});
test(`${panel.name} takes the keyboard while it is open`, async ({ page }) => {
await openApp(page);
await panel.open(page);
await expect(page.getByRole("dialog", { name: panel.name })).toBeVisible();
// `d` is day view out there. In here it belongs to the panel, which does nothing with it.
await page.keyboard.press("d");
await settle(page);
expect(await page.evaluate(() => document.documentElement.getAttribute("data-view"))).toBe(
"week",
);
await expect(page.getByRole("dialog", { name: panel.name })).toBeVisible();
});
}
test("clicking the backdrop dismisses a panel", async ({ page }) => {
await openApp(page);
await openCalendars(page);
const dialog = page.getByRole("dialog", { name: "Calendars" });
await expect(dialog).toBeVisible();
await page.mouse.click(30, 500);
await expect(dialog).toHaveCount(0);
});
test("Escape unwinds one layer at a time", async ({ page }) => {
await openApp(page);
await openSettings(page);
await page.getByRole("button", { name: "Manage" }).click();
const accounts = page.getByRole("dialog", { name: "Accounts" });
await expect(accounts).toBeVisible();
await page.getByRole("button", { name: "Disconnect" }).first().click();
const confirm = page.locator(".confirm");
await expect(confirm).toBeVisible();
await expect(confirm).toContainText("Disconnect [email protected]?");
// The confirmation goes first, and the panel it belongs to is still there behind it.
await page.keyboard.press("Escape");
await expect(confirm).toHaveCount(0);
await expect(accounts).toBeVisible();
await expect(page.getByRole("button", { name: "Disconnect" }).first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(accounts).toHaveCount(0);
});
test("the mini month jumps the view to the day you pick", async ({ page }) => {
await openApp(page);
const range = await page.locator(".header-range").textContent();
await page.keyboard.press("m");
await expect(page.getByRole("dialog", { name: "Jump to" })).toBeVisible();
// A day in the same month that is not the one already showing.
await page.locator(".mini-day:not([data-outside]):not([data-span])").last().click();
await expect(page.getByRole("dialog", { name: "Jump to" })).toHaveCount(0);
expect(await page.locator(".header-range").textContent()).not.toBe(range);
await expect(page.locator(".grid-event").first()).toBeVisible();
});
test("turning a calendar off takes its events off the grid", async ({ page }) => {
await openApp(page);
const meetup = page.locator(".grid-event", { hasText: "Antithesis" });
expect(await meetup.count()).toBeGreaterThan(0);
await openCalendars(page);
await page
.locator(".cal-row", { hasText: "Antithesis x Bengaluru Systems Meetup" })
.locator("input[type=checkbox]")
.uncheck();
await page.keyboard.press("Escape");
await expect(meetup).toHaveCount(0);
});
test("the theme switch actually repaints", async ({ page }) => {
await openApp(page);
const paper = () =>
page.evaluate(() => getComputedStyle(document.querySelector(".grid")!).backgroundColor);
const light = await paper();
await openSettings(page);
await page.getByRole("button", { name: "Dark", exact: true }).click();
await page.keyboard.press("Escape");
await settle(page);
expect(await page.evaluate(() => document.documentElement.getAttribute("data-theme"))).toBe(
"dark",
);
expect(await paper()).not.toBe(light);
});
+200
View File
@@ -0,0 +1,200 @@
// The phone. 390x844 is an iPhone 15's viewport, and it is the size at which the desktop header
// stops fitting at all: three groups on one row want about 700px.
//
// What is checked here is the chrome, not the grid. The bars have to be there instead of the
// header, they have to reach the views, the overflow has to reach everything that lost its icon,
// and every one of them has to be big enough to hit with a thumb. The panels behind them have to
// come up from the bottom edge rather than float in the middle of a screen this small.
import { expect, test, type Locator, type Page } from "@playwright/test";
import { box, openApp, settle } from "./app";
const PHONE = { width: 390, height: 844 };
// hasTouch as well as the size, because a phone is both and the two are separate switches: the
// layout keys off the width and the target sizes key off the pointer.
test.use({ viewport: PHONE, hasTouch: true });
/** The phone opens on the day, which is what the boot script picks when nothing was ever chosen. */
const openPhone = (page: Page) => openApp(page, { view: "day" });
/** The sheet slides up, so a rectangle read too early is a rectangle from halfway there. */
async function landed(target: Locator): Promise<void> {
await target.evaluate((el) => Promise.all(el.getAnimations().map((a) => a.finished)));
}
const openMenu = async (page: Page) => {
await page.getByRole("button", { name: "Menu" }).click();
await expect(page.getByRole("dialog", { name: "Menu" })).toBeVisible();
};
const spill = (page: Page) =>
page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
test("the phone gets two bars instead of the header", async ({ page }) => {
await openPhone(page);
expect(await page.evaluate(() => document.documentElement.hasAttribute("data-phone"))).toBe(true);
await expect(page.locator(".titlebar")).toHaveCount(0);
await expect(page.locator(".phonebar")).toBeVisible();
await expect(page.locator(".tabbar")).toBeVisible();
// The top bar starts at the top and the tab bar ends at the bottom, with the stage between them.
const top = await box(page.locator(".phonebar"));
const tabs = await box(page.locator(".tabbar"));
expect(top.top).toBe(0);
expect(tabs.bottom).toBeCloseTo(PHONE.height, 0);
expect(tabs.top).toBeGreaterThan(top.bottom);
expect(await spill(page)).toBeLessThanOrEqual(0);
});
test("the date range and Today still work from the top bar", async ({ page }) => {
await openPhone(page);
const range = page.locator(".phonebar-range");
const start = await range.textContent();
await page.getByRole("button", { name: "Next day" }).click();
await settle(page);
expect(await range.textContent()).not.toBe(start);
await page.getByRole("button", { name: "Today" }).click();
await settle(page);
expect(await range.textContent()).toBe(start);
// The range is the way into the month, the same as the header's is.
await range.click();
await expect(page.getByRole("dialog", { name: "Jump to" })).toBeVisible();
});
test("the tab bar switches the view", async ({ page }) => {
await openPhone(page);
const view = () => page.evaluate(() => document.documentElement.getAttribute("data-view"));
await page.getByRole("button", { name: "Week", exact: true }).click();
await settle(page);
expect(await view()).toBe("week");
await page.getByRole("button", { name: "Agenda", exact: true }).click();
await expect(page.locator(".agenda-row").first()).toBeVisible();
expect(await view()).toBe("agenda");
expect(await spill(page)).toBeLessThanOrEqual(0);
await page.getByRole("button", { name: "Day", exact: true }).click();
await settle(page);
expect(await view()).toBe("day");
await expect(page.locator(".tabbar-view[data-active='true']")).toHaveText("Day");
});
const ROWS: { label: string; opens: string }[] = [
{ label: "Calendars", opens: "Calendars" },
{ label: "Settings", opens: "Settings" },
{ label: "Accounts", opens: "Accounts" },
{ label: "Keyboard shortcuts", opens: "Keyboard shortcuts" },
];
for (const row of ROWS) {
test(`the overflow menu opens ${row.opens}`, async ({ page }) => {
await openPhone(page);
await openMenu(page);
await page.locator(".menu-item", { hasText: row.label }).click();
await expect(page.getByRole("dialog", { name: row.opens })).toBeVisible();
// One panel at a time: the menu is replaced by what it opened, not stacked under it.
await expect(page.getByRole("dialog", { name: "Menu" })).toHaveCount(0);
});
}
test("the overflow menu reaches search, sync and the theme", async ({ page }) => {
await openPhone(page);
await openMenu(page);
await page.locator(".menu-item", { hasText: "Search events" }).click();
await expect(page.locator(".agenda-search-input")).toBeVisible();
await page.keyboard.press("Escape");
// The theme is the one row that leaves the sheet up, so the second tap is not four taps away.
await openMenu(page);
await page.locator(".menu-item", { hasText: "Dark mode" }).click();
await settle(page);
expect(await page.evaluate(() => document.documentElement.getAttribute("data-theme"))).toBe(
"dark",
);
await expect(page.locator(".menu-item", { hasText: "Light mode" })).toBeVisible();
// Anything that acts in place and is done acting closes it.
await page.locator(".menu-item", { hasText: "Sync now" }).click();
await expect(page.getByRole("dialog", { name: "Menu" })).toHaveCount(0);
});
test("an overlay is a bottom sheet, not a box in the middle", async ({ page }) => {
await openPhone(page);
await openMenu(page);
const panel = page.locator(".panel");
await landed(panel);
const sheet = await box(panel);
expect(sheet.left).toBe(0);
expect(sheet.width).toBe(PHONE.width);
expect(sheet.bottom).toBeCloseTo(PHONE.height, 0);
// It is a layer over the page, so the page is still visible above it.
expect(sheet.height).toBeLessThanOrEqual(PHONE.height * 0.88);
expect(await spill(page)).toBeLessThanOrEqual(0);
// Square top corners would read as a screen rather than something laid over one.
const radii = await panel.evaluate((el) => {
const style = getComputedStyle(el);
return [style.borderTopLeftRadius, style.borderBottomLeftRadius];
});
expect(Number.parseFloat(radii[0])).toBeGreaterThan(0);
expect(Number.parseFloat(radii[1])).toBe(0);
});
test("every control in the chrome is big enough to hit", async ({ page }) => {
await openPhone(page);
await openMenu(page);
// 44px is Apple's floor for a target, and the number the bars are laid out around.
const small = await page.evaluate(() =>
[...document.querySelectorAll<HTMLElement>(".phonebar button, .tabbar button, .menu-item")]
.map((el) => {
const rect = el.getBoundingClientRect();
const name = (el.getAttribute("aria-label") ?? el.innerText ?? "").trim();
return { name, width: Math.round(rect.width), height: Math.round(rect.height) };
})
.filter((control) => Math.min(control.width, control.height) < 44),
);
expect(small).toEqual([]);
});
// A panel reached from the menu has to lead back to it. The menu dispatches the same commands a
// key does, and a command opens a panel with `show`, which clears the trail, so without
// `reachedFrom` closing Settings here drops you on the grid instead of the sheet you were reading.
// That is the same dead end the desktop had before the back arrow existed.
test("a panel opened from the menu goes back to the menu", async ({ page }) => {
await openPhone(page);
await openMenu(page);
await page.getByRole("button", { name: "Settings" }).click();
const settings = page.getByRole("dialog", { name: "Settings" });
await expect(settings).toBeVisible();
const back = settings.locator(".panel-back");
await expect(back).toBeVisible();
await back.click();
await expect(page.getByRole("dialog", { name: "Menu" })).toBeVisible();
await expect(settings).toHaveCount(0);
});
// Opening the same panel straight from a bar is not a journey, so it must not grow a back arrow
// that would take you somewhere you have never been.
test("a panel opened directly has no back arrow", async ({ page }) => {
await openPhone(page);
await page.locator(".phonebar .header-range, .phonebar-range").first().click();
const dialog = page.getByRole("dialog").first();
await expect(dialog).toBeVisible();
await expect(dialog.locator(".panel-back")).toHaveCount(0);
});
+350
View File
@@ -0,0 +1,350 @@
// The grid with a finger.
//
// Everything here is the same app the other specs drive, in a phone-sized window with touch
// emulation on, which is what makes `(pointer: coarse)` true and the pointer events say `touch`.
// The bugs this file exists to catch are all the same bug: a finger press is ambiguous, and the
// grid used to resolve every one of them as "drag out a new event".
//
// Nothing here reaches into the app either. The assertions are what the browser laid out: whether
// a card came back, which dates the day bar is showing, what scrolls.
import { expect, test, type Page } from "@playwright/test";
import { axis, box, gridFit, gridReady, headerDates, openApp, settle } from "./app";
/**
* A phone, and the two emulation flags that go with it. `hasTouch` is what makes the touches below
* arrive as touches; `isMobile` is what makes the media queries agree that this is a phone, so
* `data-phone` and `data-touch` are on the root before the first paint.
*/
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
/** Comfortably past the 350ms the grid waits before a press becomes a drag. */
const HOLD_MS = 600;
/** Comfortably under it. */
const TAP_MS = 70;
interface Point {
x: number;
y: number;
}
/**
* A finger. Playwright's touchscreen only taps, and every gesture below is about what happens
* between the press and the release, so the touches go through CDP instead: those arrive as real
* touch events, which is the only way the pointer events they turn into carry `pointerType`
* `touch`, which is what the grid reads to decide which rules apply.
*/
async function finger(page: Page) {
const session = await page.context().newCDPSession(page);
const send = (type: string, points: Point[]) =>
session.send("Input.dispatchTouchEvent", {
type,
touchPoints: points.map((p) => ({ x: Math.round(p.x), y: Math.round(p.y) })),
});
return {
down: (at: Point) => send("touchStart", [at]),
moveTo: (at: Point) => send("touchMove", [at]),
up: () => send("touchEnd", []),
};
}
async function tapAt(page: Page, at: Point, hold = TAP_MS): Promise<void> {
const hand = await finger(page);
await hand.down(at);
await page.waitForTimeout(hold);
await hand.up();
await settle(page);
}
async function pressAt(page: Page, at: Point): Promise<void> {
await tapAt(page, at, HOLD_MS);
}
/** A press that travels sideways, in the steps a hand would take rather than a teleport. */
async function swipe(page: Page, from: Point, dx: number): Promise<void> {
const hand = await finger(page);
await hand.down(from);
for (let step = 1; step <= 6; step++) {
await hand.moveTo({ x: from.x + (dx * step) / 6, y: from.y });
}
await hand.up();
await settle(page);
}
/**
* A point on the axis with nothing on it: no block, no strip, no fold chip.
*
* The middle of the longest clear run rather than the first clear pixel, because the browser
* widens a touch to the size of a finger and snaps it to any control within a few pixels of where
* it landed. A point four pixels under a strip is a point that presses the strip, and a spec that
* used one would be testing the strip while claiming to test empty grid.
*/
function emptySpot(page: Page): Promise<Point> {
return page.evaluate(() => {
const canvas = document.querySelector<HTMLElement>(".grid-canvas");
const cols = document.querySelector<HTMLElement>(".grid-cols");
if (!canvas || !cols) throw new Error("the grid is not on screen");
const rect = cols.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const bottom = Math.round(rect.bottom);
let best = { y: 0, length: 0 };
let run: number | null = null;
for (let y = Math.round(rect.top); y <= bottom; y++) {
const clear = y < bottom && document.elementFromPoint(x, y) === canvas;
if (clear) {
if (run === null) run = y;
continue;
}
if (run !== null && y - run > best.length) best = { y: run + (y - run) / 2, length: y - run };
run = null;
}
if (best.length < 24) throw new Error("no run of empty grid long enough to press on");
return { x, y: best.y };
});
}
const draft = (page: Page) => page.locator(".quick-create");
test.describe("a press is not a drag", () => {
test("tapping empty grid creates nothing", async ({ page }) => {
await openApp(page, { view: "day" });
const spot = await emptySpot(page);
await tapAt(page, spot);
// The whole complaint: every tap on the grid used to leave an event behind.
await expect(draft(page)).toHaveCount(0);
await expect(page.locator(".grid-ghost")).toHaveCount(0);
});
test("holding empty grid opens a draft there", async ({ page }) => {
await openApp(page, { view: "day" });
const spot = await emptySpot(page);
await pressAt(page, spot);
await expect(draft(page)).toHaveCount(1);
// And it is ready to be typed into, the same way the mouse drag leaves it.
expect(await page.evaluate(() => document.activeElement?.tagName ?? null)).toBe("INPUT");
});
test("tapping a block opens it, holding one picks it up", async ({ page }) => {
await openApp(page, { view: "day" });
const block = page.locator(".grid-event", { hasText: "Design review" }).first();
const rect = await box(block);
const at = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
await tapAt(page, at);
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
// The same press held is the other gesture entirely, and the card must stay out of the way of
// it: the browser sends a click after a touch whatever the touch turned out to be.
const hand = await finger(page);
await hand.down(at);
await page.waitForTimeout(HOLD_MS);
await expect(page.locator(".grid-ghost")).toBeVisible();
await hand.up();
await settle(page);
await expect(page.getByRole("dialog")).toHaveCount(0);
});
test("a tap that wanders a few pixels is still a tap", async ({ page }) => {
await openApp(page, { view: "day" });
const spot = await emptySpot(page);
// Six pixels of thumb drift, which is under the touch slop and well over the mouse's three.
const hand = await finger(page);
await hand.down(spot);
await hand.moveTo({ x: spot.x + 4, y: spot.y + 6 });
await page.waitForTimeout(TAP_MS);
await hand.up();
await settle(page);
await expect(draft(page)).toHaveCount(0);
});
});
test.describe("swiping pages the view", () => {
test("a swipe moves the day view by exactly one day", async ({ page }) => {
await openApp(page, { view: "day" });
const spot = await emptySpot(page);
const [todayDate, tomorrowDate] = await page.evaluate(() => {
const d = new Date();
const next = new Date();
next.setDate(next.getDate() + 1);
return [String(d.getDate()), String(next.getDate())];
});
expect(await headerDates(page)).toEqual([todayDate]);
// Far past the threshold on purpose: a long swipe still buys one day, never a week.
await swipe(page, spot, -240);
expect(await headerDates(page)).toEqual([tomorrowDate]);
// And it paged instead of creating, rather than as well as.
await expect(draft(page)).toHaveCount(0);
});
test("swiping back returns to the day it started on", async ({ page }) => {
await openApp(page, { view: "day" });
const spot = await emptySpot(page);
const before = await headerDates(page);
await swipe(page, spot, -120);
expect(await headerDates(page)).not.toEqual(before);
await swipe(page, await emptySpot(page), 120);
expect(await headerDates(page)).toEqual(before);
});
test("a swipe in the week slides it one column, not seven", async ({ page }) => {
await openApp(page);
const before = await headerDates(page);
expect(before).toHaveLength(7);
await swipe(page, await emptySpot(page), -160);
const after = await headerDates(page);
expect(after).toHaveLength(7);
// Six of the seven days were already on screen, in the same order, one column to the left.
expect(after.slice(0, 6)).toEqual(before.slice(1));
});
test("a short sideways press is neither a swipe nor a create", async ({ page }) => {
await openApp(page, { view: "day" });
const before = await headerDates(page);
// Half the swipe threshold: not enough to page, and it killed the long press on the way.
await swipe(page, await emptySpot(page), -28);
expect(await headerDates(page)).toEqual(before);
await expect(draft(page)).toHaveCount(0);
});
});
test.describe("what a finger can reach", () => {
test("the fold chip is on screen without anything to hover it", async ({ page }) => {
await openApp(page, { view: "day" });
// There is no pointer to rest on an empty run, so every empty run carries its own chip.
const chips = page.locator(".grid-gap-fold");
expect(await chips.count()).toBeGreaterThan(0);
await expect(chips.first()).toBeVisible();
// And it does what it says. Judged on the axis rather than on the number of strips: a run that
// reaches the bounds folds into the strip already there, so folding one does not always leave
// one more behind. Hours coming off the axis is what folding means either way.
const before = (await axis(page)).map((entry) => entry.text);
await chips.first().tap();
await settle(page);
const after = (await axis(page)).map((entry) => entry.text);
expect(after.length).toBeLessThan(before.length);
});
test("the strip says what it does without being pointed at", async ({ page }) => {
await openApp(page, { view: "day" });
const verb = page.locator(".grid-strip .grid-fold-verb").first();
await expect(verb).toBeVisible();
await expect(verb).toHaveText("Expand");
});
test("the resize grips are gone rather than being seven pixels of nothing", async ({ page }) => {
await openApp(page, { view: "day" });
const grips = page.locator(".grid-event-grip");
expect(await grips.count()).toBeGreaterThan(0);
await expect(grips.first()).toBeHidden();
});
test("an all-day chip is tall enough to hit", async ({ page }) => {
await openApp(page);
const chip = page.locator(".grid-chip").first();
const rect = await chip.boundingBox();
expect(rect).not.toBeNull();
expect(rect!.height).toBeGreaterThanOrEqual(24);
});
test("tapping a day header opens that day", async ({ page }) => {
await openApp(page);
const header = page.locator(".grid-head-day").nth(3);
const date = (await header.locator(".grid-head-date").textContent())?.trim();
await header.tap();
await gridReady(page);
await expect(page.locator(".grid-head-day")).toHaveCount(1);
expect(await headerDates(page)).toEqual([date]);
});
});
test.describe("the phone week is still a grid", () => {
test("the day bar carries one letter and the date", async ({ page }) => {
await openApp(page);
const names = await page.locator(".grid-head-name").allTextContents();
expect(names).toHaveLength(7);
for (const name of names) expect(name.trim()).toHaveLength(1);
});
test("nothing spills sideways at 390 pixels, in either view", async ({ page }) => {
for (const view of ["week", "day"] as const) {
await openApp(page, { view });
const spill = await page.evaluate(() => {
const grid = document.querySelector<HTMLElement>(".grid");
if (!grid) throw new Error("the grid is not on screen");
return {
document: document.documentElement.scrollWidth - document.documentElement.clientWidth,
grid: grid.scrollWidth - grid.clientWidth,
};
});
expect(spill.document, `the document scrolls sideways in the ${view}`).toBeLessThanOrEqual(0);
expect(spill.grid, `the grid scrolls sideways in the ${view}`).toBeLessThanOrEqual(0);
}
});
test("no block is drawn past the last column", async ({ page }) => {
// The body clips what hangs off it, so this never reaches the document's scroll width: the
// only way to see it is to compare each block against the edge it is supposed to stop at.
for (const view of ["day", "week"] as const) {
await openApp(page, { view });
const spilled = await page.evaluate(() => {
const cols = document.querySelector<HTMLElement>(".grid-cols");
if (!cols) throw new Error("the grid is not on screen");
const edge = cols.getBoundingClientRect();
return [...document.querySelectorAll<HTMLElement>(".grid-event")]
.map((el) => {
const rect = el.getBoundingClientRect();
const past = Math.round(rect.right - edge.right);
const short = Math.round(edge.left - rect.left);
const title = (el.querySelector(".grid-event-title")?.textContent ?? "").trim();
if (past > 1) return `${title} hangs ${past}px past the last column`;
if (short > 1) return `${title} starts ${short}px before the first column`;
return null;
})
.filter((entry): entry is string => entry !== null);
});
expect(spilled, view).toEqual([]);
}
});
test("the day still fits, between the two bars rather than the window", async ({ page }) => {
for (const view of ["week", "day"] as const) {
await openApp(page, { view });
const fit = await gridFit(page);
expect(fit.canvasHeight, view).toBeCloseTo(fit.bodyHeight, 0);
expect(fit.bodyScrollHeight, view).toBeLessThanOrEqual(Math.ceil(fit.bodyHeight) + 1);
expect(fit.overflow, view).toBe(false);
// The phone took the top and the bottom of the window for its own chrome, so "the day fits"
// now means it fits what is left. The bars are the other agent's; what is checked here is
// that the grid solved for the gap between them and did not run under either.
const top = await box(page.locator(".phonebar"));
const tabs = await box(page.locator(".tabbar"));
const grid = await box(page.locator(".grid"));
expect(grid.top, view).toBeGreaterThanOrEqual(top.bottom - 1);
expect(grid.bottom, view).toBeLessThanOrEqual(tabs.top + 1);
expect(fit.bodyBottom, view).toBeLessThanOrEqual(tabs.top + 1);
}
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [".", "../playwright.config.ts"]
}
+104
View File
@@ -0,0 +1,104 @@
// Day and agenda, which are the two views that are not the week and so are the two nobody looks
// at until they are broken.
import { expect, test } from "@playwright/test";
import { blocks, box, gridFit, openApp } from "./app";
test.describe("day view", () => {
test("renders one day, full of the day's events, filling the window", async ({ page }) => {
await openApp(page, { view: "day" });
await expect(page.locator(".grid-col")).toHaveCount(1);
await expect(page.locator(".grid-head-day")).toHaveCount(1);
await expect(page.locator(".grid-allday")).toBeVisible();
const all = await blocks(page);
expect(all.length).toBeGreaterThan(3);
for (const block of all) expect(block.name.trim()).not.toBe("");
const fit = await gridFit(page);
expect(fit.canvasHeight).toBeCloseTo(fit.bodyHeight, 0);
expect(fit.overflow).toBe(false);
});
test("one column means wide blocks, not a week's worth of slivers", async ({ page }) => {
await openApp(page, { view: "day" });
const column = await box(page.locator('.grid-col[data-index="0"]'));
const all = await blocks(page);
// Nothing overlaps three deep all day, so the widest block should be most of the column.
const widest = Math.max(...all.map((b) => b.width));
expect(widest).toBeGreaterThan(column.width * 0.6);
});
});
test.describe("agenda view", () => {
test("groups by day, in order, with a count that matches the rows", async ({ page }) => {
await openApp(page, { view: "agenda" });
const days = page.locator(".agenda-day");
expect(await days.count()).toBeGreaterThan(5);
const first = days.first();
await expect(first.locator(".agenda-day-name")).toHaveText(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)$/);
const rows = await first.locator(".agenda-row").count();
expect(rows).toBeGreaterThan(0);
await expect(first.locator(".agenda-day-count")).toHaveText(String(rows));
// The dates run forwards.
const dates = await page.locator(".agenda-day-date").allTextContents();
expect(dates.length).toBeGreaterThan(5);
expect(new Set(dates.slice(0, 5)).size).toBe(5);
});
test("all-day events are on the list, ahead of the timed ones on their day", async ({ page }) => {
await openApp(page, { view: "agenda" });
const trip = page.locator(".agenda-row[data-allday]", { hasText: "Trip to Goa" }).first();
await expect(trip).toBeVisible();
await expect(trip.locator(".agenda-row-time")).toHaveText(/all day/i);
const day = page.locator(".agenda-day").filter({ hasText: "Trip to Goa" }).first();
const kinds = await day
.locator(".agenda-row")
.evaluateAll((rows) => rows.map((row) => row.hasAttribute("data-allday")));
expect(kinds.indexOf(true)).toBe(0);
// All-day rows come first and are not interleaved with the timed ones.
expect(kinds.lastIndexOf(true)).toBeLessThan(kinds.indexOf(false));
});
test("the day's rows are in chronological order", async ({ page }) => {
await openApp(page, { view: "agenda" });
const times = await page
.locator(".agenda-day")
.first()
.locator(".agenda-row:not([data-allday]) .agenda-row-time")
.allTextContents();
expect(times.length).toBeGreaterThan(1);
const minutes = times.map((text) => {
const match = text.match(/^(\d+)(?::(\d+))?(am|pm)/i);
if (!match) return -1;
const hour = Number(match[1]) % 12 + (/pm/i.test(match[3]) ? 12 : 0);
return hour * 60 + Number(match[2] ?? 0);
});
expect(minutes).not.toContain(-1);
expect([...minutes].sort((a, b) => a - b)).toEqual(minutes);
});
test("the agenda scrolls rather than being cut off at the window", async ({ page }) => {
await openApp(page, { view: "agenda" });
const scroller = page.locator(".agenda-scroll");
const scrolled = await scroller.evaluate((el) => {
el.scrollTop = el.scrollHeight;
return el.scrollTop;
});
expect(scrolled).toBeGreaterThan(0);
// And the window itself still does not scroll.
expect(
await page.evaluate(
() => document.documentElement.scrollHeight - document.documentElement.clientHeight,
),
).toBeLessThanOrEqual(0);
});
});