super-productivity/packages/plugin-dev/doc-mode/src/persistence.spec.ts
Johannes Millan 2580ea6eb1
perf(plugins): skip no-op doc-mode saves + rename to doc-mode (#7825)
* perf(plugins): skip no-op doc-mode saves via lastSeenDocBytes equality

Closes #7815.

flushSave and flushSaveSync now compute the would-be-written raw bytes
first and short-circuit when they equal lastSeenDocBytes — a typed-then-
reverted cycle inside the 30s throttle window no longer produces a
postMessage round-trip, an IDB transaction, or an op-log entry.

The baseline is intentionally NOT updated on a skip: it already equals
these bytes, so the self-echo invariant for PERSISTED_DATA_CHANGED
(#7752) is preserved.

Drive-by: added the isDocCorrupt guard to flushSave for symmetry with
flushSaveSync / scheduleSave. The schedule gate normally prevents
flushSave from firing on a corrupt doc, but corruption can be set
between schedule and fire (e.g. a remote-update reload turning up an
unparseable blob), so the explicit guard is cheaper than reasoning
about that invariant.

Persistence rename: saveContextDoc → persistContextDocRaw. The caller
now owns serialisation (via serializeContextDoc) so the byte-compare
can run before the persist dispatch. Three persistence.spec.ts tests
replace the dropped saveContextDoc test: exact-raw write, encoder
determinism (the premise the byte-compare relies on), and
write-idempotence (which proves the skip loses no information).

* refactor(plugins): apply doc-mode no-op-save review findings (#7815)

Multi-review follow-ups for #7815:

- Tighten serializeContextDoc determinism test: previously asserted
  equality between a doc and its `{...doc}` spread, which preserves V8
  insertion order — the test would pass even against a future encoder
  that randomised iteration over Map-backed nodes (the real failure
  mode for the byte-compare). Now asserts repeated-call determinism on
  the same input directly, plus type+non-empty shape so a future return-
  type change (e.g. Uint8Array) breaks here, not in the editor.

- Document the sync/async stamp asymmetry in flushSaveSync: it stamps
  lastSeenDocBytes BEFORE the void dispatch, whereas flushSave stamps
  AFTER `await`. Both are correct (the sync path is fire-and-forget and
  the iframe can be torn down mid-call, so a post-dispatch stamp would
  silently drop on teardown) but the divergence wasn't called out in
  the inline comments.

* refactor(plugins): rename document-mode to doc-mode

Renames the bundled plugin's id (`document-mode` → `doc-mode`), display
name (`Document Mode (Alpha)` → `Doc Mode (Alpha)`), package name,
directory, and asset path. No migration: the plugin has never been
published, so there is no on-disk user data to preserve.

Touched everywhere the id was hardcoded:
- Plugin manifest, package.json, build/deploy/test scripts, log prefixes
- Host BUNDLED_PLUGIN_PATHS entry (src/app/plugins/plugin.service.ts)
- Host comments and test fixtures (plugin-hooks, plugin-persistence-key,
  plugin-persistence.model, conflict-resolution.service.spec)
- E2E spec filenames + PLUGIN_ID constant + label assertions
- build-all.js plugin orchestrator
- build/release-notes.md user-facing entry

Test pass: 88/88 plugin specs, 8/8 plugin-hooks, 132/132
conflict-resolution, 15/15 plugin-persistence-key.util.
Bundle redeployed to src/assets/bundled-plugins/doc-mode (gitignored).

The old `src/assets/bundled-plugins/document-mode/` dir is gitignored and
left on disk after this commit; the host loader no longer references it,
so it's inert. Remove with `rm -rf src/assets/bundled-plugins/document-mode`.

* refactor(plugins): apply doc-mode rename review findings

Multi-review follow-ups for the rename in 3443bcacd0:

- editor.ts:1920: missed runtime log string ('Document mode:' →
  'doc-mode:') — leaked into the dev console with inconsistent brand
  because the rename sweep matched on the kebab `document-mode` or the
  PascalCase `Document Mode`, not the bare-space `Document mode`.
- editor.ts + background.ts header JSDoc: `Document-Mode editor` /
  `Document-Mode background script` → `Doc-Mode …`. Cosmetic but the
  only remaining branded references in the source.
- scripts/build.js: esbuild iife `globalName: 'DocumentModePlugin'` →
  `'DocModePlugin'`. Internal global, no consumers, but worth keeping
  consistent with the new id.
- src/app/plugins/plugin.service.ts: add a header comment to
  BUNDLED_PLUGIN_PATHS noting that pluginIds become entityId prefixes in
  IDB / op-log / sync wire and must be treated as permanent for any
  plugin that has ever shipped. Records the rule we relied on being
  absent for this rename.

DOM-document references (`installDocumentDragHandlers`,
`Document-status banner`, `Document-level pointer handlers`) left
unchanged — those refer to the browser document object, not the brand.
Historical plan docs in `docs/plans/2026-05-2[123]-*.md` also left
unchanged per the rename commit's intent.
2026-05-27 17:30:38 +02:00

355 lines
15 KiB
TypeScript

/**
* Tests for the keyed-persistence helpers and the legacy → keyed migration.
* Run with `npm test` (see scripts/test.js) — esbuild transpiles + bundles
* each spec, and `node --test` executes it.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { PluginAPI } from '@super-productivity/plugin-api';
import {
docKey,
loadContextDoc,
loadEnabledCtxIds,
migrateToKeyedPersistence,
persistContextDocRaw,
saveEnabledCtxIds,
serializeContextDoc,
} from './persistence';
/**
* In-memory mock of the host's plugin storage. Mirrors the keyed semantics:
* each (pluginId, key?) tuple is a distinct entry, an empty payload
* tombstones an entry (treated as missing on read).
*/
const createMockApi = (): {
api: PluginAPI;
store: Map<string, string>;
writes: { key: string | undefined; data: string }[];
} => {
// Single entityId space (pluginId-scoped — the test plugin id is implied).
// Key "" means the legacy keyless entry.
const store = new Map<string, string>();
const writes: { key: string | undefined; data: string }[] = [];
const api = {
persistDataSynced: async (data: string, key?: string): Promise<void> => {
writes.push({ key, data });
const k = key ?? '';
// Plugin-side tombstone convention: an empty payload means "ignore".
// We model this on the host by storing the empty string; the read
// side returns null for empty entries so callers see "no data".
store.set(k, data);
},
loadSyncedData: async (key?: string): Promise<string | null> => {
const k = key ?? '';
const v = store.get(k);
if (v === undefined) return null;
if (v === '') return ''; // explicit tombstone — still readable as empty
return v;
},
} as unknown as PluginAPI;
return { api, store, writes };
};
/* -------------------------------------------------------------------------- */
/* Read/write helpers */
/* -------------------------------------------------------------------------- */
test('docKey: composes "doc:<ctxId>"', () => {
assert.equal(docKey('project-1'), 'doc:project-1');
assert.equal(docKey('TODAY'), 'doc:TODAY');
});
test('loadEnabledCtxIds: returns [] when meta is missing', async () => {
const { api } = createMockApi();
assert.deepEqual(await loadEnabledCtxIds(api), []);
});
test('loadEnabledCtxIds: returns the saved ids', async () => {
const { api } = createMockApi();
await saveEnabledCtxIds(api, ['p1', 'p2']);
assert.deepEqual(await loadEnabledCtxIds(api), ['p1', 'p2']);
});
test('loadEnabledCtxIds: tolerates corrupt meta (returns [])', async () => {
const { api, store } = createMockApi();
store.set('meta', '{not json');
assert.deepEqual(await loadEnabledCtxIds(api), []);
});
test('loadContextDoc: returns {raw:null, parsed:null} when the entry is missing', async () => {
const { api } = createMockApi();
assert.deepEqual(await loadContextDoc(api, 'p1'), { raw: null, parsed: null });
});
test('loadContextDoc: returns both the raw string and parsed value', async () => {
const { api } = createMockApi();
const doc = { type: 'doc', content: [{ type: 'paragraph' }] };
await persistContextDocRaw(api, 'p1', serializeContextDoc(doc));
const loaded = await loadContextDoc(api, 'p1');
// raw is what the editor needs for the lastSeenDocBytes byte-compare (#7752)
assert.equal(loaded.raw, JSON.stringify(doc));
assert.deepEqual(loaded.parsed, doc);
});
test('loadContextDoc: corrupt entry returns raw bytes with parsed:null', async () => {
// The editor's hook handler still needs the raw bytes for the self-echo
// baseline even when JSON.parse fails — otherwise a remote write that
// happens to also be corrupt would self-echo as "different" forever.
const { api, store } = createMockApi();
store.set('doc:p1', '{not valid json');
const loaded = await loadContextDoc(api, 'p1');
assert.equal(loaded.raw, '{not valid json');
assert.equal(loaded.parsed, null);
});
test('loadContextDoc: primitive JSON values pass through parsed as the primitive', async () => {
// `parsed` is typed `unknown` so the editor's truthy guard
// (`stored ? prepareStoredDoc(stored, ...) : buildSeedDoc(...)`) is the
// only thing keeping a non-object from reaching the doc transformer.
// Lock the persistence-side behaviour so a future refactor (e.g.
// "validate it's an object here") surfaces here, not in the editor.
const { api, store } = createMockApi();
store.set('doc:p1', '123');
assert.deepEqual(await loadContextDoc(api, 'p1'), { raw: '123', parsed: 123 });
store.set('doc:p1', 'null');
assert.deepEqual(await loadContextDoc(api, 'p1'), { raw: 'null', parsed: null });
store.set('doc:p1', '"hi"');
assert.deepEqual(await loadContextDoc(api, 'p1'), { raw: '"hi"', parsed: 'hi' });
});
test('persistContextDocRaw: writes the exact raw string under doc:<ctxId>', async () => {
const { api, store, writes } = createMockApi();
// Pre-computed raw (mirrors the flushSave path: serialize once, byte-compare,
// then persist). The helper must not re-stringify — that would defeat the
// self-echo baseline (#7752) and the no-op skip (#7815).
const raw = serializeContextDoc({ type: 'doc' });
await persistContextDocRaw(api, 'TODAY', raw);
assert.equal(store.get('doc:TODAY'), raw);
assert.deepEqual(writes, [{ key: 'doc:TODAY', data: raw }]);
});
test('serializeContextDoc: deterministic across repeated calls on the same input', () => {
// The no-op save short-circuit (#7815) in flushSave / flushSaveSync compares
// the freshly-serialised bytes against the `lastSeenDocBytes` baseline. That
// comparison is only meaningful if the encoder is deterministic — same input
// must always produce byte-identical output, otherwise a "real revert" cycle
// would compute different bytes each call and the skip would never fire (or,
// worse, would fire when bytes actually differ if the comparator hashed).
// Lock this in so a future swap to a non-deterministic encoder (e.g. one
// that randomises iteration order over Map-backed nodes) breaks here, not
// silently in the editor.
const doc = {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
};
const first = serializeContextDoc(doc);
const second = serializeContextDoc(doc);
assert.equal(first, second);
// Also asserts the obvious shape so a future signature change (e.g.
// returning a Uint8Array) fails compile + runtime, not just structurally.
assert.equal(typeof first, 'string');
assert.ok(first.length > 0);
});
test('persistContextDocRaw: re-persisting identical bytes is a no-op at the store level', async () => {
// The optimization premise: when the editor's no-op short-circuit fires, the
// persist call never reaches here. But IF a caller did call this twice with
// the same raw, the result is byte-identical state — confirming the skip is
// safe (no information is lost by not making the second write).
const { api, store } = createMockApi();
const raw = serializeContextDoc({ type: 'doc', content: [] });
await persistContextDocRaw(api, 'p1', raw);
const after1 = store.get('doc:p1');
await persistContextDocRaw(api, 'p1', raw);
assert.equal(store.get('doc:p1'), after1);
});
/* -------------------------------------------------------------------------- */
/* Migration */
/* -------------------------------------------------------------------------- */
test('migration: no-op when already stamped', async () => {
const { api, store, writes } = createMockApi();
store.set('__meta__', JSON.stringify({ migrated: 1 }));
// Even if a legacy blob exists, we don't touch it once the stamp says we're
// done — a re-migration could overwrite later edits to the keyed entries.
store.set('', JSON.stringify({ docs: { p1: { type: 'doc' } } }));
await migrateToKeyedPersistence(api);
assert.deepEqual(writes, []);
assert.equal(store.has('doc:p1'), false);
});
test('migration: stamps success on a fresh install (no legacy data)', async () => {
const { api, store } = createMockApi();
await migrateToKeyedPersistence(api);
assert.equal(store.get('__meta__'), JSON.stringify({ migrated: 1 }));
assert.equal(store.has('meta'), false);
assert.equal(store.has(''), false);
});
test('migration: splits legacy blob into keyed entries + meta', async () => {
const { api, store } = createMockApi();
const legacy = {
version: 1,
docs: {
p1: { type: 'doc', content: [{ type: 'paragraph' }] },
TODAY: { type: 'doc', content: [{ type: 'heading' }] },
},
enabledCtxIds: ['p1'],
};
store.set('', JSON.stringify(legacy));
await migrateToKeyedPersistence(api);
assert.equal(store.get('doc:p1'), JSON.stringify(legacy.docs.p1));
assert.equal(store.get('doc:TODAY'), JSON.stringify(legacy.docs.TODAY));
assert.equal(store.get('meta'), JSON.stringify({ enabledCtxIds: ['p1'] }));
// Legacy entry tombstoned (empty payload).
assert.equal(store.get(''), '');
// Final stamp.
assert.equal(store.get('__meta__'), JSON.stringify({ migrated: 1 }));
});
test('migration: handles a legacy blob with no enabledCtxIds', async () => {
const { api, store } = createMockApi();
store.set('', JSON.stringify({ docs: { p1: { type: 'doc' } } }));
await migrateToKeyedPersistence(api);
assert.equal(store.get('meta'), JSON.stringify({ enabledCtxIds: [] }));
assert.equal(store.get('doc:p1'), JSON.stringify({ type: 'doc' }));
});
test('migration: refuses to tombstone a corrupt legacy blob (preserves data for recovery)', async () => {
const { api, store, writes } = createMockApi();
store.set('', '{not json');
await migrateToKeyedPersistence(api);
// Corrupt legacy stays in place; no keyed entries; no success stamp.
assert.equal(store.get(''), '{not json');
assert.equal(store.has('__meta__'), false);
assert.equal(store.has('meta'), false);
// We attempted (intent stamp) but bailed before tombstoning.
const sawAttemptedStamp = writes.some(
(w) =>
w.key === '__meta__' &&
typeof w.data === 'string' &&
w.data.includes('"migrated":0'),
);
// The attempt stamp wasn't written either — we bailed BEFORE stamping
// attempt, because parsing failed.
assert.equal(sawAttemptedStamp, false);
});
test('migration: skips an oversized legacy doc and leaves legacy intact for recovery', async () => {
// One context's stored doc is so big that the keyed write throws (mock
// simulates the host's MAX_PLUGIN_DATA_SIZE check). The migration must
// not abort the whole run — the other docs must still land — and must
// NOT tombstone or stamp success, so a future build with a larger cap
// (or after the user prunes the doc) can recover.
const { store, writes } = createMockApi();
const api = {
persistDataSynced: async (data: string, key?: string): Promise<void> => {
// Simulate host-side cap: throw for the oversized doc.
if (data.length > 10_000) {
throw new Error('Plugin data exceeds maximum size');
}
writes.push({ key, data });
store.set(key ?? '', data);
},
loadSyncedData: async (key?: string): Promise<string | null> => {
const v = store.get(key ?? '');
return v === undefined ? null : v;
},
} as unknown as PluginAPI;
const big = 'x'.repeat(20_000);
store.set(
'',
JSON.stringify({
docs: { small: { type: 'doc' }, big: { type: 'doc', text: big } },
enabledCtxIds: ['small'],
}),
);
await migrateToKeyedPersistence(api);
// Small doc migrated; big doc absent.
assert.equal(store.get('doc:small'), JSON.stringify({ type: 'doc' }));
assert.equal(store.has('doc:big'), false);
// Meta lands (it's small).
assert.equal(store.get('meta'), JSON.stringify({ enabledCtxIds: ['small'] }));
// Legacy NOT tombstoned — original bytes preserved.
assert.equal(
store.get(''),
JSON.stringify({
docs: { small: { type: 'doc' }, big: { type: 'doc', text: big } },
enabledCtxIds: ['small'],
}),
);
// Migration NOT stamped successful — next session retries.
const stampRaw = store.get('__meta__');
const stamp = stampRaw ? (JSON.parse(stampRaw) as { migrated: number }) : null;
assert.notEqual(stamp?.migrated, 1);
});
test('migration: idempotent re-run after crash mid-loop', async () => {
const { api, store } = createMockApi();
store.set('', JSON.stringify({ docs: { p1: { type: 'doc' } }, enabledCtxIds: ['p1'] }));
// Simulate a previous attempt that stamped intent but never reached
// success — e.g. the iframe was torn down mid-loop.
store.set('__meta__', JSON.stringify({ migrated: 0 }));
await migrateToKeyedPersistence(api);
assert.equal(store.get('doc:p1'), JSON.stringify({ type: 'doc' }));
assert.equal(store.get('meta'), JSON.stringify({ enabledCtxIds: ['p1'] }));
assert.equal(store.get(''), '');
assert.equal(store.get('__meta__'), JSON.stringify({ migrated: 1 }));
});
test('migration: re-running after success is a no-op even with replayed legacy data', async () => {
// Simulate: A migrates, B (offline, old build) replays a legacy edit on top
// of A's tombstone, A's local stamp still says migrated:1. A must NOT
// re-migrate (it would overwrite its keyed entries with B's older shape).
const { api, store, writes } = createMockApi();
store.set('__meta__', JSON.stringify({ migrated: 1 }));
store.set('', JSON.stringify({ docs: { p1: { type: 'paragraph' } } }));
store.set('doc:p1', JSON.stringify({ type: 'doc', content: [{ type: 'heading' }] }));
await migrateToKeyedPersistence(api);
assert.deepEqual(writes, []);
assert.equal(
store.get('doc:p1'),
JSON.stringify({ type: 'doc', content: [{ type: 'heading' }] }),
);
});
test('migration: stamps intent FIRST, then splits, then tombstones, then stamps success', async () => {
// Order matters for crash recovery: the intent stamp must be the first
// write so that a crash between "stamp" and "split" still leaves the
// marker that says "we tried; retry on resume."
const { api, store, writes } = createMockApi();
store.set('', JSON.stringify({ docs: { p1: { type: 'doc' } }, enabledCtxIds: [] }));
await migrateToKeyedPersistence(api);
const order = writes.map((w) => w.key ?? '<legacy>');
// First write: intent stamp.
assert.equal(order[0], '__meta__');
assert.match(writes[0].data, /"migrated":0/);
// Last write: success stamp.
assert.equal(order[order.length - 1], '__meta__');
assert.match(writes[writes.length - 1].data, /"migrated":1/);
// Tombstone happens before the success stamp.
const tombstoneIdx = writes.findIndex((w) => w.key === undefined && w.data === '');
const successIdx = writes.findIndex(
(w, i) => w.key === '__meta__' && i > 0 && w.data.includes('"migrated":1'),
);
assert.ok(tombstoneIdx > 0, 'expected a tombstone write');
assert.ok(successIdx > tombstoneIdx, 'success stamp must come after tombstone');
});