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.
209 lines
10 KiB
JavaScript
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();
|