diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index 912171bb6d..25a305bc5b 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -135,7 +135,8 @@ "pack:local": "pnpm run pack:es", "pack": "node ./scripts/pack-output.cjs clean && pnpm run build:es && pnpm pack && node ./scripts/pack-output.cjs finalize", "pack:es": "node ./scripts/pack-output.cjs clean && pnpm run build:es && pnpm pack && node ./scripts/pack-output.cjs finalize", - "test": "vitest" + "test": "vitest", + "test:external-docx": "node scripts/run-external-docx.mjs" }, "dependencies": { "@types/mdast": "catalog:", diff --git a/packages/superdoc/scripts/run-external-docx.mjs b/packages/superdoc/scripts/run-external-docx.mjs new file mode 100644 index 0000000000..8ce3f4b8a6 --- /dev/null +++ b/packages/superdoc/scripts/run-external-docx.mjs @@ -0,0 +1,112 @@ +import { existsSync, statSync } from 'node:fs'; +import { mkdir, mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, extname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const usage = `Usage: + pnpm --filter superdoc test:external-docx -- --fixture [--output ] [--evidence ] +`; + +const parseOptions = (args) => { + if (args.includes('--help')) return { help: true }; + + const values = new Map(); + const allowed = new Set(['--fixture', '--output', '--evidence']); + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const value = args[index + 1]; + if (!flag || !allowed.has(flag)) throw new Error(`Unknown option: ${flag ?? ''}`); + if (!value) throw new Error(`${flag} requires a value`); + if (values.has(flag)) throw new Error(`${flag} may only be provided once`); + values.set(flag, value); + } + + const fixture = values.get('--fixture'); + if (!fixture) throw new Error('--fixture is required'); + return { + fixture: resolve(fixture), + output: values.has('--output') ? resolve(values.get('--output')) : null, + evidence: values.has('--evidence') ? resolve(values.get('--evidence')) : null, + }; +}; + +const main = async () => { + let options; + try { + const args = process.argv.slice(2); + if (args[0] === '--') args.shift(); + options = parseOptions(args); + } catch (error) { + console.error(error.message); + console.error(usage); + return 1; + } + + if (options.help) { + console.log(usage); + return 0; + } + + if (extname(options.fixture).toLowerCase() !== '.docx') { + console.error(`Fixture must be a .docx file: ${options.fixture}`); + return 1; + } + if (!existsSync(options.fixture) || !statSync(options.fixture).isFile()) { + console.error(`Fixture does not exist: ${options.fixture}`); + return 1; + } + + const artifactsDirectory = + options.output && options.evidence ? null : await mkdtemp(join(tmpdir(), 'superdoc-external-docx-')); + const output = options.output ?? join(artifactsDirectory, 'roundtrip.docx'); + const evidence = options.evidence ?? join(artifactsDirectory, 'evidence.json'); + if (extname(output).toLowerCase() !== '.docx') { + console.error(`Output must be a .docx file: ${output}`); + return 1; + } + if (extname(evidence).toLowerCase() !== '.json') { + console.error(`Evidence must be a .json file: ${evidence}`); + return 1; + } + if (new Set([options.fixture, output, evidence]).size !== 3) { + console.error('Fixture, output, and evidence paths must be different'); + return 1; + } + const existingArtifact = [output, evidence].find((path) => existsSync(path)); + if (existingArtifact) { + console.error(`Artifact path already exists: ${existingArtifact}`); + return 1; + } + await Promise.all([mkdir(dirname(output), { recursive: true }), mkdir(dirname(evidence), { recursive: true })]); + + console.log(`Fixture: ${options.fixture}`); + console.log(`Round-trip DOCX: ${output}`); + console.log(`Evidence: ${evidence}`); + + const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + const result = spawnSync( + pnpmCommand, + ['exec', 'vitest', 'run', '--config', 'tests/external-docx/vitest.config.js', '--reporter=verbose'], + { + cwd: packageRoot, + stdio: 'inherit', + env: { + ...process.env, + SUPERDOC_TEST_DOCX: options.fixture, + SUPERDOC_TEST_OUTPUT: output, + SUPERDOC_TEST_EVIDENCE: evidence, + }, + }, + ); + + if (result.error) { + console.error(`Unable to start Vitest: ${result.error.message}`); + return 1; + } + return result.status ?? 1; +}; + +process.exitCode = await main(); diff --git a/packages/superdoc/tests/external-docx/README.md b/packages/superdoc/tests/external-docx/README.md new file mode 100644 index 0000000000..c4a600a417 --- /dev/null +++ b/packages/superdoc/tests/external-docx/README.md @@ -0,0 +1,21 @@ +# External DOCX feedback loop + +Run an externally generated DOCX through SuperDoc import, comment projection, export, and re-import: + +```bash +pnpm --filter superdoc test:external-docx -- \ + --fixture /absolute/path/input.docx +``` + +The command prints temporary paths for the exported DOCX and JSON evidence. Keep artifacts at explicit paths when another tool, such as Microsoft Word, needs to inspect them: + +```bash +pnpm --filter superdoc test:external-docx -- \ + --fixture /absolute/path/input.docx \ + --output /tmp/superdoc-roundtrip.docx \ + --evidence /tmp/superdoc-roundtrip.json +``` + +The fixture must contain at least one comment. The run fails when comment import is empty or a comment disappears during sidebar projection or export. The evidence records input and output hashes, rendered comment HTML, and imported node types. + +The DOCX editor and comments store are real. User, selection, collaboration, and comment-model host plumbing are mocked so this test stays focused on document conversion and sidebar projection. Generated fixtures and artifacts stay outside the repository. diff --git a/packages/superdoc/tests/external-docx/external-docx.fixture.js b/packages/superdoc/tests/external-docx/external-docx.fixture.js new file mode 100644 index 0000000000..6d4511c57b --- /dev/null +++ b/packages/superdoc/tests/external-docx/external-docx.fixture.js @@ -0,0 +1,177 @@ +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { createPinia, defineStore, setActivePinia } from 'pinia'; +import { reactive, ref } from 'vue'; + +vi.mock('@superdoc/stores/superdoc-store', () => { + const documents = ref([]); + const user = reactive({ name: 'Fixture runner', email: 'fixture-runner@superdoc.dev' }); + const activeSelection = reactive({ documentId: 'external-docx', selectionBounds: {} }); + const selectionPosition = reactive({ source: null }); + const getDocument = (id) => documents.value.find((document) => document.id === id); + const useMockStore = defineStore('superdoc', () => ({ + documents, + user, + activeSelection, + selectionPosition, + getDocument, + })); + + return { + useSuperdocStore: useMockStore, + __fixtureSuperdoc: { + documents, + user, + activeSelection, + selectionPosition, + emit: vi.fn(), + config: { isInternal: false }, + }, + }; +}); + +vi.mock('@superdoc/components/CommentsLayer/use-comment', () => ({ + default: vi.fn((params = {}) => { + const selection = params.selection || { source: 'external-docx', selectionBounds: {} }; + return { + ...params, + selection, + getValues: () => ({ ...params, selection }), + setText: vi.fn(), + }; + }), +})); + +vi.mock('@superdoc/core/collaboration/helpers.js', () => ({ syncCommentsToClients: vi.fn() })); +vi.mock('@superdoc/helpers/group-changes.js', () => ({ groupChanges: vi.fn(() => []) })); + +import { useCommentsStore } from '@superdoc/stores/comments-store.js'; +import { __fixtureSuperdoc } from '@superdoc/stores/superdoc-store'; +import { Editor } from '@superdoc/super-editor'; +import { initTestEditor } from '@tests/helpers/helpers.js'; + +const requiredPath = (name) => { + const value = process.env[name]; + if (!value) + throw new Error(`${name} is required. Run this fixture through pnpm --filter superdoc test:external-docx.`); + return value; +}; + +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); + +const toBuffer = async (value) => { + if (Buffer.isBuffer(value)) return value; + if (value instanceof ArrayBuffer) return Buffer.from(value); + if (ArrayBuffer.isView(value)) return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + if (typeof value?.arrayBuffer === 'function') return Buffer.from(await value.arrayBuffer()); + throw new TypeError('Editor.exportDocx returned an unsupported value'); +}; + +const collectTypes = (value) => { + const types = []; + const visit = (node) => { + if (Array.isArray(node)) return node.forEach(visit); + if (!node || typeof node !== 'object') return; + if (typeof node.type === 'string') types.push(node.type); + if (Array.isArray(node.content)) node.content.forEach(visit); + }; + visit(value); + return types; +}; + +const commentId = (comment) => String(comment?.commentId ?? ''); +const sortedCommentIds = (comments) => comments.map(commentId).sort(); +const sameCommentIds = (left, right) => { + const leftIds = sortedCommentIds(left); + const rightIds = sortedCommentIds(right); + return leftIds.length === rightIds.length && leftIds.every((id, index) => id === rightIds[index]); +}; + +describe('external DOCX feedback loop', () => { + let editor; + let reimportEditor; + + beforeEach(() => { + setActivePinia(createPinia()); + __fixtureSuperdoc.documents.value = [{ id: 'external-docx', type: 'docx' }]; + }); + + afterEach(() => { + editor?.destroy(); + reimportEditor?.destroy(); + }); + + it('imports, projects comments, exports, and re-imports the document', async () => { + const fixturePath = requiredPath('SUPERDOC_TEST_DOCX'); + const outputPath = requiredPath('SUPERDOC_TEST_OUTPUT'); + const evidencePath = requiredPath('SUPERDOC_TEST_EVIDENCE'); + const fixture = await readFile(fixturePath); + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(fixture, true); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts, documentId: 'external-docx' })); + + const importedComments = editor.converter.comments ?? []; + const store = useCommentsStore(); + await store.processLoadedDocxComments({ + superdoc: __fixtureSuperdoc, + editor, + comments: importedComments, + documentId: 'external-docx', + }); + const projectedComments = [...store.commentsList]; + + const exported = await editor.exportDocx({ + comments: store.translateCommentsForExport(), + commentsType: 'external', + }); + const exportedBytes = await toBuffer(exported); + await writeFile(outputPath, exportedBytes); + + const [reimportedDocx, reimportedMedia, reimportedMediaFiles, reimportedFonts] = await Editor.loadXmlData( + exportedBytes, + true, + ); + ({ editor: reimportEditor } = initTestEditor({ + content: reimportedDocx, + media: reimportedMedia, + mediaFiles: reimportedMediaFiles, + fonts: reimportedFonts, + documentId: 'external-docx-reimported', + })); + const reimportedComments = reimportEditor.converter.comments ?? []; + const commentsImported = importedComments.length > 0; + const projectionPreserved = sameCommentIds(projectedComments, importedComments); + const roundTripPreserved = sameCommentIds(reimportedComments, importedComments); + + const evidence = { + schemaVersion: '1.0', + input: { path: fixturePath, bytes: fixture.byteLength, sha256: sha256(fixture) }, + output: { path: outputPath, bytes: exportedBytes.byteLength, sha256: sha256(exportedBytes) }, + comments: { + imported: importedComments.map((comment) => ({ + id: commentId(comment), + nodeTypes: collectTypes(comment.elements), + })), + projected: projectedComments.map((comment) => ({ + id: commentId(comment), + html: comment.commentText ?? null, + nodeTypes: collectTypes(comment.docxCommentJSON), + })), + reimported: reimportedComments.map((comment) => ({ + id: commentId(comment), + nodeTypes: collectTypes(comment.elements), + })), + }, + checks: { + commentsImported, + projectionPreserved, + roundTripPreserved, + }, + }; + await writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`); + + expect(exportedBytes.byteLength).toBeGreaterThan(0); + expect(commentsImported, 'No comments were imported from the fixture').toBe(true); + expect(projectionPreserved, 'Comment identities changed during sidebar projection').toBe(true); + expect(roundTripPreserved, 'Comment identities changed after export and re-import').toBe(true); + }); +}); diff --git a/packages/superdoc/tests/external-docx/vitest.config.js b/packages/superdoc/tests/external-docx/vitest.config.js new file mode 100644 index 0000000000..fc3c30efbf --- /dev/null +++ b/packages/superdoc/tests/external-docx/vitest.config.js @@ -0,0 +1,12 @@ +import { mergeConfig } from 'vitest/config'; +import packageConfigFactory from '../../vite.config.js'; + +const packageConfig = packageConfigFactory({ mode: 'test', command: 'serve' }); + +export default mergeConfig(packageConfig, { + test: { + include: ['tests/external-docx/external-docx.fixture.js'], + retry: 0, + testTimeout: 60_000, + }, +});