freezmo/tests/native-test.mjs
Martin Dvorak aac6befb0d App versioning + the create page's missing top padding
Versioning: package.json carries the app version (0.1.2 here; one bump per
task with `npm run version:bump`, patch 0-9 rolling into the minor at 10),
baked into the build via lib/version.ts, reported by /api/healthz and shown
in Settings next to the running commit. AGENTS.md documents the rule; the
native suite checks both the API and the Settings row.

Fix: on desktop the app header is hidden, so every page owns its top padding —
/create was the only one without `lg:pt-10`, which slammed the Study deck /
Make my homework switch against the top of the window while /homework had a
comfortable gap. Both now sit at 40px; the wizard suite asserts the clearance
and that the two pages stay in step (90 checks).

SW -> freezmo-v50.
2026-10-12 20:04:26 +00:00

209 lines
10 KiB
JavaScript

/**
* Native-feel suite: PWA head/manifest, offline handling, the OS share
* target (GET + POST), the Back gesture layers, undo toasts, tab re-tap,
* haptics persistence and wizard draft restore.
*/
import { BASE, DECK, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
const { check, finish } = makeChecker();
const browser = await launchBrowser();
/* 1. PWA head + manifest --------------------------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(600);
const head = await page.evaluate(() => ({
manifest: document.querySelector('link[rel="manifest"]')?.getAttribute("href") ?? "",
themeColor: document.querySelector('meta[name="theme-color"]')?.getAttribute("content") ?? "",
appleIcon: document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute("href") ?? "",
viewport: document.querySelector('meta[name="viewport"]')?.getAttribute("content") ?? "",
}));
check("manifest linked", head.manifest.includes("manifest.webmanifest"), head.manifest);
check("theme-color present", head.themeColor.startsWith("#"), head.themeColor);
check("apple-touch-icon present", head.appleIcon.length > 0, head.appleIcon);
check("viewport allows zoom (no user-scalable=no)", !/user-scalable\s*=\s*no/i.test(head.viewport), head.viewport);
const manifest = await page.evaluate(async () => (await fetch("/manifest.webmanifest")).json());
check("manifest is standalone", manifest.display === "standalone", manifest.display);
check("manifest has icons", Array.isArray(manifest.icons) && manifest.icons.length >= 2, String(manifest.icons?.length));
await context.close();
}
/* 1b. the app version is reported and shown in Settings --------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(600);
const health = await page.evaluate(async () => (await fetch("/api/healthz", { cache: "no-store" })).json());
check("healthz reports the app version", /^\d+\.\d+\.\d+$/.test(health.version ?? ""), String(health.version));
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(700);
check(
"settings shows the app version",
(await page.getByText(new RegExp(`v${String(health.version).replace(/\./g, "\\.")}`)).count()) >= 1,
String(health.version),
);
await context.close();
}
/* 2. offline: banner + generation disabled --------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
await page.waitForTimeout(700);
await page.getByRole("button", { name: "Paste text instead" }).click();
await page.locator("textarea").first().fill("Offline test notes.");
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(300);
check("online: Freeze enabled", await page.getByRole("button", { name: "Freeze my deck" }).isEnabled());
await context.setOffline(true);
await page.waitForTimeout(600);
check("offline: banner appears", await page.getByText(/You're offline/).first().isVisible().catch(() => false));
check("offline: Freeze disabled", await page.getByRole("button", { name: "Freeze my deck" }).isDisabled());
await context.setOffline(false);
await page.waitForTimeout(500);
check("online again: Freeze enabled", await page.getByRole("button", { name: "Freeze my deck" }).isEnabled());
await context.close();
}
/* 3. share target: text (GET) and file (POST) ------------------------ */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(`${BASE}/share?text=Photosynthesis%20is%20cool`, { waitUntil: "networkidle" });
await page.waitForTimeout(600);
check("share: chooser shows the text", await page.getByText("Photosynthesis is cool").isVisible());
await page.getByRole("button", { name: "New deck" }).click();
await page.waitForTimeout(900);
check("share: deck wizard prefilled", (await page.locator("textarea").first().inputValue()).includes("Photosynthesis"));
const posted = await page.request.post(`${BASE}/share-target`, {
multipart: {
text: "Solve: 2x = 4",
files: { name: "shared.txt", mimeType: "text/plain", buffer: Buffer.from("Shared document text about photosynthesis.") },
},
maxRedirects: 0,
});
const location = posted.headers()["location"] ?? "";
check("share-target redirects to the chooser", location.includes("/share?id="), location);
// The server builds the Location from the request host (0.0.0.0 here).
const target = location.replace(/^https?:\/\/[^/]+/, BASE);
await page.goto(target, { waitUntil: "networkidle" });
await page.waitForTimeout(600);
check("share: chooser lists the file", await page.getByText("shared.txt").isVisible());
await page.getByRole("button", { name: "Make my homework" }).first().click();
await page.waitForTimeout(1200);
check("share: homework got the file", await page.getByText("shared.txt").first().isVisible());
await context.close();
}
/* 4. back gesture closes layers -------------------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [DECK] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(700);
check(
"back: no hamburger or drawer",
(await page.getByRole("button", { name: "Open menu" }).count()) === 0 &&
(await page.getByRole("complementary", { name: "Menu" }).count()) === 0,
);
check("back: tab bar present on the library", await page.locator("nav.tabbar").isVisible());
await page.locator("nav.tabbar").getByRole("link", { name: "Progress" }).click();
await page.waitForTimeout(700);
await page.goBack();
await page.waitForTimeout(600);
check("back: returns from the Progress tab", await page.getByRole("heading", { name: "Your icebox" }).isVisible());
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(400);
await page.goBack();
await page.waitForTimeout(600);
check("back: settings closed", (await page.getByRole("dialog").count()) === 0);
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "Paste text instead" }).click();
await page.locator("textarea").first().fill("Back gesture notes.");
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(400);
check("back: on step 2", await page.getByText("2 / 3").isVisible());
await page.goBack();
await page.waitForTimeout(500);
check("back: returns to step 1", await page.getByText("1 / 3").isVisible());
check("back: still in the wizard", await page.getByRole("heading", { name: "Your notes" }).isVisible());
await context.close();
}
/* 5. undo toast on deck delete --------------------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [{ ...DECK, id: "native_deck", title: "Native feel deck" }] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(700);
await page.getByRole("button", { name: "Deck actions" }).click();
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Delete deck" }).click();
await page.waitForTimeout(500);
check("undo: deck removed", (await page.getByText("Native feel deck").count()) === 0);
check("undo: toast shown", await page.getByText("Deck deleted").isVisible());
await page.getByRole("button", { name: "Undo" }).click();
await page.waitForTimeout(500);
check("undo: deck restored", await page.getByText("Native feel deck").first().isVisible());
await context.close();
}
/* 6. share fallback + tab re-tap + haptics toggle + draft ------------ */
{
const { context, page } = await makePage(browser);
await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: BASE });
await seed(page, { decks: [{ ...DECK, id: "native_deck", title: "Native feel deck" }] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(700);
await page.getByRole("button", { name: "Deck actions" }).click();
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Share", exact: true }).click();
await page.waitForTimeout(600);
const shareToast = await page.getByText(/Copied to the clipboard|Sharing isn't available/).count();
check("share: fallback reports via toast", shareToast >= 1);
await page.evaluate(() => window.scrollTo(0, 1200));
await page.waitForTimeout(300);
await page.locator("nav.tabbar").getByRole("link", { name: "Library" }).click();
await page.waitForTimeout(700);
const scrolled = await page.evaluate(() => window.scrollY);
check("tab re-tap scrolls to the top", scrolled < 40, String(scrolled));
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(600);
const toggle = page.getByRole("switch", { name: "Haptics" });
check("haptics: toggle present and on", (await toggle.count()) === 1 && (await toggle.getAttribute("aria-checked")) === "true");
await toggle.click();
await page.waitForTimeout(300);
check("haptics: toggled off", (await toggle.getAttribute("aria-checked")) === "false");
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.settings.v1") ?? "{}").haptics);
check("haptics: persisted", stored === false, String(stored));
await page.keyboard.press("Escape");
await page.waitForTimeout(400);
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "Paste text instead" }).click();
await page.locator("textarea").first().fill("Draft survives a reload.");
await page.waitForTimeout(800);
await page.reload({ waitUntil: "networkidle" });
await page.waitForTimeout(900);
const restored = await page.locator("textarea").first().inputValue().catch(() => "");
check("draft: text restored after reload", restored.includes("Draft survives a reload."), restored.slice(0, 40));
await context.close();
}
await browser.close();
finish();