- 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.
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: "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();
|