From 3a384e440406d850b831154d7bdd8dded1fcac67 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 20:27:22 -0300 Subject: [PATCH 1/4] test(superdoc): pin runtime event payload shapes (SD-673) Add key-set assertions for the six public events whose Config callbacks have named payload types (introduced in #3503). Existing tests used objectContaining({...}) which would not catch a missing or extra field; these new assertions pin Object.keys(payload).sort() to the exact declared shape. This protects the bug class found and fixed in #3503: typed callback payloads silently drifted from the runtime emit (onLocked, onAwarenessUpdate, onCommentsUpdate had wrong shapes for months). Events covered: - ready -> { superdoc } (SuperDoc.test.js) - editorBeforeCreate -> { editor } (SuperDoc.test.js) - editorCreate -> { editor } (SuperDoc.test.js) - locked -> { isLocked, lockedBy } (SuperDoc.test.js) - awareness-update -> { states, added, removed, superdoc} (collaboration.test.js) - comments-update -> { type, comment } (comments-store.test.js) Out of scope for this PR (pass-through events from upstream emitters, runtime shape is already enforced by SuperDocEventMap in the consumer- typecheck fixtures): - collaboration-ready (emitted from SuperDoc.vue verbatim) - list-definitions-change (emitted from super-editor, re-emitted by SuperDoc.vue verbatim) No new gate, no new scanner, no production code touched. Verified: - vitest run on the three touched files: 250/250 pass - pnpm check:public:superdoc --skip-build -> PASS (11/12, 133.7s) --- packages/superdoc/src/core/SuperDoc.test.js | 103 ++++++++++++++++++ .../core/collaboration/collaboration.test.js | 26 +++++ .../src/stores/comments-store.test.js | 9 ++ 3 files changed, 138 insertions(+) diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index d987574bb0..45ad4fd677 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -2887,4 +2887,107 @@ describe('SuperDoc core', () => { expect(instance.config.documentMode).toBe(before); }); }); + + // --------------------------------------------------------------------------- + // SD-673: runtime event payload shapes + // --------------------------------------------------------------------------- + // + // Pin the exact key set the runtime emits for each public event whose + // Config callback has a named payload type (SuperDoc{Ready,Editor,Locked} + // Payload). Existing tests use objectContaining({...}) which would not + // catch a missing or extra field; these assertions catch the bug class + // from #3503 where typed payloads silently drifted from what the runtime + // emits. + // + // Each test: + // 1. Registers superdoc.on(event, listener). + // 2. Triggers the runtime emit path (the broadcast/lock method). + // 3. Asserts Object.keys(payload).sort() matches the named type's + // key set, and each value has the expected runtime type. + + describe('SD-673: runtime event payload shapes', () => { + const baseConfig = () => ({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + user: { name: 'Jane', email: 'jane@example.com' }, + onException: vi.fn(), + }); + + it("ready: payload key set is ['superdoc'] and value is the SuperDoc instance", async () => { + const { superdocStore } = createAppHarness(); + superdocStore.documents = [{ type: DOCX, getEditor: vi.fn(() => ({})), setEditor: vi.fn() }]; + + const instance = new SuperDoc(baseConfig()); + await flushMicrotasks(); + + const received = []; + instance.on('ready', (payload) => received.push(payload)); + + instance.broadcastEditorCreate({}); + + expect(received).toHaveLength(1); + expect(Object.keys(received[0]).sort()).toEqual(['superdoc']); + expect(received[0].superdoc).toBe(instance); + }); + + it("editorBeforeCreate: payload key set is ['editor']", async () => { + createAppHarness(); + const instance = new SuperDoc(baseConfig()); + await flushMicrotasks(); + + const received = []; + instance.on('editorBeforeCreate', (payload) => received.push(payload)); + + const editor = { id: 'editor-1' }; + instance.broadcastEditorBeforeCreate(editor); + + expect(received).toHaveLength(1); + expect(Object.keys(received[0]).sort()).toEqual(['editor']); + // editor is wrapped in createDeprecatedEditorProxy; the proxy is + // transparent for property access, so identity-by-property holds. + expect(received[0].editor.id).toBe('editor-1'); + }); + + it("editorCreate: payload key set is ['editor']", async () => { + createAppHarness(); + const instance = new SuperDoc(baseConfig()); + await flushMicrotasks(); + + const received = []; + instance.on('editorCreate', (payload) => received.push(payload)); + + const editor = { id: 'editor-2' }; + instance.broadcastEditorCreate(editor); + + expect(received).toHaveLength(1); + expect(Object.keys(received[0]).sort()).toEqual(['editor']); + expect(received[0].editor.id).toBe('editor-2'); + }); + + it("locked: payload key set is ['isLocked', 'lockedBy'] and lockedBy is User | null", async () => { + createAppHarness(); + const instance = new SuperDoc(baseConfig()); + await flushMicrotasks(); + instance.config.documents = []; + + const received = []; + instance.on('locked', (payload) => received.push(payload)); + + // Lock with a user. + instance.lockSuperdoc(true, { name: 'Admin', email: 'admin@x.com' }); + // Unlock (lockedBy is the implicit `null` default). + instance.lockSuperdoc(false); + + expect(received).toHaveLength(2); + + expect(Object.keys(received[0]).sort()).toEqual(['isLocked', 'lockedBy']); + expect(received[0]).toEqual({ isLocked: true, lockedBy: { name: 'Admin', email: 'admin@x.com' } }); + + expect(Object.keys(received[1]).sort()).toEqual(['isLocked', 'lockedBy']); + expect(received[1]).toEqual({ isLocked: false, lockedBy: null }); + }); + }); }); diff --git a/packages/superdoc/src/core/collaboration/collaboration.test.js b/packages/superdoc/src/core/collaboration/collaboration.test.js index 5c8cc51aa0..24eb7d95e0 100644 --- a/packages/superdoc/src/core/collaboration/collaboration.test.js +++ b/packages/superdoc/src/core/collaboration/collaboration.test.js @@ -192,6 +192,32 @@ describe('collaboration.createProvider', () => { ); }); + // SD-673: pin the exact key set of the awareness-update payload. + // SuperDocAwarenessUpdatePayload declares { states, added, removed, + // superdoc }; the objectContaining assertion above would not catch a + // dropped field or a regression to the older { context } shape. + it('awareness-update payload has exactly { states, added, removed, superdoc } (SD-673)', () => { + const context = { emit: vi.fn() }; + const result = collaborationModule.createProvider({ + config: { url: 'ws://test' }, + user: { name: 'Sam', email: 'sam@example.com' }, + documentId: 'doc-1', + superdocInstance: context, + }); + + awarenessStatesToArrayMock.mockReturnValueOnce([{ name: 'A' }, { name: 'B' }]); + result.provider.emitAwareness({ added: [3], removed: [7] }, new Map()); + + expect(context.emit).toHaveBeenCalledTimes(1); + const [eventName, payload] = context.emit.mock.calls[0]; + expect(eventName).toBe('awareness-update'); + expect(Object.keys(payload).sort()).toEqual(['added', 'removed', 'states', 'superdoc']); + expect(payload.states).toEqual([{ name: 'A' }, { name: 'B' }]); + expect(payload.added).toEqual([3]); + expect(payload.removed).toEqual([7]); + expect(payload.superdoc).toBe(context); + }); + it('creates hocuspocus provider and wires lifecycle callbacks', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const context = { emit: vi.fn() }; diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index ddae0e63bb..838c2c5123 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -512,6 +512,15 @@ describe('comments-store', () => { comment: { commentId: 'change-1' }, }), ); + + // SD-673: pin the exact key set of the comments-update payload. + // SuperDocCommentsUpdatePayload declares { type, comment?, changes? }; + // the objectContaining assertion above would not catch a regression + // to the older { data: ... } shape or an extra field leaking in. + const [, payload] = superdoc.emit.mock.calls[0]; + expect(Object.keys(payload).sort()).toEqual(['comment', 'type']); + expect(typeof payload.type).toBe('string'); + expect(payload.comment).toEqual({ commentId: 'change-1' }); }); it('reopens resolved tracked change comments on update events', () => { From 569a7fb1b83af0c066501ffe995696a8f626c7d7 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 26 May 2026 20:34:34 -0300 Subject: [PATCH 2/4] test(superdoc): pin list-definitions-change bridge and DELETED comments shape (SD-673) Two follow-up coverage additions to the runtime payload tests: 1. SuperDoc.vue's list-definitions-change bridge. The vue layer's onEditorListdefinitionsChange is a verbatim pass-through, but nothing in the SuperDoc-side suite pinned it. Adds an assertion to the existing 'wires editor lifecycle events' test that calls options. onListDefinitionsChange(payload) and verifies superdoc.emit was called with ('list-definitions-change', payload). Catches a regression where the bridge starts re-shaping or dropping fields. Upstream emission of ListDefinitionsPayload is owned by super-editor and tested there. 2. The DELETED variant of SuperDocCommentsUpdatePayload. The first PR only pinned the UPDATE variant ({ comment, type }). The DELETED path (pruneStaleTrackedChangeComments -> emit) produces the three-key shape { changes, comment, type }. Extends the existing 'emits deleted events when replay sync prunes' test to assert the exact key set, so a regression that dropped 'changes' would fail. No production code touched. Verified: - vitest run on all four touched test files: 317/317 pass --- packages/superdoc/src/SuperDoc.test.js | 9 +++++++++ .../superdoc/src/stores/comments-store.test.js | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index f269e11ccc..1773fe6a5e 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -547,6 +547,15 @@ describe('SuperDoc.vue', () => { await nextTick(); expect(superdocStoreStub.isReady.value).toBe(true); + // SD-673: pin the list-definitions-change bridge. SuperDoc.vue's + // onEditorListdefinitionsChange is a verbatim pass-through to + // superdoc.emit, so the runtime shape is whatever the upstream editor + // emits (ListDefinitionsPayload). Catch a regression where the bridge + // starts re-shaping or dropping fields. + const listDefsPayload = { change: { kind: 'add' }, numbering: { nums: [] }, editor: editorMock }; + options.onListDefinitionsChange(listDefsPayload); + expect(superdocStub.emit).toHaveBeenCalledWith('list-definitions-change', listDefsPayload); + options.onDocumentLocked({ editor: editorMock, isLocked: true, lockedBy: { name: 'A' } }); expect(superdocStub.lockSuperdoc).toHaveBeenCalledWith(true, { name: 'A' }); diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.js index 838c2c5123..9bab624d46 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.js @@ -1391,6 +1391,20 @@ describe('comments-store', () => { comment: expect.objectContaining({ commentId: 'tc-stale' }), }), ); + + // SD-673: pin the DELETED variant of SuperDocCommentsUpdatePayload. + // This emit path produces the three-key shape { type, comment, changes } + // (changes carries the [{ key: 'deleted', commentId, fileId }] tuple). + // The objectContaining assertion above does not require `changes` to be + // present, so a regression that dropped it would not be caught. + const deletedCall = superdoc.emit.mock.calls.find( + ([name, payload]) => name === 'comments-update' && payload?.type === comments_module_events.DELETED, + ); + expect(deletedCall).toBeDefined(); + const [, deletedPayload] = deletedCall; + expect(Object.keys(deletedPayload).sort()).toEqual(['changes', 'comment', 'type']); + expect(Array.isArray(deletedPayload.changes)).toBe(true); + expect(deletedPayload.changes[0]).toMatchObject({ key: 'deleted' }); }); it('keeps tracked-change comments whose IDs are still present in marks', () => { From f71ea6497a32dea28f6793b8824f6807def142e5 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 27 May 2026 06:27:26 -0300 Subject: [PATCH 3/4] test(superdoc): tighten list-definitions-change bridge assertion (SD-673) Inspect the emitted payload directly instead of using toHaveBeenCalledWith(name, listDefsPayload). That call's deep-equal compares the actual call argument against the expected reference; if the bridge mutated the payload in place before emit (e.g. delete params.editor), both sides would point at the same now-mutated object and the assertion would pass trivially. Now pins two distinct properties: - emittedListDefsPayload toBe listDefsPayload (verbatim pass-through) - Object.keys(emittedListDefsPayload).sort() (no in-place key drop/add) A regression that clones the payload (breaks reference equality) or mutates a field out of it (breaks key set) now fails. Verified: - vitest run on SuperDoc.test.js: the lifecycle test still passes --- packages/superdoc/src/SuperDoc.test.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 1773fe6a5e..40ca44fbf8 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -551,10 +551,19 @@ describe('SuperDoc.vue', () => { // onEditorListdefinitionsChange is a verbatim pass-through to // superdoc.emit, so the runtime shape is whatever the upstream editor // emits (ListDefinitionsPayload). Catch a regression where the bridge - // starts re-shaping or dropping fields. + // starts re-shaping, dropping, or mutating fields before emit. + // + // Reference equality (.toBe) pins the verbatim pass-through; the + // separate Object.keys snapshot pins the key set in case the bridge + // ever mutates the payload in place before forwarding (a deep-equal + // assertion against the same reference would pass trivially). const listDefsPayload = { change: { kind: 'add' }, numbering: { nums: [] }, editor: editorMock }; options.onListDefinitionsChange(listDefsPayload); - expect(superdocStub.emit).toHaveBeenCalledWith('list-definitions-change', listDefsPayload); + const listDefsCall = superdocStub.emit.mock.calls.find(([name]) => name === 'list-definitions-change'); + expect(listDefsCall).toBeDefined(); + const [, emittedListDefsPayload] = listDefsCall; + expect(emittedListDefsPayload).toBe(listDefsPayload); + expect(Object.keys(emittedListDefsPayload).sort()).toEqual(['change', 'editor', 'numbering']); options.onDocumentLocked({ editor: editorMock, isLocked: true, lockedBy: { name: 'A' } }); expect(superdocStub.lockSuperdoc).toHaveBeenCalledWith(true, { name: 'A' }); From 33b74a74764087d04e4e22b6db094ae9a7b35f43 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 27 May 2026 06:46:10 -0300 Subject: [PATCH 4/4] test(superdoc): use real production payload for list-definitions-change bridge (SD-673) Per Codex review on PR #3526: the previous synthetic { change, numbering, editor } payload didn't match any production emit site. The only producers, in super-editor's numbering-part- descriptor, emit { editor, numbering } (no 'change' field). ListDefinitionsPayload marks all three fields optional, so the synthetic test passed trivially without pinning the real shape. Now pins the current production numbering variant: - input payload is { editor, numbering } (matches numbering-part- descriptor.ts:222,242 verbatim) - Object.keys(emittedListDefsPayload).sort() === ['editor', 'numbering'] - reference equality still asserts verbatim pass-through The comment block is explicit that this pins the production numbering variant + bridge pass-through, not every possible ListDefinitionsPayload shape. Verified: - vitest run on SuperDoc.test.js lifecycle test still passes --- packages/superdoc/src/SuperDoc.test.js | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 40ca44fbf8..3a9ab1410c 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -547,23 +547,27 @@ describe('SuperDoc.vue', () => { await nextTick(); expect(superdocStoreStub.isReady.value).toBe(true); - // SD-673: pin the list-definitions-change bridge. SuperDoc.vue's - // onEditorListdefinitionsChange is a verbatim pass-through to - // superdoc.emit, so the runtime shape is whatever the upstream editor - // emits (ListDefinitionsPayload). Catch a regression where the bridge - // starts re-shaping, dropping, or mutating fields before emit. + // SD-673: pin the list-definitions-change bridge using the actual + // production payload shape. Both producers in super-editor's + // numbering-part-descriptor emit `{ editor, numbering }` (see + // packages/super-editor/src/editors/v1/core/parts/adapters/ + // numbering-part-descriptor.ts:222,242). ListDefinitionsPayload + // itself marks all three fields optional, so this test pins the + // current production numbering variant + the SuperDoc.vue + // pass-through, not every possible ListDefinitionsPayload shape. // - // Reference equality (.toBe) pins the verbatim pass-through; the - // separate Object.keys snapshot pins the key set in case the bridge - // ever mutates the payload in place before forwarding (a deep-equal - // assertion against the same reference would pass trivially). - const listDefsPayload = { change: { kind: 'add' }, numbering: { nums: [] }, editor: editorMock }; + // Reference equality (.toBe) pins verbatim pass-through; the + // separate Object.keys snapshot pins the key set in case the + // bridge ever mutates the payload in place before forwarding (a + // deep-equal assertion against the same reference would pass + // trivially). + const listDefsPayload = { editor: editorMock, numbering: { nums: [] } }; options.onListDefinitionsChange(listDefsPayload); const listDefsCall = superdocStub.emit.mock.calls.find(([name]) => name === 'list-definitions-change'); expect(listDefsCall).toBeDefined(); const [, emittedListDefsPayload] = listDefsCall; expect(emittedListDefsPayload).toBe(listDefsPayload); - expect(Object.keys(emittedListDefsPayload).sort()).toEqual(['change', 'editor', 'numbering']); + expect(Object.keys(emittedListDefsPayload).sort()).toEqual(['editor', 'numbering']); options.onDocumentLocked({ editor: editorMock, isLocked: true, lockedBy: { name: 'A' } }); expect(superdocStub.lockSuperdoc).toHaveBeenCalledWith(true, { name: 'A' });