Deck/homework writes sync themselves when sync is on

Sync was push-on-demand only: a deck frozen on the laptop stayed in dirty
until someone opened Settings and pressed Sync now. The two stores whose
writes mean "the user just made something" now schedule one debounced
background syncNow("auto") through lib/sync-auto: debounced 2.5s so a burst
of writes is one push, one run at a time, silent on failure (the data stays
dirty and Settings still offers the manual sync), retried on the browser's
online event, and imported dynamically so the sync code stays out of the
first load with no import cycle.

markSyncDirty() now reports whether it actually marked, which keeps pull and
backup-import paths quiet while a real import pushes like any other local
change. Answers and chat keep the existing manual behaviour on purpose —
they change every few seconds while studying.

Tests: phase-1 (a deck write pushes itself, then Sync now is a no-op — 50
checks) and phase-3 (a check run pushes the new homework record — 30 checks).
README: decision 59. SW -> freezmo-v46.
This commit is contained in:
Martin Dvořák 2026-10-12 16:40:49 +00:00
commit abacfb6018
8 changed files with 173 additions and 11 deletions

View file

@ -5,6 +5,7 @@
* Runs against a stub Ollama so no model is needed.
*/
import http from "node:http";
import { mkdirSync } from "node:fs";
import { launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
@ -83,7 +84,14 @@ const stub = http.createServer((request, response) => {
await new Promise((resolve) => stub.listen(STUB_PORT, "127.0.0.1", resolve));
check("stub app port is free", !(await portInUse(APP_PORT)));
const app = spawnApp(APP_PORT, { OLLAMA_BASE_URL: STUB_BASE, OLLAMA_AUTH: "local", OLLAMA_VERIFY_KEY: "false" });
const syncDir = new URL("./.tmp/sync-phase3", import.meta.url).pathname;
mkdirSync(syncDir, { recursive: true });
const app = spawnApp(APP_PORT, {
OLLAMA_BASE_URL: STUB_BASE,
OLLAMA_AUTH: "local",
OLLAMA_VERIFY_KEY: "false",
SYNC_DIR: syncDir,
});
const browser = await launchBrowser();
@ -225,6 +233,53 @@ try {
check("desktop: AI disclaimer on the result", await desktop.page.getByText("Freezmo uses AI and can make mistakes").isVisible());
await desktop.context.close();
/* 4b. a new homework pushes itself when sync is on ---------------- */
const synced = await makePage(browser, { width: 390 });
await seed(synced.page, { decks: [] });
await synced.page.goto(`${APP}/`, { waitUntil: "networkidle" });
await synced.page.waitForTimeout(800);
await synced.page.locator("nav.tabbar").getByRole("button").click();
await synced.page.getByRole("dialog").waitFor({ timeout: 6000 });
await synced.page.waitForTimeout(500);
await synced.page.getByRole("button", { name: "Turn on sync" }).click();
await synced.page.getByPlaceholder("Sync passphrase").fill("correct horse battery");
await synced.page.getByPlaceholder("Repeat the passphrase").fill("correct horse battery");
await synced.page.getByRole("button", { name: "Start syncing" }).click();
await synced.page.waitForTimeout(2500);
await synced.page.keyboard.press("Escape");
await synced.page.waitForTimeout(500);
const blob = () =>
synced.page.evaluate(async () => {
const record = JSON.parse(localStorage.getItem("freezmo.sync.v1"));
const response = await fetch(`/api/sync?id=${record.id}`, { headers: { "x-ollama-key": "test-key" } });
const body = response.ok ? await response.json() : null;
return { id: record.id, dirtyAt: record.dirtyAt, updatedAt: body?.updatedAt ?? null };
});
const beforeHomework = await blob();
check("sync: enabled on this device", /^[a-f0-9]{32}$/.test(beforeHomework.id) && beforeHomework.updatedAt !== null, JSON.stringify(beforeHomework));
// Run a check: saving the homework record must push itself (no Sync now).
await synced.page.goto(`${APP}/homework`, { waitUntil: "networkidle" });
await synced.page.waitForTimeout(900);
await synced.page.getByRole("button", { name: "Paste text instead" }).click();
await synced.page.locator("textarea").first().fill("Solve x + 2 = 4");
await synced.page.getByRole("button", { name: "Next" }).click();
await synced.page.waitForTimeout(400);
await synced.page.getByRole("button", { name: /Check my work/ }).click();
await synced.page.waitForTimeout(400);
await synced.page.locator("#homework-solution").fill("x = 3");
await synced.page.getByRole("button", { name: "Check my work", exact: true }).click();
// The check run + the debounced auto-push (see lib/sync-auto, 2.5s).
await synced.page.waitForTimeout(7500);
const pushed = await blob();
check(
"sync: the new homework pushed itself",
pushed.dirtyAt === null && pushed.updatedAt !== null && pushed.updatedAt !== beforeHomework.updatedAt,
JSON.stringify({ before: beforeHomework.updatedAt, after: pushed.updatedAt }),
);
await synced.context.close();
const guide = await makePage(browser, { width: 390 });
await seed(guide.page, { decks: [] });
await guide.page.goto(`${APP}/homework`, { waitUntil: "networkidle" });