From 57a1965a6d8b2a3b81b53bca4da54ec1f84adf34 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Wed, 10 Jun 2026 11:00:09 +0930 Subject: [PATCH] add aden's requested changes --- .github/workflows/pr-ci.yml | 6 ++ docs/CLOUD_SETTINGS_SYNC_OPTIONS.md | 30 +++---- docs/CLOUD_SETTINGS_SYNC_SERVER.md | 17 ++-- jest.config.js | 4 + package.json | 5 ++ scripts/smoke-test.mjs | 49 +++++++++++ src/background/cloudSettingsAutoSync.ts | 50 +++++++++-- src/seqta/utils/CloudAuth.ts | 2 + .../utils/cloudSettingsSync.legacy.test.ts | 75 ++++++++++++++++ .../utils/cloudSettingsSync.patch.test.ts | 88 +++++++++++++++++++ src/seqta/utils/cloudSettingsSync.ts | 84 +++++++++++++++--- .../utils/ensureSyncableStorageDefaults.ts | 2 +- src/test/mocks/webextension-polyfill.ts | 35 ++++++++ 13 files changed, 403 insertions(+), 44 deletions(-) create mode 100644 scripts/smoke-test.mjs create mode 100644 src/seqta/utils/cloudSettingsSync.legacy.test.ts create mode 100644 src/seqta/utils/cloudSettingsSync.patch.test.ts create mode 100644 src/test/mocks/webextension-polyfill.ts diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index e2d567fe..dbc5ab58 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -23,7 +23,13 @@ jobs: env: ESLINT_USE_FLAT_CONFIG: "false" + - name: Unit tests + run: npm test + - name: Build extension uses: ./.github/actions/build-extension with: gh_release_update_check: "false" + + - name: Smoke tests + run: npm run test:smoke diff --git a/docs/CLOUD_SETTINGS_SYNC_OPTIONS.md b/docs/CLOUD_SETTINGS_SYNC_OPTIONS.md index 34d88bbc..69b9393a 100644 --- a/docs/CLOUD_SETTINGS_SYNC_OPTIONS.md +++ b/docs/CLOUD_SETTINGS_SYNC_OPTIONS.md @@ -47,11 +47,11 @@ Cloud settings sync is a **whole-snapshot backup** of extension local storage: │ │ menuitems… │ │ │ │ │ │ │ └─────────────┘ └──────────────┘ └────────────────────────┘ │ └────────────────────────────┬────────────────────────────────────┘ - │ buildUploadPayload() - │ • filter exclusions - │ • migrateLegacyToPluginSettings() + │ buildUploadPatch() + │ • ensureSyncableStorageDefaults (local) + │ • normalize + diff vs last upload / defaults ▼ - PUT /api/bsplus/settings/sync + PUT /api/bsplus/settings/sync (sparse data patch) { schemaVersion, themeId, data: { … } } │ ▼ @@ -108,9 +108,9 @@ Download is skipped when server `schemaVersion` > client `CLOUD_SETTINGS_SYNC_SC After download, if `themeId` / `selectedTheme` is non-empty, the service worker sets `bsplus_pending_theme_ensure_after_cloud` so the page `ThemeManager` can download missing theme assets from the store. -### Full schema before upload +### Local schema before diff (upload) -`ensureSyncableStorageDefaults()` (`src/seqta/utils/ensureSyncableStorageDefaults.ts`) ensures every **cloud-syncable** key exists in `chrome.storage.local` with its default value if it was previously absent. This makes uploads and dev JSON exports contain a complete schema (e.g. `customshortcuts: []`, `shortcuts: [...]`, every `plugin.{id}.settings` object) instead of omitting keys the user never touched. +`ensureSyncableStorageDefaults()` (`src/seqta/utils/ensureSyncableStorageDefaults.ts`) ensures every **cloud-syncable** key exists in `chrome.storage.local` with its default value if it was previously absent. This runs **locally only** so diffs against the last-uploaded snapshot (or schema defaults) are consistent. The PUT body is a **sparse patch** of changed keys only — defaults are not bulk-uploaded. | When it runs | Context | |--------------|---------| @@ -118,6 +118,7 @@ After download, if `themeId` / `selectedTheme` is non-empty, the service worker | `browser.runtime.onStartup` | Service worker | | Service worker load | `background.ts` (once at startup) | | `initializeSettingsState()` | SEQTA content script + extension settings page (first init) | +| Before each cloud upload | `putSettingsOnce()` in `cloudSettingsAutoSync.ts` | Rules: @@ -146,22 +147,14 @@ Content-Type: application/json "schemaVersion": 1, "themeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "data": { - "onoff": true, "DarkMode": true, - "selectedTheme": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "selectedColor": "linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)", - "plugin.global-search.settings": { - "enabled": true, - "searchHotkey": "ctrl+k", - "showRecentFirst": true, - "transparencyEffects": true, - "runIndexingOnLoad": true, - "passiveIndexing": true - } + "selectedTheme": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } } ``` +Only keys that changed since the last successful upload appear in `data`. The server merges this patch into stored JSON; omitted keys are not deleted. Dev JSON export (`getSnapshotForUpload`) still returns the full normalized map for debugging. + | Top-level field | JSON type | Rules | |-----------------|-----------|-------| | `schemaVersion` | `number` | Always `1` today (`CLOUD_SETTINGS_SYNC_SCHEMA_VERSION`) | @@ -238,6 +231,7 @@ Implemented in `shouldOmitKeyFromCloudPayload(key)`: | `plugin.assessments-average.storage.assessments` | object | Keep device value | | `plugin.assessments-average.storage.weightings` | object | Keep device value | | `bsplus_cloud_settings_known_remote_updated_at` | ISO `string` | Keep device value | +| `bsplus_cloud_settings_last_uploaded_snapshot` | `object` (normalized syncable map) | Keep device value | | `bsplus_lastCloudPoll` | `number` (ms) | Keep device value | | `bsplus_pending_theme_ensure_after_cloud` | `string` (theme id) | Keep device value | @@ -1061,7 +1055,7 @@ Any new `chrome.storage.local` key syncs automatically unless added to: 2. At least one changed key passes `isKeyIncludedInCloudUploadPayload` 3. `autoCloudSettingsSync !== false` 4. Valid access token -5. Not during restore → schedule 2 s debounce → `PUT` full snapshot +5. Not during restore → schedule 2 s debounce → `PUT` sparse patch (skip if empty) ### Server storage suggestion diff --git a/docs/CLOUD_SETTINGS_SYNC_SERVER.md b/docs/CLOUD_SETTINGS_SYNC_SERVER.md index 2a57d8cd..bf64f30a 100644 --- a/docs/CLOUD_SETTINGS_SYNC_SERVER.md +++ b/docs/CLOUD_SETTINGS_SYNC_SERVER.md @@ -28,7 +28,7 @@ Use the same **access tokens** issued by the existing BetterSEQTA+ OAuth flows ( ### `PUT /api/bsplus/settings/sync` -Upserts the caller’s settings backup. +Upserts the caller’s settings backup. The server **merges** `data` into the stored JSON document; keys omitted from the patch are **not** deleted. **Request body (JSON):** @@ -37,12 +37,14 @@ Upserts the caller’s settings backup. "schemaVersion": 1, "themeId": "uuid-string-or-empty", "data": { - "...": "flat key-value map mirroring extension storage (see Payload shape)", + "DarkMode": true, "selectedTheme": "uuid-or-empty-string" } } ``` +The extension sends a **sparse patch**: only keys that changed since the last successful upload (or, on first upload, keys that differ from schema defaults). A full snapshot is not required. + - **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations. - **`themeId`**: optional but recommended duplicate of **`data.selectedTheme`**. Should be the UUID of the **installed** BetterSEQTA store theme (`selectedTheme`). This may be a normal theme id **or** a **flavour (slave) variant** id from themes with **`flavours[]`** — the extension uses it after restore to prefetch `theme.json` when missing locally (same **`GET …/themes/{id}/download`** as the store UI). Persist and return **`themeId`** in sync with **`data.selectedTheme`**. - **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`). @@ -51,11 +53,12 @@ Upserts the caller’s settings backup. ```json { - "updated_at": "2026-04-07T12:00:00.000Z" + "updated_at": "2026-04-07T12:00:00.000Z", + "patch": { "DarkMode": true } } ``` -`updated_at` should be an ISO 8601 timestamp of the save time. The extension displays success without requiring extra fields. +`updated_at` should be an ISO 8601 timestamp of the save time. The extension displays success without requiring extra fields. Optional `patch` may echo the merged keys applied server-side. **Error responses:** Standard JSON error body if applicable, e.g. `{ "error": "message" }`, with appropriate HTTP status (`401`, `413`, `422`, etc.). @@ -103,7 +106,8 @@ Unique constraint on `user_id`. ## Semantics -- **Last write wins:** each `PUT` replaces the stored backup for that user. +- **Merge on PUT:** each `PUT` deep-merges `data` into the stored backup for that user. Keys not present in the request body remain unchanged on the server. +- **Full document on GET:** restore and poll download still receive the complete hydrated settings object. - **Optional later:** `If-Unmodified-Since` or a `revision` field for conflict detection (not required for v1). ## Security and privacy @@ -121,6 +125,7 @@ The backup is a flat JSON map of **`chrome.storage.local`** keys. It does **not* - **Assessment Averages caches** — `plugin.assessments-average.storage.assessments`, `plugin.assessments-average.storage.weightings` (school assessment data). - **Keys under** `plugin.global-search.storage.*` — reserved so any future plugin storage cache there is not synced. - **Grade Analytics** — keys under `bsplus.analytics.*` (synced assessment cache and per-school chart preferences). +- **`bsplus_cloud_settings_last_uploaded_snapshot`** — client-only normalized map last acked by PUT; used to compute sparse upload patches (not part of the cloud backup blob). - **`bsplus_cloud_settings_known_remote_updated_at`** — client-only watermark for auto-sync (not part of the cloud backup blob). On restore, those keys are **not** taken from the server; the device keeps its current local values. @@ -136,6 +141,6 @@ This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages, ## Client reference (extension) -- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, **`bsplus_pending_theme_ensure_after_cloud`**, and **`bsplus_cloud_settings_known_remote_updated_at`** — includes **`themeId`** aligned with **`selectedTheme`**). +- Upload (sparse patch): `buildUploadPatch` / `putSettingsOnce` in `src/background/cloudSettingsAutoSync.ts`; baseline from `bsplus_cloud_settings_last_uploaded_snapshot` or schema defaults; dev full export via `getSnapshotForUpload` / `buildUploadPayload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, client-only metadata — includes **`themeId`** aligned with **`selectedTheme`**). - Download: resolve id via **`resolveThemeIdForPostSyncDownload`** → **`applyDownloadedEnvelope`** after `GET` → prefetch theme blobs in page context if needed (**`prepareThemeAfterCloudSync`** in **`ThemeManager`**) → reload SEQTA tabs; local auth keys, sensitive device keys, client-only watermark, and **`bsplus_pending_theme_ensure_after_cloud`** semantics preserved as documented above. - Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`. diff --git a/jest.config.js b/jest.config.js index 7e690b7b..428e2515 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,6 +9,10 @@ export default { transform: { '^.+\\.ts$': 'ts-jest', }, + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + '^webextension-polyfill$': '/src/test/mocks/webextension-polyfill.ts', + }, moduleFileExtensions: ['ts', 'js', 'json'], collectCoverageFrom: [ 'src/**/*.ts', diff --git a/package.json b/package.json index fad5924b..c7c04fc9 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari", "dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg", "lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint \"src/**/*.{js,ts}\"", + "test": "jest", + "test:smoke": "node scripts/smoke-test.mjs", "release": "gh release create $npm_package_version --repo BetterSEQTA/BetterSEQTA-Plus ./dist/*.zip --generate-notes", "publish": "bun lib/publish.js --b", "zip": "bedframe zip" @@ -43,6 +45,7 @@ "@crxjs/vite-plugin": "^2.4.0", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", + "@types/jest": "^30.0.0", "@types/mime-types": "^3.0.1", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", @@ -53,6 +56,7 @@ "eslint": "^9.33.0", "eslint-plugin-import": "^2.31.0", "glob": "^11.0.1", + "jest": "^30.4.2", "mime-types": "^3.0.1", "prettier": "^3.5.3", "process": "^0.11.10", @@ -61,6 +65,7 @@ "sass-loader": "^16.0.5", "semver": "^7.7.1", "tailwindcss": "3", + "ts-jest": "^29.4.11", "url": "^0.11.4" }, "dependencies": { diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs new file mode 100644 index 00000000..a19d3e16 --- /dev/null +++ b/scripts/smoke-test.mjs @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); + +function fail(message) { + console.error(`[smoke-test] ${message}`); + process.exit(1); +} + +function assertManifest(browserDir) { + const manifestPath = path.join(root, "dist", browserDir, "manifest.json"); + if (!fs.existsSync(manifestPath)) { + fail(`Missing ${manifestPath} — run npm run build first`); + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if (!manifest.manifest_version) fail(`${browserDir} manifest missing manifest_version`); + if (!manifest.name) fail(`${browserDir} manifest missing name`); + if (!manifest.version) fail(`${browserDir} manifest missing version`); + + const sw = + manifest.background?.service_worker ?? + manifest.background?.scripts?.[0] ?? + null; + if (sw) { + const swPath = path.join(root, "dist", browserDir, sw); + if (!fs.existsSync(swPath)) { + fail(`${browserDir} service worker not found on disk: ${sw}`); + } + } +} + +function assertAssets(browserDir) { + const assetsDir = path.join(root, "dist", browserDir, "assets"); + if (!fs.existsSync(assetsDir)) { + fail(`Missing assets directory: dist/${browserDir}/assets`); + } + const jsFiles = fs.readdirSync(assetsDir).filter((f) => f.endsWith(".js")); + if (jsFiles.length === 0) { + fail(`No JS assets in dist/${browserDir}/assets`); + } +} + +for (const browser of ["chrome", "firefox"]) { + assertManifest(browser); + assertAssets(browser); +} + +console.log("[smoke-test] dist/chrome and dist/firefox look OK"); diff --git a/src/background/cloudSettingsAutoSync.ts b/src/background/cloudSettingsAutoSync.ts index 1ac8433f..37224124 100644 --- a/src/background/cloudSettingsAutoSync.ts +++ b/src/background/cloudSettingsAutoSync.ts @@ -1,12 +1,19 @@ import browser from "webextension-polyfill"; +import { + ensureSyncableStorageDefaults, + getSyncableStorageDefaults, +} from "@/seqta/utils/ensureSyncableStorageDefaults"; import { applyDownloadedEnvelope, - buildUploadPayload, BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY, BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY, + buildUploadPatch, CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, isKeyIncludedInCloudUploadPayload, + normalizeStorageForSync, resolveThemeIdForPostSyncDownload, + saveLastUploadedSnapshot, + getLastUploadedSnapshot, setKnownRemoteUpdatedAt, } from "@/seqta/utils/cloudSettingsSync"; @@ -138,13 +145,38 @@ async function fetchCloudSummaryWithAuthRetry( } type PutResult = - | { ok: true; updated_at?: string } + | { ok: true; updated_at?: string; skipped?: boolean } | { ok: false; unauthorized: boolean; error?: string }; +async function resolveUploadBaseline( + normalized: Record, + watermark: string | undefined, +): Promise> { + const lastSnapshot = await getLastUploadedSnapshot(); + if (lastSnapshot) return lastSnapshot; + + if (watermark) { + await saveLastUploadedSnapshot(normalized); + return normalized; + } + + return getSyncableStorageDefaults(); +} + async function putSettingsOnce(token: string): Promise { try { - const all = await browser.storage.local.get(); - const payload = buildUploadPayload(all as Record); + await ensureSyncableStorageDefaults(); + + const all = (await browser.storage.local.get()) as Record; + const normalized = normalizeStorageForSync(all); + const watermark = all[BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as string | undefined; + const baseline = await resolveUploadBaseline(normalized, watermark); + const payload = buildUploadPatch(all, baseline); + + if (!payload) { + return { ok: true, skipped: true }; + } + const r = await fetch(CLOUD_SETTINGS_SYNC_URL, { method: "PUT", headers: { @@ -163,6 +195,7 @@ async function putSettingsOnce(token: string): Promise { }; } const updated_at = data?.updated_at as string | undefined; + await saveLastUploadedSnapshot(normalized); await setKnownRemoteUpdatedAt(updated_at); return { ok: true, updated_at }; } catch (e) { @@ -176,11 +209,13 @@ async function putSettingsOnce(token: string): Promise { export async function performCloudSettingsUploadWithRetry( token: string, -): Promise<{ success: boolean; error?: string; updated_at?: string }> { +): Promise<{ success: boolean; error?: string; updated_at?: string; skipped?: boolean }> { let t = token; for (let attempt = 0; attempt < 2; attempt++) { const res = await putSettingsOnce(t); - if (res.ok) return { success: true, updated_at: res.updated_at }; + if (res.ok) { + return { success: true, updated_at: res.updated_at, skipped: res.skipped }; + } if (res.unauthorized && attempt === 0) { const refreshed = await tryRefreshTokens(); if (!refreshed) return { success: false, error: "Not authenticated" }; @@ -234,6 +269,9 @@ async function getSettingsAndApplyOnce(token: string): Promise { reloadSeqtaPagesFn?.(); const updated_at = data?.updated_at as string | undefined; await setKnownRemoteUpdatedAt(updated_at); + await saveLastUploadedSnapshot( + normalizeStorageForSync((await browser.storage.local.get()) as Record), + ); return { ok: true, updated_at }; } catch (e) { return { diff --git a/src/seqta/utils/CloudAuth.ts b/src/seqta/utils/CloudAuth.ts index 29cd4617..0a178c94 100644 --- a/src/seqta/utils/CloudAuth.ts +++ b/src/seqta/utils/CloudAuth.ts @@ -1,5 +1,6 @@ import browser from "webextension-polyfill"; import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache"; +import { clearLastUploadedSnapshot } from "@/seqta/utils/cloudSettingsSync"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback"; @@ -205,6 +206,7 @@ class CloudAuthService { public async logout(): Promise { const userId = this._state.user?.id; if (userId) await clearCloudPfpCache(userId); + await clearLastUploadedSnapshot(); await browser.storage.local.remove([ STORAGE_KEYS.accessToken, STORAGE_KEYS.refreshToken, diff --git a/src/seqta/utils/cloudSettingsSync.legacy.test.ts b/src/seqta/utils/cloudSettingsSync.legacy.test.ts new file mode 100644 index 00000000..d4cbcbe9 --- /dev/null +++ b/src/seqta/utils/cloudSettingsSync.legacy.test.ts @@ -0,0 +1,75 @@ +import { + isKeyIncludedInCloudUploadPayload, + migrateLegacyToPluginSettings, + normalizeThemeIdForSync, + resolveThemeIdForPostSyncDownload, +} from "./cloudSettingsSync"; + +describe("migrateLegacyToPluginSettings", () => { + it("maps animatedbk without overwriting existing plugin fields", () => { + const result = migrateLegacyToPluginSettings({ + animatedbk: true, + "plugin.animated-background.settings": { speed: 1.5 }, + }); + expect(result["plugin.animated-background.settings"]).toEqual({ + speed: 1.5, + enabled: true, + }); + expect(result).not.toHaveProperty("animatedbk"); + }); + + it("does not set enabled when legacy key is absent", () => { + const result = migrateLegacyToPluginSettings({ + "plugin.animated-background.settings": { speed: 1.0 }, + }); + expect(result["plugin.animated-background.settings"]).toEqual({ speed: 1.0 }); + }); +}); + +describe("isKeyIncludedInCloudUploadPayload", () => { + it("excludes auth and device cache prefixes", () => { + expect(isKeyIncludedInCloudUploadPayload("bsplus_token")).toBe(false); + expect(isKeyIncludedInCloudUploadPayload("plugin.global-search.storage.index")).toBe( + false, + ); + expect(isKeyIncludedInCloudUploadPayload("bsplus.analytics.v2.school.1")).toBe(false); + }); + + it("includes core and plugin settings keys", () => { + expect(isKeyIncludedInCloudUploadPayload("DarkMode")).toBe(true); + expect(isKeyIncludedInCloudUploadPayload("plugin.profile-picture.settings")).toBe(true); + }); +}); + +describe("resolveThemeIdForPostSyncDownload", () => { + it("prefers top-level themeId over data.selectedTheme", () => { + expect( + resolveThemeIdForPostSyncDownload({ + themeId: " top-id ", + data: { selectedTheme: "other-id" }, + }), + ).toBe("top-id"); + }); + + it("falls back to data.selectedTheme", () => { + expect( + resolveThemeIdForPostSyncDownload({ + data: { selectedTheme: "from-data" }, + }), + ).toBe("from-data"); + }); + + it("returns undefined when theme id is empty", () => { + expect( + resolveThemeIdForPostSyncDownload({ + data: { selectedTheme: " " }, + }), + ).toBeUndefined(); + }); +}); + +describe("normalizeThemeIdForSync", () => { + it("normalizes envelope theme ids consistently", () => { + expect(normalizeThemeIdForSync(" uuid ")).toBe("uuid"); + }); +}); diff --git a/src/seqta/utils/cloudSettingsSync.patch.test.ts b/src/seqta/utils/cloudSettingsSync.patch.test.ts new file mode 100644 index 00000000..a4523129 --- /dev/null +++ b/src/seqta/utils/cloudSettingsSync.patch.test.ts @@ -0,0 +1,88 @@ +import { + buildUploadPatch, + CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, + diffSyncableStorage, + normalizeStorageForSync, + normalizeThemeIdForSync, +} from "./cloudSettingsSync"; + +describe("normalizeStorageForSync", () => { + it("strips omitted auth and client-only keys", () => { + const normalized = normalizeStorageForSync({ + DarkMode: true, + bsplus_token: "secret", + bsplus_cloud_settings_known_remote_updated_at: "2026-01-01T00:00:00.000Z", + "bsplus.analytics.v2.school.1": { cached: true }, + }); + expect(normalized).toEqual({ DarkMode: true }); + }); + + it("migrates legacy animatedbk to plugin settings", () => { + const normalized = normalizeStorageForSync({ animatedbk: true }); + expect(normalized).toEqual({ + "plugin.animated-background.settings": { enabled: true }, + }); + }); +}); + +describe("diffSyncableStorage", () => { + it("returns empty object when maps are identical", () => { + const map = { DarkMode: true, onoff: true }; + expect(diffSyncableStorage(map, { ...map })).toEqual({}); + }); + + it("includes only changed scalar keys", () => { + const current = { DarkMode: false, onoff: true }; + const baseline = { DarkMode: true, onoff: true }; + expect(diffSyncableStorage(current, baseline)).toEqual({ DarkMode: false }); + }); + + it("includes whole plugin object when nested value changes", () => { + const current = { + "plugin.global-search.settings": { enabled: true, searchHotkey: "ctrl+k" }, + }; + const baseline = { + "plugin.global-search.settings": { enabled: false, searchHotkey: "ctrl+k" }, + }; + expect(diffSyncableStorage(current, baseline)).toEqual({ + "plugin.global-search.settings": { enabled: true, searchHotkey: "ctrl+k" }, + }); + }); + + it("does not emit keys removed locally (absent from current)", () => { + const current = { DarkMode: true }; + const baseline = { DarkMode: true, onoff: true }; + expect(diffSyncableStorage(current, baseline)).toEqual({}); + }); +}); + +describe("buildUploadPatch", () => { + it("returns null when current matches baseline", () => { + const all = { DarkMode: true, selectedTheme: "" }; + const baseline = { DarkMode: true, selectedTheme: "" }; + expect(buildUploadPatch(all, baseline)).toBeNull(); + }); + + it("returns sparse envelope when values differ", () => { + const all = { + DarkMode: false, + selectedTheme: "theme-uuid", + bsplus_token: "ignore-me", + }; + const baseline = { DarkMode: true, selectedTheme: "theme-uuid" }; + const patch = buildUploadPatch(all, baseline); + expect(patch).toEqual({ + schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, + themeId: "theme-uuid", + data: { DarkMode: false }, + }); + }); +}); + +describe("normalizeThemeIdForSync", () => { + it("trims whitespace and returns empty for non-strings", () => { + expect(normalizeThemeIdForSync(" abc ")).toBe("abc"); + expect(normalizeThemeIdForSync(undefined)).toBe(""); + expect(normalizeThemeIdForSync(" ")).toBe(""); + }); +}); diff --git a/src/seqta/utils/cloudSettingsSync.ts b/src/seqta/utils/cloudSettingsSync.ts index 4c41857a..4f2ba31b 100644 --- a/src/seqta/utils/cloudSettingsSync.ts +++ b/src/seqta/utils/cloudSettingsSync.ts @@ -1,4 +1,5 @@ import browser from "webextension-polyfill"; +import isEqual from "lodash/isEqual"; /** Matches the contract in docs/CLOUD_SETTINGS_SYNC_SERVER.md */ export const CLOUD_SETTINGS_SYNC_SCHEMA_VERSION = 1; @@ -17,6 +18,13 @@ export const BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY = export const BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY = "bsplus_pending_theme_ensure_after_cloud"; +/** + * Client-only: normalized syncable storage last acked by a successful PUT. + * Never uploaded; used to compute sparse upload patches. + */ +export const BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY = + "bsplus_cloud_settings_last_uploaded_snapshot"; + /** * Never uploaded to the cloud backup (OAuth and legacy keys). * IndexedDB (e.g. Global Search’s `betterseqta-index` database) is not part of @@ -48,6 +56,7 @@ export const SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES = [ const CLIENT_ONLY_CLOUD_KEYS_EXACT = [ BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY, + BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY, "bsplus_lastCloudPoll", BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY, ] as const; @@ -118,30 +127,79 @@ export function normalizeThemeIdForSync(raw: unknown): string { return raw.trim(); } -export function buildUploadPayload(all: Record): { - schemaVersion: number; - themeId: string; - data: Record; -} { +/** Filter omit lists and migrate legacy keys → full syncable map for diff/export. */ +export function normalizeStorageForSync(all: Record): Record { const filtered: Record = {}; for (const [k, v] of Object.entries(all)) { if (shouldOmitKeyFromCloudPayload(k)) continue; filtered[k] = v; } - const data = migrateLegacyToPluginSettings(filtered); - const themeId = normalizeThemeIdForSync(all.selectedTheme); + return migrateLegacyToPluginSettings(filtered); +} + +/** Keys in `current` whose values differ from `baseline` (sparse PUT body). */ +export function diffSyncableStorage( + current: Record, + baseline: Record, +): Record { + const patch: Record = {}; + for (const [key, value] of Object.entries(current)) { + if (!isEqual(value, baseline[key])) { + patch[key] = value; + } + } + return patch; +} + +export type CloudSettingsUploadEnvelope = { + schemaVersion: number; + themeId: string; + data: Record; +}; + +/** Sparse upload envelope, or null when nothing changed vs baseline. */ +export function buildUploadPatch( + all: Record, + baseline: Record, +): CloudSettingsUploadEnvelope | null { + const normalized = normalizeStorageForSync(all); + const data = diffSyncableStorage(normalized, baseline); + if (Object.keys(data).length === 0) return null; return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, - themeId, + themeId: normalizeThemeIdForSync(all.selectedTheme), data, }; } -export async function getSnapshotForUpload(): Promise<{ - schemaVersion: number; - themeId: string; - data: Record; -}> { +/** Full normalized snapshot (dev export / debugging). */ +export function buildUploadPayload(all: Record): CloudSettingsUploadEnvelope { + const data = normalizeStorageForSync(all); + return { + schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, + themeId: normalizeThemeIdForSync(all.selectedTheme), + data, + }; +} + +export async function getLastUploadedSnapshot(): Promise | null> { + const stored = await browser.storage.local.get(BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY); + const snapshot = stored[BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY]; + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null; + return snapshot as Record; +} + +export async function saveLastUploadedSnapshot( + snapshot: Record, +): Promise { + await browser.storage.local.set({ [BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY]: snapshot }); +} + +export async function clearLastUploadedSnapshot(): Promise { + await browser.storage.local.remove(BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY); +} + +export async function getSnapshotForUpload(): Promise { const all = await browser.storage.local.get(); return buildUploadPayload(all as Record); } diff --git a/src/seqta/utils/ensureSyncableStorageDefaults.ts b/src/seqta/utils/ensureSyncableStorageDefaults.ts index 4d91bb8b..efa0d82f 100644 --- a/src/seqta/utils/ensureSyncableStorageDefaults.ts +++ b/src/seqta/utils/ensureSyncableStorageDefaults.ts @@ -84,7 +84,7 @@ function mergePluginSettingsDefaults( } /** - * Writes any missing cloud-syncable keys so uploads contain a full schema. + * Writes any missing cloud-syncable keys locally for consistent diffing. * Never overwrites existing values. Missing plugin settings respect legacy keys. */ export async function ensureSyncableStorageDefaults(): Promise { diff --git a/src/test/mocks/webextension-polyfill.ts b/src/test/mocks/webextension-polyfill.ts new file mode 100644 index 00000000..6171d913 --- /dev/null +++ b/src/test/mocks/webextension-polyfill.ts @@ -0,0 +1,35 @@ +const storage = new Map(); + +const local = { + get: jest.fn(async (keys?: string | string[] | null) => { + if (keys == null) { + return Object.fromEntries(storage); + } + if (typeof keys === "string") { + return keys in storage ? { [keys]: storage.get(keys) } : {}; + } + const out: Record = {}; + for (const key of keys) { + if (storage.has(key)) out[key] = storage.get(key); + } + return out; + }), + set: jest.fn(async (items: Record) => { + for (const [k, v] of Object.entries(items)) storage.set(k, v); + }), + remove: jest.fn(async (keys: string | string[]) => { + const list = Array.isArray(keys) ? keys : [keys]; + for (const key of list) storage.delete(key); + }), +}; + +export default { + storage: { local }, +}; + +export function __resetBrowserStorageMock() { + storage.clear(); + local.get.mockClear(); + local.set.mockClear(); + local.remove.mockClear(); +}