mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-15 08:04:12 +00:00
add aden's requested changes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -9,6 +9,10 @@ export default {
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest',
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^webextension-polyfill$': '<rootDir>/src/test/mocks/webextension-polyfill.ts',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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");
|
||||
@@ -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<string, unknown>,
|
||||
watermark: string | undefined,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const lastSnapshot = await getLastUploadedSnapshot();
|
||||
if (lastSnapshot) return lastSnapshot;
|
||||
|
||||
if (watermark) {
|
||||
await saveLastUploadedSnapshot(normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return getSyncableStorageDefaults();
|
||||
}
|
||||
|
||||
async function putSettingsOnce(token: string): Promise<PutResult> {
|
||||
try {
|
||||
const all = await browser.storage.local.get();
|
||||
const payload = buildUploadPayload(all as Record<string, unknown>);
|
||||
await ensureSyncableStorageDefaults();
|
||||
|
||||
const all = (await browser.storage.local.get()) as Record<string, unknown>;
|
||||
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<PutResult> {
|
||||
};
|
||||
}
|
||||
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<PutResult> {
|
||||
|
||||
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<GetResult> {
|
||||
reloadSeqtaPagesFn?.();
|
||||
const updated_at = data?.updated_at as string | undefined;
|
||||
await setKnownRemoteUpdatedAt(updated_at);
|
||||
await saveLastUploadedSnapshot(
|
||||
normalizeStorageForSync((await browser.storage.local.get()) as Record<string, unknown>),
|
||||
);
|
||||
return { ok: true, updated_at };
|
||||
} catch (e) {
|
||||
return {
|
||||
|
||||
@@ -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<void> {
|
||||
const userId = this._state.user?.id;
|
||||
if (userId) await clearCloudPfpCache(userId);
|
||||
await clearLastUploadedSnapshot();
|
||||
await browser.storage.local.remove([
|
||||
STORAGE_KEYS.accessToken,
|
||||
STORAGE_KEYS.refreshToken,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>): {
|
||||
schemaVersion: number;
|
||||
themeId: string;
|
||||
data: Record<string, unknown>;
|
||||
} {
|
||||
/** Filter omit lists and migrate legacy keys → full syncable map for diff/export. */
|
||||
export function normalizeStorageForSync(all: Record<string, unknown>): Record<string, unknown> {
|
||||
const filtered: Record<string, unknown> = {};
|
||||
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<string, unknown>,
|
||||
baseline: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
/** Sparse upload envelope, or null when nothing changed vs baseline. */
|
||||
export function buildUploadPatch(
|
||||
all: Record<string, unknown>,
|
||||
baseline: Record<string, unknown>,
|
||||
): 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<string, unknown>;
|
||||
}> {
|
||||
/** Full normalized snapshot (dev export / debugging). */
|
||||
export function buildUploadPayload(all: Record<string, unknown>): CloudSettingsUploadEnvelope {
|
||||
const data = normalizeStorageForSync(all);
|
||||
return {
|
||||
schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
|
||||
themeId: normalizeThemeIdForSync(all.selectedTheme),
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLastUploadedSnapshot(): Promise<Record<string, unknown> | 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<string, unknown>;
|
||||
}
|
||||
|
||||
export async function saveLastUploadedSnapshot(
|
||||
snapshot: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await browser.storage.local.set({ [BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY]: snapshot });
|
||||
}
|
||||
|
||||
export async function clearLastUploadedSnapshot(): Promise<void> {
|
||||
await browser.storage.local.remove(BSPLUS_CLOUD_LAST_UPLOADED_SNAPSHOT_KEY);
|
||||
}
|
||||
|
||||
export async function getSnapshotForUpload(): Promise<CloudSettingsUploadEnvelope> {
|
||||
const all = await browser.storage.local.get();
|
||||
return buildUploadPayload(all as Record<string, unknown>);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
const storage = new Map<string, unknown>();
|
||||
|
||||
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<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
if (storage.has(key)) out[key] = storage.get(key);
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
set: jest.fn(async (items: Record<string, unknown>) => {
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user