Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/superdoc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
112 changes: 112 additions & 0 deletions packages/superdoc/scripts/run-external-docx.mjs
Original file line number Diff line number Diff line change
@@ -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 <input.docx> [--output <roundtrip.docx>] [--evidence <evidence.json>]
`;

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();
21 changes: 21 additions & 0 deletions packages/superdoc/tests/external-docx/README.md
Original file line number Diff line number Diff line change
@@ -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.
177 changes: 177 additions & 0 deletions packages/superdoc/tests/external-docx/external-docx.fixture.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 12 additions & 0 deletions packages/superdoc/tests/external-docx/vitest.config.js
Original file line number Diff line number Diff line change
@@ -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,
},
});
Loading