/** * 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 freezer" }).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();