Files
pj d4c3a304b5 Sign in on a phone with no console work, in the browser's own session
Mobile OAuth reused the desktop client all along; what stopped it was the
browser. Sending the user out to Safari or Chrome backgrounds the app, iOS
suspends it, and the redirect carrying the code arrives at a socket nobody
is accepting on. The consent page now opens in front of the app instead, in
SFSafariViewController or a Chrome Custom Tab, so the loopback listener
stays live and the existing `installed` client is enough. Verified against
Google's real consent screen on a simulator and an emulator.

A per-platform client is still supported and is now an upgrade rather than a
prerequisite. On iOS it buys ASWebAuthenticationSession, which shares
Safari's session so nobody is asked to sign in to Google twice. Android
needs nothing: Custom Tabs share Chrome's cookies, measured rather than
assumed. iOS session sharing could not be confirmed on the simulator and
wants a real device.

Never an app-owned WebView: Google blocks it, and rightly, since a webview
the app controls can read the password typed into it.

Cancelling is no longer reported as a failure. AuthEvent carries a
`cancelled` flag, set by comparing against the constant every back-out path
returns, and Google's `access_denied` on desktop counts too.

Five frontend bugs found by driving the real UI, not by reading it: the
details card slid under the tab bar leaving its buttons unhittable; the
ghost click after a touch pressed a button in the card that tap had just
opened, opening the editor by itself; the swipe that pages the day was dead
over every read-only block; 84px of macOS traffic-light lane was reserved on
platforms with no traffic lights; and the desktop header ignored the top
safe area on an iPad. A first launch now says what to do next rather than
showing an empty grid, and accounts are named as Google accounts throughout.
2026-08-12 18:32:45 +05:30

201 lines
8.3 KiB
TypeScript

// 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: "Google accounts", opens: "Google 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);
});