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.
190 lines
9.5 KiB
JavaScript
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();
|