freezmo/tests/native-test.mjs
Martin Dvorak dbd5dfbc58 Move the e2e suites into the repo (tests/), out of /tmp
The browser harness lived in /tmp/opencode and an overnight reboot
wiped it. It now lives in tests/ and survives reboots, is versioned
and runs with one command.

- tests/lib.mjs: shared helpers (Chromium auto-detect, seeding,
  IndexedDB fixtures, stub/extra instance spawn with process-group
  cleanup, check/finish).
- tests/run-all.mjs + npm run test:e2e [filter]: runs the ten suites
  in order with a summary; suites that need a stub Ollama or VAPID
  keys start their own next start instance on 3208/3209, verify the
  port is free and kill the group on exit. Test scratch in
  tests/.tmp/ (gitignored).
- Suites rebuilt and green against the current build: regression 38,
  mobile 94 (tab matrix, ... sheet, settings sheet, axe), wizard 83,
  native 31 (PWA/offline/share/back/undo/haptics/draft), i18n 13,
  freeze-loading 6, review 15, phase1 36 (backup/sync/usage limits),
  phase2 42 (learning engine/reminders), phase3 21 (homework check).
  379 checks, 10/10 suites in one full run.
- playwright-core + axe-core added as devDependencies; CHROME env or
  the newest ~/.cache/ms-playwright chromium is used.
- README: new decision 45 documenting the harness; decision 44's
  verification note updated.

The older suites' exact sources were lost with the wipe, so the
rebuilt versions reconstruct the same feature areas rather than
being byte-identical.
2026-10-11 15:11:06 +00:00

190 lines
9.5 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();
}
/* 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();