From 183c1d8fbc06b04481251e0f0c405fc51779de84 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 19 Mar 2026 19:49:57 -0300 Subject: [PATCH 1/3] perf(test): move one-time setup to beforeAll in contract-conformance Registration of executors and part descriptors only needs to happen once per suite, not before every test. Mock resets remain in beforeEach for proper test isolation. --- .../contract-conformance.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts index a5043f0138..fe992b6552 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -1,5 +1,5 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Editor } from '../../core/Editor.js'; import { COMMAND_CATALOG, @@ -10395,11 +10395,19 @@ const dryRunVectors: Partial unknown>> = { }, }; -beforeEach(() => { +beforeAll(() => { registerBuiltInExecutors(); registerPartDescriptor(numberingPartDescriptor); registerPartDescriptor(settingsPartDescriptor); registerPartDescriptor(stylesPartDescriptor); +}); + +afterAll(() => { + clearPartDescriptors(); + clearInvalidationHandlers(); +}); + +const resetMocks = () => { vi.restoreAllMocks(); mockedDeps.resolveCommentAnchorsById.mockReset(); mockedDeps.resolveCommentAnchorsById.mockImplementation(() => []); @@ -10425,11 +10433,10 @@ beforeEach(() => { refResolverMocks.getSourcesFromConverter.mockImplementation(() => []); refResolverMocks.findAllAuthorities.mockImplementation(() => []); refResolverMocks.findAllAuthorityEntries.mockImplementation(() => []); -}); +}; -afterEach(() => { - clearPartDescriptors(); - clearInvalidationHandlers(); +beforeEach(() => { + resetMocks(); }); describe('document-api adapter conformance', () => { From ed56baca2f12ed74c349ad5d19948713e55763e3 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 19 Mar 2026 19:53:05 -0300 Subject: [PATCH 2/3] perf(test): eliminate vi.resetModules in insertContent and cleanUpParagraph tests - cleanUpParagraphWithAnnotation: replace per-test vi.resetModules + vi.doMock + dynamic import with a single hoisted vi.mock and a controllable mock variable. Tests drop from 58s to 6ms. - insertContent: move helpers/DOCX loading to beforeAll, remove per-test vi.resetModules. Tests drop from 90s to 4.67s. --- .../src/core/commands/insertContent.test.js | 64 ++++++++--------- .../cleanUpParagraphWithAnnotation.test.js | 68 +++++++------------ 2 files changed, 53 insertions(+), 79 deletions(-) diff --git a/packages/super-editor/src/core/commands/insertContent.test.js b/packages/super-editor/src/core/commands/insertContent.test.js index 2557b3bc8a..e6d3e7b56d 100644 --- a/packages/super-editor/src/core/commands/insertContent.test.js +++ b/packages/super-editor/src/core/commands/insertContent.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'; import { insertContent } from './insertContent.js'; import * as contentProcessor from '../helpers/contentProcessor.js'; @@ -161,11 +161,14 @@ describe('insertContent', () => { // Integration-style tests that use a real Editor instance to // insert markdown/HTML lists and verify exported OOXML has list numbering. +// +// These tests need the REAL contentProcessor (not the mock from the unit tests +// above). We use a separate vi.mock-free import path by dynamically importing +// the real insertContent function. describe('insertContent (integration) list export', () => { - // Cache loaded DOCX data and helpers to avoid repeated file loading - let cachedDocxData = null; let helpers = null; let exportHelpers = null; + let cachedDocxData = null; const getListParagraphs = (result) => { const body = result.elements?.find((el) => el.name === 'w:body'); @@ -185,22 +188,16 @@ describe('insertContent (integration) list export', () => { return { numId, ilvl }; }; - const setupEditor = async () => { - // Use real content processor for these tests + // Load helpers and DOCX data once for all integration tests + beforeAll(async () => { vi.resetModules(); vi.doUnmock('../helpers/contentProcessor.js'); + helpers = await import('../../tests/helpers/helpers.js'); + cachedDocxData = await helpers.loadTestDataForEditorTests('blank-doc.docx'); + exportHelpers = await import('../../tests/export/export-helpers/index.js'); + }); - // Cache helpers and DOCX data on first call - if (!helpers) { - helpers = await import('../../tests/helpers/helpers.js'); - } - if (!cachedDocxData) { - cachedDocxData = await helpers.loadTestDataForEditorTests('blank-doc.docx'); - } - if (!exportHelpers) { - exportHelpers = await import('../../tests/export/export-helpers/index.js'); - } - + const setupEditor = () => { const { docx, media, mediaFiles, fonts } = cachedDocxData; const { editor } = helpers.initTestEditor({ content: docx, media, mediaFiles, fonts, mode: 'docx' }); return editor; @@ -212,7 +209,7 @@ describe('insertContent (integration) list export', () => { }; it('exports ordered list from markdown with numId/ilvl', async () => { - const editor = await setupEditor(); + const editor = setupEditor(); editor.commands.insertContent('1. One\n2. Two', { contentType: 'markdown' }); await Promise.resolve(); @@ -230,7 +227,7 @@ describe('insertContent (integration) list export', () => { }); it('exports unordered list from markdown with numId/ilvl', async () => { - const editor = await setupEditor(); + const editor = setupEditor(); editor.commands.insertContent('- Alpha\n- Beta', { contentType: 'markdown' }); await Promise.resolve(); @@ -248,7 +245,7 @@ describe('insertContent (integration) list export', () => { }); it('exports ordered list from HTML with numId/ilvl', async () => { - const editor = await setupEditor(); + const editor = setupEditor(); editor.commands.insertContent('
  1. First
  2. Second
', { contentType: 'html' }); await Promise.resolve(); @@ -262,7 +259,7 @@ describe('insertContent (integration) list export', () => { }); it('inserts markdown heading + bold text without creating a table', async () => { - const editor = await setupEditor(); + const editor = setupEditor(); editor.commands.insertContent('# Hello\n\nSome **bold** text', { contentType: 'markdown' }); await Promise.resolve(); @@ -280,7 +277,7 @@ describe('insertContent (integration) list export', () => { }); it('exports unordered list from HTML with numId/ilvl', async () => { - const editor = await setupEditor(); + const editor = setupEditor(); editor.commands.insertContent('
  • Apple
  • Banana
', { contentType: 'html' }); await Promise.resolve(); @@ -294,7 +291,7 @@ describe('insertContent (integration) list export', () => { }); it('defaults imported HTML tables to 100% width', async () => { - const editor = await setupEditor(); + const editor = setupEditor(); editor.commands.insertContent( '
QueryAssessment
AB
', { contentType: 'html' }, @@ -310,7 +307,7 @@ describe('insertContent (integration) list export', () => { }); it('defaults imported markdown tables to 100% width', async () => { - const editor = await setupEditor(); + const editor = setupEditor(); editor.commands.insertContent('| Query | Assessment |\n| --- | --- |\n| A | B |', { contentType: 'markdown' }); await Promise.resolve(); @@ -323,7 +320,7 @@ describe('insertContent (integration) list export', () => { }); it('does not inject inline cell borders on imported HTML table headers', async () => { - const editor = await setupEditor(); + const editor = setupEditor(); editor.commands.insertContent( '
Search QueryFindings / Assessment
AB
', { contentType: 'html' }, @@ -358,17 +355,14 @@ describe.skipIf(!process.env.CI)('insertContent (integration) horizontal rule', let helpers = null; let cachedDocxData = null; - const setupEditor = async () => { + beforeAll(async () => { vi.resetModules(); vi.doUnmock('../helpers/contentProcessor.js'); + helpers = await import('../../tests/helpers/helpers.js'); + cachedDocxData = await helpers.loadTestDataForEditorTests('blank-doc.docx'); + }); - if (!helpers) { - helpers = await import('../../tests/helpers/helpers.js'); - } - if (!cachedDocxData) { - cachedDocxData = await helpers.loadTestDataForEditorTests('blank-doc.docx'); - } - + const setupEditor = () => { const { docx, media, mediaFiles, fonts } = cachedDocxData; const { editor } = helpers.initTestEditor({ content: docx, media, mediaFiles, fonts, mode: 'docx' }); return editor; @@ -391,7 +385,7 @@ describe.skipIf(!process.env.CI)('insertContent (integration) horizontal rule', }; it('insertContent with contentType html creates a horizontal rule', async () => { - const editor = await setupEditor(); + const editor = setupEditor(); expect(countHorizontalRules(editor)).toBe(0); editor.commands.insertContent('
', { contentType: 'html' }); @@ -400,7 +394,7 @@ describe.skipIf(!process.env.CI)('insertContent (integration) horizontal rule', }); it('insertContent with contentType markdown creates a horizontal rule', async () => { - const editor = await setupEditor(); + const editor = setupEditor(); expect(countHorizontalRules(editor)).toBe(0); editor.commands.insertContent('---', { contentType: 'markdown' }); @@ -409,7 +403,7 @@ describe.skipIf(!process.env.CI)('insertContent (integration) horizontal rule', }); it('insertContent with bare
(no contentType) creates a horizontal rule', async () => { - const editor = await setupEditor(); + const editor = setupEditor(); expect(countHorizontalRules(editor)).toBe(0); editor.commands.insertContent('
'); diff --git a/packages/super-editor/src/extensions/field-annotation/cleanup-commands/cleanUpParagraphWithAnnotation.test.js b/packages/super-editor/src/extensions/field-annotation/cleanup-commands/cleanUpParagraphWithAnnotation.test.js index d215a004f2..632ab204a4 100644 --- a/packages/super-editor/src/extensions/field-annotation/cleanup-commands/cleanUpParagraphWithAnnotation.test.js +++ b/packages/super-editor/src/extensions/field-annotation/cleanup-commands/cleanUpParagraphWithAnnotation.test.js @@ -12,6 +12,16 @@ if (!Object.getOwnPropertyDescriptor(PMNode.prototype, 'children')) { }); } +// Controllable mock — set mockAnnotations before each test to control return value +let mockAnnotations = []; + +vi.mock('../fieldAnnotationHelpers/index.js', () => ({ + findFieldAnnotationsByFieldId: vi.fn(() => mockAnnotations), +})); + +// Import AFTER vi.mock so the command picks up the hoisted mock +const { cleanUpParagraphWithAnnotations } = await import('./index.js'); + // Local helpers const schema = basic; const p = schema.nodes.paragraph; @@ -24,27 +34,19 @@ const makeAnnotationAt = (state, pos) => { }; const hardBreak = schema.nodes.hard_break; const textContent = (docNode) => docNode.textContent; -const mockHelperPath = '../fieldAnnotationHelpers/index.js'; -describe('cleanUpParagraphWithAnnotations - test range error crash', () => { - beforeEach(() => { - vi.resetModules(); - }); +beforeEach(() => { + mockAnnotations = []; +}); +describe('cleanUpParagraphWithAnnotations - test range error crash', () => { /** Test to fix error in position out of range in original */ - it('throws RangeError "Position … out of range" on single-paragraph doc', async () => { + it('throws RangeError "Position … out of range" on single-paragraph doc', () => { const doc = schema.node('doc', null, [p.createAndFill(null, [text('A')])]); const state = makeState(doc); const tr = state.tr; - const ann = makeAnnotationAt(state, 1); - - vi.doMock('../fieldAnnotationHelpers/index.js', () => ({ - findFieldAnnotationsByFieldId: vi.fn(() => [ann]), - })); - - // Import AFTER mocking so the command picks up the mocked helper - const { cleanUpParagraphWithAnnotations } = await import('./index.js'); + mockAnnotations = [makeAnnotationAt(state, 1)]; const cmd = cleanUpParagraphWithAnnotations(['field-x']); const run = () => cmd({ dispatch: () => {}, tr, state }); @@ -54,11 +56,7 @@ describe('cleanUpParagraphWithAnnotations - test range error crash', () => { }); describe('cleanUpParagraphWithAnnotations – original behavior', () => { - beforeEach(() => { - vi.resetModules(); - }); - - it('deletes a single-child paragraph (non-last) annotated node', async () => { + it('deletes a single-child paragraph (non-last) annotated node', () => { // doc: [ p("REMOVE_ME"), p("keep this") ] const doc = schema.node('doc', null, [ p.createAndFill(null, [text('REMOVE_ME')]), @@ -68,13 +66,8 @@ describe('cleanUpParagraphWithAnnotations – original behavior', () => { const tr = state.tr; // Annotate inside the first paragraph - const ann = makeAnnotationAt(state, 1); + mockAnnotations = [makeAnnotationAt(state, 1)]; - vi.doMock(mockHelperPath, () => ({ - findFieldAnnotationsByFieldId: vi.fn(() => [ann]), - })); - - const { cleanUpParagraphWithAnnotations } = await import('./index.js'); const cmd = cleanUpParagraphWithAnnotations(['field-x']); // no-op dispatch is fine; we inspect tr afterwards @@ -89,20 +82,15 @@ describe('cleanUpParagraphWithAnnotations – original behavior', () => { expect(textContent(after)).not.toMatch(/REMOVE_ME/); }); - it('no-ops when parent has >= 2 inline children (e.g., text + hardBreak + text)', async () => { + it('no-ops when parent has >= 2 inline children (e.g., text + hardBreak + text)', () => { // p("A",
, "B") -> childCount >= 2 -> guard should fail -> no deletion const para = p.createAndFill(null, [text('A'), hardBreak.create(), text('B')]); const doc = schema.node('doc', null, [para]); const state = makeState(doc); const tr = state.tr; - const ann = makeAnnotationAt(state, 1); - - vi.doMock(mockHelperPath, () => ({ - findFieldAnnotationsByFieldId: vi.fn(() => [ann]), - })); + mockAnnotations = [makeAnnotationAt(state, 1)]; - const { cleanUpParagraphWithAnnotations } = await import('./index.js'); const cmd = cleanUpParagraphWithAnnotations(['field-x']); const run = () => cmd({ dispatch: () => {}, tr, state }); @@ -111,7 +99,7 @@ describe('cleanUpParagraphWithAnnotations – original behavior', () => { expect(textContent(tr.doc)).toContain('AB'); }); - it('no-ops when the annotation node does not equal the current node at mapped position', async () => { + it('no-ops when the annotation node does not equal the current node at mapped position', () => { // doc: [ p("X"), p("Y") ] const doc = schema.node('doc', null, [p.createAndFill(null, [text('X')]), p.createAndFill(null, [text('Y')])]); const state = makeState(doc); @@ -119,13 +107,8 @@ describe('cleanUpParagraphWithAnnotations – original behavior', () => { // Build an "annotation" object whose node DOES NOT equal the current node at pos 1 // We fake it by using a different node instance/type (paragraph node) so node.eq(currentNode) is false. - const badAnn = { pos: 1, node: state.doc.child(0) /* paragraph, not text node */ }; - - vi.doMock(mockHelperPath, () => ({ - findFieldAnnotationsByFieldId: vi.fn(() => [badAnn]), - })); + mockAnnotations = [{ pos: 1, node: state.doc.child(0) /* paragraph, not text node */ }]; - const { cleanUpParagraphWithAnnotations } = await import('./index.js'); const cmd = cleanUpParagraphWithAnnotations(['field-x']); const run = () => cmd({ dispatch: () => {}, tr, state }); @@ -134,7 +117,7 @@ describe('cleanUpParagraphWithAnnotations – original behavior', () => { expect(textContent(tr.doc)).toMatch(/^XY$/); }); - it('handles multiple annotations by queuing and deleting them in descending order', async () => { + it('handles multiple annotations by queuing and deleting them in descending order', () => { // doc: [ p("FIRST"), p("MID"), p("SECOND") ] // Annotate inside FIRST and SECOND (both single-child paragraphs). const first = p.createAndFill(null, [text('FIRST')]); @@ -154,11 +137,8 @@ describe('cleanUpParagraphWithAnnotations – original behavior', () => { const ann1 = makeAnnotationAt(state, 1); const ann2 = makeAnnotationAt(state, secondTextPos); - vi.doMock(mockHelperPath, () => ({ - findFieldAnnotationsByFieldId: vi.fn(() => [ann1, ann2]), - })); + mockAnnotations = [ann1, ann2]; - const { cleanUpParagraphWithAnnotations } = await import('./index.js'); const cmd = cleanUpParagraphWithAnnotations(['field-x']); const run = () => cmd({ dispatch: () => {}, tr, state }); From 091af2a053b56d73407dbada1de011eb932f510c Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 19 Mar 2026 19:56:15 -0300 Subject: [PATCH 3/3] perf(test): extract collaboration tests from startImageUpload to avoid vi.resetModules Move the 3 collaboration-branch tests into a dedicated file where hoisted vi.mock can replace per-test vi.resetModules + vi.doMock + dynamic import. Tests drop from 22s to 407ms. --- .../startImageUpload.collaboration.test.js | 256 +++++++++++++++++ .../imageHelpers/startImageUpload.test.js | 269 ------------------ 2 files changed, 256 insertions(+), 269 deletions(-) create mode 100644 packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.collaboration.test.js diff --git a/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.collaboration.test.js b/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.collaboration.test.js new file mode 100644 index 0000000000..68c082aa1a --- /dev/null +++ b/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.collaboration.test.js @@ -0,0 +1,256 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Controllable mock implementations — configure per test +let mockFindPlaceholder = vi.fn(() => 0); +let mockRemoveImagePlaceholder = vi.fn((_state, tr) => tr); +let mockFindOrCreateRelationship = vi.fn(() => 'rId100'); +let mockDefaultUpload = vi.fn(); +let mockGenerateDocxRandomId = vi.fn(); + +vi.mock('./imageRegistrationPlugin.js', () => ({ + findPlaceholder: (...args) => mockFindPlaceholder(...args), + removeImagePlaceholder: (...args) => mockRemoveImagePlaceholder(...args), + addImagePlaceholder: vi.fn(), +})); + +vi.mock('@core/parts/adapters/relationships-mutation.js', () => ({ + findOrCreateRelationship: (...args) => mockFindOrCreateRelationship(...args), +})); + +vi.mock('./handleImageUpload.js', () => ({ + handleImageUpload: (...args) => mockDefaultUpload(...args), +})); + +vi.mock('@core/helpers/index.js', () => ({ + generateDocxRandomId: (...args) => mockGenerateDocxRandomId(...args), +})); + +// Import after vi.mock (hoisted) +const { uploadAndInsertImage } = await import('./startImageUpload.js'); + +describe('uploadAndInsertImage collaboration branch (isolated)', () => { + beforeEach(() => { + mockFindPlaceholder = vi.fn(() => 0); + mockRemoveImagePlaceholder = vi.fn((_state, tr) => tr); + mockFindOrCreateRelationship = vi.fn(() => 'rId100'); + mockDefaultUpload = vi.fn(); + mockGenerateDocxRandomId = vi.fn(); + }); + + it('calls addImageToCollaboration when ydoc is provided', async () => { + const collabSpy = vi.fn(); + + const editor = { + options: { + handleImageUpload: vi.fn().mockResolvedValue('http://example.com/image.png'), + mode: 'docx', + ydoc: {}, + }, + commands: { + addImageToCollaboration: collabSpy, + }, + storage: { + image: { media: {} }, + }, + }; + + const tr = { + replaceWith: vi.fn(() => tr), + }; + + const view = { + state: { + tr, + schema: { + nodes: { + image: { + create: vi.fn(() => ({ attrs: {} })), + }, + }, + }, + }, + dispatch: vi.fn(), + }; + + const file = new File([new Uint8Array([1])], 'collab.png', { type: 'image/png' }); + + await uploadAndInsertImage({ + editor, + view, + file, + size: { width: 10, height: 10 }, + id: {}, + }); + + expect(collabSpy).toHaveBeenCalledWith({ + mediaPath: 'word/media/collab.png', + fileData: 'http://example.com/image.png', + }); + }); + + it('falls back when media is unset and file lacks lastModified', async () => { + mockFindOrCreateRelationship = vi.fn(() => 'rId200'); + + const OriginalFile = globalThis.File; + const fileCtorSpy = vi.fn(); + + class MockFile { + constructor(parts, name, options = {}) { + fileCtorSpy({ parts, name, options }); + this.name = name; + this.type = options.type; + } + } + + globalThis.File = MockFile; + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(123456); + + const editor = { + options: { + handleImageUpload: vi.fn().mockResolvedValue('data:image/png;base64,CCC'), + mode: 'docx', + }, + commands: { + addImageToCollaboration: vi.fn(), + }, + storage: { + image: {}, + }, + state: { + doc: { + descendants: () => {}, + }, + }, + }; + + const backingMedia = {}; + let firstAccess = true; + Object.defineProperty(editor.storage.image, 'media', { + configurable: true, + get() { + if (firstAccess) { + firstAccess = false; + return undefined; + } + return backingMedia; + }, + set(value) { + Object.assign(backingMedia, value); + }, + }); + + const tr = { + replaceWith: vi.fn(() => tr), + }; + + const view = { + state: { + tr, + schema: { + nodes: { + image: { + create: vi.fn(() => ({ attrs: {} })), + }, + }, + }, + }, + dispatch: vi.fn(), + }; + + const sourceFile = { name: 'Screenshot 2025.png', type: 'image/png', size: 10 }; + + try { + await uploadAndInsertImage({ + editor, + view, + file: sourceFile, + size: { width: 10, height: 10 }, + id: {}, + }); + } finally { + globalThis.File = OriginalFile; + nowSpy.mockRestore(); + delete editor.storage.image.media; + editor.storage.image.media = backingMedia; + } + + expect(fileCtorSpy).toHaveBeenCalledTimes(1); + const [[callArgs]] = fileCtorSpy.mock.calls; + expect(callArgs.name).toBe('Screenshot_2025.png'); + expect(callArgs.options.lastModified).toBe(123456); + + expect(editor.options.handleImageUpload).toHaveBeenCalledWith(expect.any(MockFile)); + expect(backingMedia).toHaveProperty('word/media/Screenshot_2025.png'); + expect(mockFindPlaceholder).toHaveBeenCalled(); + expect(mockRemoveImagePlaceholder).toHaveBeenCalled(); + }); + + it('uses default upload handler and skips duplicate docPr ids', async () => { + mockDefaultUpload.mockResolvedValue('data:image/png;base64,DDD'); + const relationshipSpy = vi.fn(() => 'rId500'); + mockFindOrCreateRelationship = relationshipSpy; + mockGenerateDocxRandomId.mockReturnValueOnce('0000007b').mockReturnValueOnce('0000007c'); + + const imageCreateSpy = vi.fn(() => ({ attrs: {} })); + + const editor = { + options: { + mode: 'docx', + }, + commands: { + addImageToCollaboration: vi.fn(), + }, + storage: { + image: { media: {} }, + }, + state: { + doc: { + descendants: (callback) => { + callback({ + type: { name: 'image' }, + attrs: { id: '123' }, + }); + }, + }, + }, + }; + + const tr = { + replaceWith: vi.fn(() => tr), + }; + + const view = { + state: { + tr, + schema: { + nodes: { + image: { + create: imageCreateSpy, + }, + }, + }, + }, + dispatch: vi.fn(), + }; + + const basicFile = new File([new Uint8Array([1])], 'image.png', { type: 'image/png' }); + + await uploadAndInsertImage({ + editor, + view, + file: basicFile, + size: { width: 20, height: 20 }, + id: {}, + }); + + expect(mockDefaultUpload).toHaveBeenCalledTimes(1); + expect(relationshipSpy).toHaveBeenCalledWith(editor, 'startImageUpload:addImageRelationship', { + target: 'media/image.png', + type: 'image', + }); + const createdNodeAttrs = imageCreateSpy.mock.calls[0][0]; + expect(createdNodeAttrs.id).toBe('124'); + + expect(mockGenerateDocxRandomId).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.test.js b/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.test.js index ce36fe4b63..fff31f6fb6 100644 --- a/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.test.js +++ b/packages/super-editor/src/extensions/image/imageHelpers/startImageUpload.test.js @@ -275,272 +275,3 @@ describe('image upload helpers integration', () => { uploadStub.mockRestore(); }); }); - -describe('uploadAndInsertImage collaboration branch (isolated)', () => { - afterEach(() => { - vi.resetModules(); - }); - - it('calls addImageToCollaboration when ydoc is provided', async () => { - vi.resetModules(); - - vi.doMock('./imageRegistrationPlugin.js', () => ({ - findPlaceholder: () => 0, - removeImagePlaceholder: (_state, tr) => tr, - addImagePlaceholder: vi.fn(), - })); - - vi.doMock('@core/parts/adapters/relationships-mutation.js', () => ({ - findOrCreateRelationship: vi.fn(() => 'rId100'), - })); - - const { uploadAndInsertImage } = await import('./startImageUpload.js'); - - const collabSpy = vi.fn(); - - const editor = { - options: { - handleImageUpload: vi.fn().mockResolvedValue('http://example.com/image.png'), - mode: 'docx', - ydoc: {}, - }, - commands: { - addImageToCollaboration: collabSpy, - }, - storage: { - image: { media: {} }, - }, - }; - - const tr = { - replaceWith: vi.fn(() => tr), - }; - - const view = { - state: { - tr, - schema: { - nodes: { - image: { - create: vi.fn(() => ({ attrs: {} })), - }, - }, - }, - }, - dispatch: vi.fn(), - }; - - const file = new File([new Uint8Array([1])], 'collab.png', { type: 'image/png' }); - - await uploadAndInsertImage({ - editor, - view, - file, - size: { width: 10, height: 10 }, - id: {}, - }); - - expect(collabSpy).toHaveBeenCalledWith({ - mediaPath: 'word/media/collab.png', - fileData: 'http://example.com/image.png', - }); - }); - - it('falls back when media is unset and file lacks lastModified', async () => { - vi.resetModules(); - - const findPlaceholder = vi.fn(() => 0); - const removeImagePlaceholder = vi.fn((_, tr) => tr); - - vi.doMock('./imageRegistrationPlugin.js', () => ({ - findPlaceholder, - removeImagePlaceholder, - addImagePlaceholder: vi.fn(), - })); - - vi.doMock('@core/parts/adapters/relationships-mutation.js', () => ({ - findOrCreateRelationship: vi.fn(() => 'rId200'), - })); - - const OriginalFile = globalThis.File; - const fileCtorSpy = vi.fn(); - - class MockFile { - constructor(parts, name, options = {}) { - fileCtorSpy({ parts, name, options }); - this.name = name; - this.type = options.type; - } - } - - globalThis.File = MockFile; - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(123456); - - const { uploadAndInsertImage } = await import('./startImageUpload.js'); - - const editor = { - options: { - handleImageUpload: vi.fn().mockResolvedValue('data:image/png;base64,CCC'), - mode: 'docx', - }, - commands: { - addImageToCollaboration: vi.fn(), - }, - storage: { - image: {}, - }, - state: { - doc: { - descendants: () => {}, - }, - }, - }; - - const backingMedia = {}; - let firstAccess = true; - Object.defineProperty(editor.storage.image, 'media', { - configurable: true, - get() { - if (firstAccess) { - firstAccess = false; - return undefined; - } - return backingMedia; - }, - set(value) { - Object.assign(backingMedia, value); - }, - }); - - const tr = { - replaceWith: vi.fn(() => tr), - }; - - const view = { - state: { - tr, - schema: { - nodes: { - image: { - create: vi.fn(() => ({ attrs: {} })), - }, - }, - }, - }, - dispatch: vi.fn(), - }; - - const sourceFile = { name: 'Screenshot 2025.png', type: 'image/png', size: 10 }; - - try { - await uploadAndInsertImage({ - editor, - view, - file: sourceFile, - size: { width: 10, height: 10 }, - id: {}, - }); - } finally { - globalThis.File = OriginalFile; - nowSpy.mockRestore(); - delete editor.storage.image.media; - editor.storage.image.media = backingMedia; - } - - expect(fileCtorSpy).toHaveBeenCalledTimes(1); - const [[callArgs]] = fileCtorSpy.mock.calls; - expect(callArgs.name).toBe('Screenshot_2025.png'); - expect(callArgs.options.lastModified).toBe(123456); - - expect(editor.options.handleImageUpload).toHaveBeenCalledWith(expect.any(MockFile)); - expect(backingMedia).toHaveProperty('word/media/Screenshot_2025.png'); - expect(findPlaceholder).toHaveBeenCalled(); - expect(removeImagePlaceholder).toHaveBeenCalled(); - }); - - it('uses default upload handler and skips duplicate docPr ids', async () => { - vi.resetModules(); - - const defaultUpload = vi.fn().mockResolvedValue('data:image/png;base64,DDD'); - vi.doMock('./handleImageUpload.js', () => ({ - handleImageUpload: defaultUpload, - })); - vi.doMock('./imageRegistrationPlugin.js', () => ({ - findPlaceholder: () => 0, - removeImagePlaceholder: (_state, tr) => tr, - addImagePlaceholder: vi.fn(), - })); - const relationshipSpy = vi.fn(() => 'rId500'); - vi.doMock('@core/parts/adapters/relationships-mutation.js', () => ({ - findOrCreateRelationship: relationshipSpy, - })); - const randomSpy = vi.fn().mockReturnValueOnce('0000007b').mockReturnValueOnce('0000007c'); - vi.doMock('@core/helpers/index.js', () => ({ - generateDocxRandomId: randomSpy, - })); - - const { uploadAndInsertImage } = await import('./startImageUpload.js'); - - const imageCreateSpy = vi.fn(() => ({ attrs: {} })); - - const editor = { - options: { - mode: 'docx', - }, - commands: { - addImageToCollaboration: vi.fn(), - }, - storage: { - image: { media: {} }, - }, - state: { - doc: { - descendants: (callback) => { - callback({ - type: { name: 'image' }, - attrs: { id: '123' }, - }); - }, - }, - }, - }; - - const tr = { - replaceWith: vi.fn(() => tr), - }; - - const view = { - state: { - tr, - schema: { - nodes: { - image: { - create: imageCreateSpy, - }, - }, - }, - }, - dispatch: vi.fn(), - }; - - const basicFile = new File([new Uint8Array([1])], 'image.png', { type: 'image/png' }); - - await uploadAndInsertImage({ - editor, - view, - file: basicFile, - size: { width: 20, height: 20 }, - id: {}, - }); - - expect(defaultUpload).toHaveBeenCalledTimes(1); - expect(relationshipSpy).toHaveBeenCalledWith(editor, 'startImageUpload:addImageRelationship', { - target: 'media/image.png', - type: 'image', - }); - const createdNodeAttrs = imageCreateSpy.mock.calls[0][0]; - expect(createdNodeAttrs.id).toBe('124'); - - expect(randomSpy).toHaveBeenCalledTimes(2); - }); -});