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.
378 lines
16 KiB
JavaScript
378 lines
16 KiB
JavaScript
/**
|
|
* Phase 1 verification: backup export/import, storage persistence row, backup
|
|
* nudge, opt-in encrypted sync (enable/join/push/delete + server validation),
|
|
* and usage-limit handling (client wiring + real server mapping against a stub
|
|
* Ollama).
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import http from "node:http";
|
|
|
|
import {
|
|
BASE,
|
|
DECK,
|
|
launchBrowser,
|
|
makeChecker,
|
|
makePage,
|
|
portInUse,
|
|
seed,
|
|
spawnApp,
|
|
waitForHttp,
|
|
} from "./lib.mjs";
|
|
|
|
const { check, finish } = makeChecker();
|
|
|
|
const DECK_A = { ...DECK, id: "deck_a", title: "Photosynthesis basics", folderId: "fold_bio", updatedAt: 1 };
|
|
const DECK_B = { ...DECK, id: "deck_b", title: "Trigonometry", folderId: undefined, updatedAt: 1 };
|
|
const FOLDER = { id: "fold_bio", name: "Biology", createdAt: 1 };
|
|
const HOMEWORK = {
|
|
id: "hw_1",
|
|
mode: "make",
|
|
title: "Quadratic equation",
|
|
language: "English",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
sourceText: "Solve x^2 - 4 = 0",
|
|
answer: "x = 2 or x = -2",
|
|
};
|
|
|
|
const browser = await launchBrowser();
|
|
|
|
async function openSettings(page) {
|
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(800);
|
|
await page.locator("nav.tabbar").getByRole("button").click();
|
|
await page.getByRole("dialog").waitFor({ timeout: 6000 });
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
/* 1. export contains everything except the key --------------------- */
|
|
{
|
|
const { context, page } = await makePage(browser);
|
|
await seed(page, { decks: [DECK_A, DECK_B], folders: [FOLDER], homework: [HOMEWORK] });
|
|
await openSettings(page);
|
|
const downloadPromise = page.waitForEvent("download");
|
|
await page.getByRole("button", { name: "Export all data" }).click();
|
|
const download = await downloadPromise;
|
|
const raw = readFileSync(await download.path(), "utf8");
|
|
const backup = JSON.parse(raw);
|
|
check("backup: kind and version", backup.app === "freezmo" && backup.kind === "backup" && backup.version === 1);
|
|
check(
|
|
"backup: decks, folders and homework included",
|
|
backup.data.decks.length === 2 && backup.data.folders.length === 1 && backup.data.homework.length === 1,
|
|
);
|
|
check("backup: API key never in the file", !raw.includes("test-key") && !raw.includes("apiKey") && !raw.includes("x-ollama-key"));
|
|
check("backup: safe settings included", typeof backup.data.settings?.haptics === "boolean");
|
|
await context.close();
|
|
}
|
|
|
|
/* 2. import preview + merge + replace ------------------------------ */
|
|
{
|
|
const { context, page } = await makePage(browser);
|
|
await seed(page, { decks: [DECK_A, DECK_B], folders: [FOLDER], homework: [HOMEWORK] });
|
|
await openSettings(page);
|
|
const incoming = {
|
|
app: "freezmo",
|
|
kind: "backup",
|
|
version: 1,
|
|
exportedAt: new Date().toISOString(),
|
|
data: { decks: [{ ...DECK_A, id: "deck_new", title: "Imported deck", folderId: undefined }], folders: [], homework: [], settings: { haptics: true } },
|
|
};
|
|
await page.getByRole("dialog").locator('input[type="file"][accept*="json"]').setInputFiles({
|
|
name: "backup.json",
|
|
mimeType: "application/json",
|
|
buffer: Buffer.from(JSON.stringify(incoming)),
|
|
});
|
|
await page.waitForTimeout(400);
|
|
check("import: preview shown", await page.getByText(/This backup has 1 decks/).isVisible());
|
|
await page.getByRole("button", { name: "Merge" }).click();
|
|
await page.waitForTimeout(400);
|
|
check("import: merge adds the deck", await page.getByText(/Imported 3 decks/).isVisible());
|
|
await page.keyboard.press("Escape");
|
|
await page.waitForTimeout(500);
|
|
check("import: deck visible in the library", await page.getByText("Imported deck").first().isVisible());
|
|
|
|
await openSettings(page);
|
|
const replace = {
|
|
app: "freezmo",
|
|
kind: "backup",
|
|
version: 1,
|
|
exportedAt: new Date().toISOString(),
|
|
data: { decks: [{ ...DECK_A, id: "only_deck", title: "Only deck", folderId: undefined }], folders: [], homework: [] },
|
|
};
|
|
await page.getByRole("dialog").locator('input[type="file"][accept*="json"]').setInputFiles({
|
|
name: "backup.json",
|
|
mimeType: "application/json",
|
|
buffer: Buffer.from(JSON.stringify(replace)),
|
|
});
|
|
await page.waitForTimeout(400);
|
|
await page.getByRole("button", { name: "Replace" }).click();
|
|
await page.waitForTimeout(400);
|
|
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1")).map((deck) => deck.title));
|
|
check("import: replace wipes and uses the backup", stored.length === 1 && stored[0] === "Only deck", JSON.stringify(stored));
|
|
await context.close();
|
|
}
|
|
|
|
/* 3. invalid backup + persistence row ------------------------------ */
|
|
{
|
|
const { context, page } = await makePage(browser);
|
|
await seed(page, { decks: [DECK_A, DECK_B] });
|
|
await openSettings(page);
|
|
await page.getByRole("dialog").locator('input[type="file"][accept*="json"]').setInputFiles({
|
|
name: "junk.json",
|
|
mimeType: "application/json",
|
|
buffer: Buffer.from("{not json"),
|
|
});
|
|
await page.waitForTimeout(300);
|
|
check("import: junk file gets a friendly error", await page.getByText("That file is not valid JSON.").isVisible());
|
|
check("storage: persistence row present", await page.getByText("Storage", { exact: true }).isVisible());
|
|
const storageText = await page.getByText(/Persistent —|Best-effort —/).first().innerText();
|
|
check("storage: state explained", /Persistent|Best-effort/.test(storageText), storageText.slice(0, 40));
|
|
await context.close();
|
|
}
|
|
|
|
/* 4. backup nudge --------------------------------------------------- */
|
|
{
|
|
const { context, page } = await makePage(browser);
|
|
await seed(page, {
|
|
decks: [DECK_A, DECK_B],
|
|
onlyIfMissing: true,
|
|
extra: { "freezmo.backup.v1": JSON.stringify({ lastBackupAt: Date.now() - 15 * 86_400_000, decksAtLastBackup: 0, snoozedUntil: null }) },
|
|
});
|
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(900);
|
|
check("nudge: appears after 14 days", await page.getByText("Back up your decks?").isVisible());
|
|
await page.getByRole("button", { name: "Not now" }).click();
|
|
await page.waitForTimeout(300);
|
|
check("nudge: dismissed", (await page.getByText("Back up your decks?").count()) === 0);
|
|
await page.reload({ waitUntil: "networkidle" });
|
|
await page.waitForTimeout(900);
|
|
check("nudge: stays dismissed after reload", (await page.getByText("Back up your decks?").count()) === 0);
|
|
await context.close();
|
|
}
|
|
|
|
/* 5. encrypted sync: enable, upload, push, join, delete ------------- */
|
|
{
|
|
const { context, page } = await makePage(browser);
|
|
// No folder references here: a deck pointing at a folder that is not in
|
|
// this browser's library is not rendered, which would hide the deck the
|
|
// suite then expects to find again on the joined device.
|
|
const syncA = { ...DECK, id: "deck_a", title: "Photosynthesis basics", updatedAt: 1 };
|
|
const syncB = { ...DECK, id: "deck_b", title: "Trigonometry", updatedAt: 1 };
|
|
await seed(page, { decks: [syncA, syncB] });
|
|
await openSettings(page);
|
|
check("sync: off by default", (await page.getByRole("button", { name: "Turn on sync" }).count()) === 1);
|
|
await page.getByRole("button", { name: "Turn on sync" }).click();
|
|
await page.waitForTimeout(200);
|
|
await page.getByPlaceholder("Sync passphrase").fill("correct horse battery");
|
|
await page.getByPlaceholder("Repeat the passphrase").fill("correct horse battery");
|
|
await page.getByRole("button", { name: "Start syncing" }).click();
|
|
await page.waitForTimeout(2500);
|
|
check("sync: enabled and uploaded", await page.getByText("Uploaded from this device.").isVisible());
|
|
const syncRecord = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.sync.v1")));
|
|
check("sync: random id (>=32 hex)", /^[a-f0-9]{32}$/.test(syncRecord.id), syncRecord.id);
|
|
|
|
const envelope = await page.evaluate(async (id) => {
|
|
const response = await fetch(`/api/sync?id=${id}`, { headers: { "x-ollama-key": "test-key" } });
|
|
return { status: response.status, body: await response.text() };
|
|
}, syncRecord.id);
|
|
check("sync: server has the blob", envelope.status === 200);
|
|
check(
|
|
"sync: blob is opaque",
|
|
!envelope.body.includes("Photosynthesis") && !envelope.body.includes("test-key") && !envelope.body.includes("apiKey"),
|
|
);
|
|
|
|
// a real UI write marks dirty, then push
|
|
await page.keyboard.press("Escape");
|
|
await page.waitForTimeout(500);
|
|
await page.getByRole("button", { name: "Deck actions" }).first().click();
|
|
await page.waitForTimeout(300);
|
|
await page.getByRole("button", { name: "Delete deck" }).click();
|
|
await page.waitForTimeout(400);
|
|
const dirty = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.sync.v1")).dirtyAt);
|
|
check("sync: local change marks dirty", typeof dirty === "number");
|
|
const remainingTitle = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0].title);
|
|
await openSettings(page);
|
|
await page.getByRole("button", { name: "Sync now" }).click();
|
|
await page.waitForTimeout(2500);
|
|
check("sync: push reports uploaded", await page.getByText("Uploaded from this device.").isVisible());
|
|
const id = syncRecord.id;
|
|
await context.close();
|
|
|
|
// second device joins
|
|
const second = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
|
const page2 = await second.newPage();
|
|
await seed(page2, { decks: [], key: "other-key" });
|
|
await page2.goto(BASE, { waitUntil: "networkidle" });
|
|
await page2.waitForTimeout(800);
|
|
await page2.locator("nav.tabbar").getByRole("button").click();
|
|
await page2.getByRole("dialog").waitFor({ timeout: 6000 });
|
|
await page2.waitForTimeout(400);
|
|
await page2.getByRole("button", { name: "Join another sync" }).click();
|
|
await page2.waitForTimeout(200);
|
|
await page2.getByPlaceholder("Sync ID").fill(id);
|
|
await page2.getByPlaceholder("Sync passphrase").fill("wrong passphrase");
|
|
await page2.getByRole("button", { name: "Join" }).click();
|
|
await page2.waitForTimeout(2000);
|
|
check("sync: wrong passphrase rejected", await page2.getByText(/can't unlock the synced copy/).isVisible());
|
|
await page2.getByPlaceholder("Sync passphrase").fill("correct horse battery");
|
|
await page2.getByRole("button", { name: "Join" }).click();
|
|
await page2.waitForTimeout(2500);
|
|
await page2.keyboard.press("Escape");
|
|
await page2.waitForTimeout(600);
|
|
check("sync: joined device pulled the decks", await page2.getByText(remainingTitle).first().isVisible(), remainingTitle);
|
|
await second.close();
|
|
|
|
// delete synced data (a device without the stored key can still revoke)
|
|
const third = await makePage(browser);
|
|
await seed(third.page, {
|
|
decks: [],
|
|
extra: { "freezmo.sync.v1": JSON.stringify({ v: 1, enabled: true, id, salt: "", lastSyncedAt: null, dirtyAt: null, conflict: false }) },
|
|
});
|
|
await openSettings(third.page);
|
|
await third.page.getByRole("button", { name: "Delete synced data" }).click();
|
|
await third.page.waitForTimeout(200);
|
|
await third.page.getByRole("button", { name: "Delete", exact: true }).click();
|
|
await third.page.waitForTimeout(1500);
|
|
const after = await third.page.evaluate(async (syncId) => {
|
|
const response = await fetch(`/api/sync?id=${syncId}`, { headers: { "x-ollama-key": "test-key" } });
|
|
return response.status;
|
|
}, id);
|
|
check("sync: delete removes the server copy", after === 404, String(after));
|
|
await third.context.close();
|
|
}
|
|
|
|
/* 6. sync server validation ----------------------------------------- */
|
|
{
|
|
const { context, page } = await makePage(browser);
|
|
await seed(page, { decks: [] });
|
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
|
const badId = await page.evaluate(async () => {
|
|
const response = await fetch("/api/sync?id=short", { headers: { "x-ollama-key": "test-key" } });
|
|
return response.status;
|
|
});
|
|
check("sync api: bad id → 400", badId === 400, String(badId));
|
|
const badBody = await page.evaluate(async () => {
|
|
const response = await fetch("/api/sync", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "x-ollama-key": "test-key" },
|
|
body: JSON.stringify({ id: "a".repeat(32), envelope: { v: 2 } }),
|
|
});
|
|
return response.status;
|
|
});
|
|
check("sync api: invalid envelope → 400", badBody === 400, String(badBody));
|
|
const noAuth = await page.evaluate(async () => {
|
|
const response = await fetch(`/api/sync?id=${"a".repeat(32)}`);
|
|
return response.status;
|
|
});
|
|
check("sync api: no key → 401", noAuth === 401, String(noAuth));
|
|
await context.close();
|
|
}
|
|
|
|
/* 7. usage limit: client wiring (deck flow + settings banner) ------- */
|
|
{
|
|
const { context, page } = await makePage(browser);
|
|
await seed(page, { decks: [] });
|
|
await page.route("**/api/generate", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/x-ndjson",
|
|
body: JSON.stringify({ type: "error", error: "monthly usage", kind: "usage_limit" }) + "\n",
|
|
}),
|
|
);
|
|
await page.route("**/api/health", (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
ok: false,
|
|
reachable: true,
|
|
mode: "direct-cloud",
|
|
authMode: "user-key",
|
|
needsKey: false,
|
|
keyAccepted: true,
|
|
baseUrl: "https://ollama.com",
|
|
hasApiKey: true,
|
|
vision: { configured: "v", resolved: "v", available: true },
|
|
text: { configured: "t", resolved: "t", available: true },
|
|
latencyMs: 10,
|
|
modelCount: 5,
|
|
error: "monthly usage",
|
|
kind: "usage_limit",
|
|
checkedAt: new Date().toISOString(),
|
|
},
|
|
}),
|
|
);
|
|
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(900);
|
|
await page.getByRole("button", { name: "Paste text instead" }).click();
|
|
await page.locator("textarea").first().fill("Notes for the usage limit test.");
|
|
await page.getByRole("button", { name: "Next" }).click();
|
|
await page.waitForTimeout(300);
|
|
await page.getByRole("button", { name: "Next" }).click();
|
|
await page.waitForTimeout(300);
|
|
await page.getByRole("button", { name: "Freeze my deck" }).click();
|
|
await page.waitForTimeout(1200);
|
|
check("usage: clear title in the deck flow", await page.getByText("Free monthly usage used up").isVisible());
|
|
check("usage: upgrade/new-key message shown", await page.getByText(/Upgrade your Ollama account, or create a new one/).isVisible());
|
|
check("usage: Open Ollama button", await page.getByRole("button", { name: "Open Ollama" }).isVisible());
|
|
await page.getByRole("button", { name: "Open Settings" }).click();
|
|
await page.getByRole("dialog").waitFor({ timeout: 6000 });
|
|
await page.waitForTimeout(600);
|
|
check("usage: settings banner shows the limit", await page.getByRole("dialog").getByText("Free monthly usage used up").isVisible());
|
|
const focused = await page.evaluate(() => document.activeElement?.id ?? "");
|
|
check("usage: key field focused", focused === "ollama-api-key-phone" || focused === "ollama-api-key", focused);
|
|
await context.close();
|
|
}
|
|
|
|
/* 8. usage limit: real server mapping against a stub Ollama --------- */
|
|
{
|
|
check("stub port is free", !(await portInUse(3208)));
|
|
let mode = "monthly";
|
|
const stub = http.createServer((request, response) => {
|
|
if (request.url?.startsWith("/api/tags")) {
|
|
response.writeHead(200, { "Content-Type": "application/json" });
|
|
response.end(JSON.stringify({ models: [{ name: "stub", model: "stub" }] }));
|
|
return;
|
|
}
|
|
response.writeHead(429, { "Content-Type": "application/json" });
|
|
response.end(
|
|
JSON.stringify({
|
|
error: mode === "monthly" ? "You have reached your monthly usage limit. Upgrade your plan." : "Too many requests, slow down.",
|
|
}),
|
|
);
|
|
});
|
|
await new Promise((resolve) => stub.listen(11999, "127.0.0.1", resolve));
|
|
|
|
const app = spawnApp(3208, {
|
|
OLLAMA_BASE_URL: "http://127.0.0.1:11999",
|
|
OLLAMA_AUTH: "local",
|
|
OLLAMA_VERIFY_KEY: "false",
|
|
});
|
|
|
|
try {
|
|
const up = await waitForHttp("http://127.0.0.1:3208/api/health");
|
|
check("stub app instance is up", up);
|
|
if (up) {
|
|
const monthly = await fetch("http://127.0.0.1:3208/api/grade", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ question: "q", referenceAnswer: "a", keyPoints: ["k"], userAnswer: "x" }),
|
|
});
|
|
const monthlyBody = await monthly.json();
|
|
check("stub server: monthly 429 → usage_limit", monthlyBody.kind === "usage_limit", JSON.stringify(monthlyBody).slice(0, 80));
|
|
|
|
mode = "burst";
|
|
const burst = await fetch("http://127.0.0.1:3208/api/grade", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ question: "q", referenceAnswer: "a", keyPoints: ["k"], userAnswer: "x" }),
|
|
});
|
|
const burstBody = await burst.json();
|
|
check("stub server: burst 429 → throttled", burstBody.kind === "throttled", JSON.stringify(burstBody).slice(0, 80));
|
|
}
|
|
} finally {
|
|
app.stop();
|
|
stub.close();
|
|
}
|
|
}
|
|
|
|
await browser.close();
|
|
finish();
|