freezmo/tests/native-test.mjs
Martin Dvorak df34337b4a Accessibility pass, report control for homework exercises, clearer rail actions
- axe is clean across every screen in both themes, phone and desktop. Fixed:
  the hidden file inputs (aria-hidden + tabIndex -1 — they are opened by
  labelled buttons, so they should not be in the accessibility tree), the
  heading order (deck/homework/smart-review cards are h2 under the page h1;
  LearningSection takes a level so it stays h2 on the library and h3 inside
  the settings dialog) and landmarks around the study and review feeds.
- tests/contrast.mjs (new diagnostic): samples rendered pixels, because axe
  cannot judge text on blurred glass — it reports those nodes as incomplete.
  It skips disabled controls (1.4.3 exempts them) and content behind an open
  sheet. Result: no below-minimum text on any scanned screen.
- The report control exists in the homework guide too: under each "Try it
  yourself" exercise, labelled "Report exercise", without a skip (there is no
  queue to skip). lib/question-reports now takes deckId/deckTitle directly.
- The rail's two creation actions are buttons now (.nav-action) rather than
  quiet nav rows — once the library is full they were the only way to start
  something on desktop and read as just more navigation. "Make my homework"
  is "New homework" wherever it is an action (rail, empty state, share
  chooser, manifest shortcut); the page title and mode switch keep their
  wording.

Version 0.1.6 (three task bumps), SW -> freezmo-v52.
2026-10-12 21:15:56 +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: "New 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();