Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
187fe18
feat: overlapping tracked changes
harbournick May 22, 2026
612e6e9
chore: tests for review issues
harbournick May 22, 2026
8e9397b
fix: floating comments fixes
harbournick May 22, 2026
2c2d49d
chore: add tests for metadata issue
harbournick May 22, 2026
ff2d238
chore: review fix, type fix
harbournick May 22, 2026
0d475d9
chore: add dispatch test for collab bug
harbournick May 22, 2026
026e5d6
fix: collab mode bug
harbournick May 22, 2026
95ad0ed
chore: type fixes
harbournick May 22, 2026
422623d
fix: tc fixes
harbournick May 23, 2026
0b24363
fix: more cases
harbournick May 23, 2026
8f893f7
fix: expose tracked mark predicate option
harbournick May 23, 2026
bb884e1
fix: restore tracked change comment interactions
harbournick May 23, 2026
79a0f8e
chore: generated files update
harbournick May 23, 2026
7796869
fix: coalesce tracked inserts across run gaps
harbournick May 23, 2026
7677029
fix: remaining collab bugs
harbournick May 23, 2026
7aba49f
chore: ci fixes
harbournick May 23, 2026
8281006
chore: more fixes
harbournick May 23, 2026
08a49c6
chore: more fixes
harbournick May 23, 2026
ce01853
chore: type fixes
harbournick May 23, 2026
7bf291a
fix: replacement pair
harbournick May 23, 2026
bc9f9bb
chore: fix regression
harbournick May 23, 2026
986abea
chore: ui and more
harbournick May 24, 2026
be86fb7
fix: add ui for overlapping delete, other fixes
harbournick May 24, 2026
540a6a7
chore: type fixes
harbournick May 24, 2026
d74a395
chore: soec fixes
harbournick May 25, 2026
359677c
chore: more fixes
harbournick May 26, 2026
a3fd042
fix: more bugs
harbournick May 26, 2026
c7c9008
chore: run generate:all and fixes
harbournick May 26, 2026
8e242e2
chore: typefix
harbournick May 26, 2026
0e5d7e4
chore: more fixes
harbournick May 26, 2026
9777d98
chore: fixes
harbournick May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
33 changes: 33 additions & 0 deletions apps/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ const ENCRYPTED_FIXTURE_SOURCE = join(
REPO_ROOT,
'packages/super-editor/src/editors/v1/core/ooxml-encryption/fixtures/encrypted-advanced-text.docx',
);
const TRACKED_CHANGE_FIXTURE_SOURCE = join(
REPO_ROOT,
'packages/super-editor/src/editors/v1/tests/data/basic-tracked-change.docx',
);
const execFileAsync = promisify(execFile);
const ZIP_MAX_BUFFER_BYTES = 10 * 1024 * 1024;

Expand Down Expand Up @@ -2334,6 +2338,35 @@ describe('superdoc CLI', () => {
expect(verifyEnvelope.data.result.total).toBeGreaterThan(0);
});

test('save --mode final exports accepted OOXML instead of copying review-preserving revisions', async () => {
const trackedSource = join(TEST_DIR, 'save-final-source.docx');
const savedOut = join(TEST_DIR, 'save-final-output.docx');
await copyFile(TRACKED_CHANGE_FIXTURE_SOURCE, trackedSource);

const openResult = await runCli(['open', trackedSource, '--session', 'final-export']);
expect(openResult.code).toBe(0);

const saveResult = await runCli([
'save',
'--session',
'final-export',
'--mode',
'final',
'--out',
savedOut,
'--force',
]);
expect(saveResult.code).toBe(0);

const saveEnvelope = parseJsonOutput<SuccessEnvelope<{ mode: string; output: { path: string } }>>(saveResult);
expect(saveEnvelope.data.mode).toBe('final');
expect(saveEnvelope.data.output.path).toBe(savedOut);

const documentXml = await readDocxPart(savedOut, 'word/document.xml');
expect(documentXml).not.toContain('<w:ins');
expect(documentXml).not.toContain('<w:del');
});

test('save --in-place detects source drift unless forced', async () => {
const driftSource = join(TEST_DIR, 'drift-source.docx');
await copyFile(SAMPLE_DOC, driftSource);
Expand Down
37 changes: 31 additions & 6 deletions apps/cli/src/__tests__/conformance/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function skippedSuccessScenario(operationId: CliOperationId) {
});
}

type SuccessScenarioFactory = (harness: ConformanceHarness) => Promise<ScenarioInvocation>;
type ScenarioFactory = (harness: ConformanceHarness) => Promise<ScenarioInvocation>;

function deferredRuntimeScenario(
operationId: CliOperationId,
Expand Down Expand Up @@ -3334,7 +3334,7 @@ export const SUCCESS_SCENARIOS = {
await harness.openSessionFixture(stateDir, 'doc-history-redo', 'history-redo-session');
return { stateDir, args: ['history', 'redo', '--session', 'history-redo-session'] };
},
} as const satisfies Partial<Record<CliOperationId, SuccessScenarioFactory>>;
} as const satisfies Partial<Record<CliOperationId, ScenarioFactory>>;

const EXPLICIT_RUNTIME_CONFORMANCE_SKIP = new Set<CliOperationId>([
'doc.toc.markEntry',
Expand All @@ -3358,22 +3358,47 @@ const EXPLICIT_RUNTIME_CONFORMANCE_SKIP = new Set<CliOperationId>([
]);

const CANONICAL_OPERATION_IDS = Object.keys(CLI_OPERATION_COMMAND_KEYS) as CliOperationId[];
const SUCCESS_SCENARIOS_BY_OPERATION: Partial<Record<CliOperationId, ScenarioFactory>> = SUCCESS_SCENARIOS;
const AUTO_SKIPPED_OPERATION_IDS = CANONICAL_OPERATION_IDS.filter(
(operationId) => SUCCESS_SCENARIOS[operationId] == null,
(operationId) => SUCCESS_SCENARIOS_BY_OPERATION[operationId] == null,
);

const RUNTIME_CONFORMANCE_SKIP = new Set<CliOperationId>([
...EXPLICIT_RUNTIME_CONFORMANCE_SKIP,
...AUTO_SKIPPED_OPERATION_IDS,
]);

const FAILURE_SCENARIOS: Partial<Record<CliOperationId, ScenarioFactory>> = {
'doc.trackChanges.decide': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('doc-trackChanges-decide-missing-id');
const fixture = await harness.addTrackedChangeFixture(stateDir, 'doc-trackChanges-decide-missing-id');
return {
stateDir,
args: [
...commandTokens('doc.trackChanges.decide'),
fixture.docPath,
'--decision',
'accept',
'--target-json',
JSON.stringify({ id: 'missing-track-change-id' }),
'--out',
harness.createOutputPath('doc-trackChanges-decide-missing-id-output'),
],
};
},
};

const EXPECTED_FAILURE_CODES: Partial<Record<CliOperationId, string[]>> = {
'doc.trackChanges.decide': ['TARGET_NOT_FOUND'],
};

export const OPERATION_SCENARIOS = CANONICAL_OPERATION_IDS.map((operationId) => {
const success = SUCCESS_SCENARIOS[operationId] ?? skippedSuccessScenario(operationId);
const success = SUCCESS_SCENARIOS_BY_OPERATION[operationId] ?? skippedSuccessScenario(operationId);
const scenario: OperationScenario = {
operationId,
success,
failure: genericInvalidArgumentFailure(operationId),
expectedFailureCodes: ['INVALID_ARGUMENT', 'MISSING_REQUIRED'],
failure: FAILURE_SCENARIOS[operationId] ?? genericInvalidArgumentFailure(operationId),
expectedFailureCodes: EXPECTED_FAILURE_CODES[operationId] ?? ['INVALID_ARGUMENT', 'MISSING_REQUIRED'],
...(RUNTIME_CONFORMANCE_SKIP.has(operationId) ? { skipRuntimeConformance: true } : {}),
};
return scenario;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Subprocess worker for the openDocument track-changes forwarding test.
*/
import { mock } from 'bun:test';

let editorOpenCalled = false;
let capturedTrackChanges: unknown;

const MockEditor = {
open: mock(async (_source: unknown, options: Record<string, unknown> = {}) => {
editorOpenCalled = true;
capturedTrackChanges = (options.modules as { trackChanges?: unknown } | undefined)?.trackChanges;
return {
destroy: () => {},
exportDocument: async () => new Uint8Array(),
};
}),
};

mock.module('superdoc/super-editor', () => ({
Editor: MockEditor,
BLANK_DOCX_BASE64: '',
DocxEncryptionError: class DocxEncryptionError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
}
},
getDocumentApiAdapters: () => ({}),
markdownToPmDoc: () => null,
initPartsRuntime: () => ({ dispose: () => {} }),
syncCommentEntitiesFromCollaboration: () => new Set<string>(),
}));

mock.module('@superdoc/document-api', () => ({
createDocumentApi: () => ({}),
}));

mock.module('happy-dom', () => ({
Window: class {
document = {
createElement: () => ({}),
body: { appendChild: () => {}, innerHTML: '' },
};
happyDOM = { abort: () => {} };
close() {}
},
}));

async function main() {
const { openDocument } = await import('../../lib/document');

const io = {
stdout: () => {},
stderr: () => {},
readStdinBytes: async () => new Uint8Array(),
};

let opened: { dispose(): void } | undefined;
try {
opened = await openDocument(undefined, io, {
editorOpenOptions: {
modules: {
trackChanges: {
replacements: 'independent',
},
},
},
});
} finally {
opened?.dispose();
}

console.log(JSON.stringify({ editorOpenCalled, capturedTrackChanges }));
}

main().catch((e) => {
console.error(e);
process.exit(1);
});
44 changes: 44 additions & 0 deletions apps/cli/src/__tests__/lib/error-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,32 @@ describe('mapInvokeError', () => {
expect(mapped.message).toBe('blocks.delete requires a target.');
expect(mapped.details).toEqual({ operationId: 'blocks.delete', details: { field: 'target' } });
});

test('preserves TARGET_NOT_FOUND for trackChanges.decide stale ids', () => {
const error = Object.assign(new Error('Tracked change "tc-1" was not found.'), {
code: 'TARGET_NOT_FOUND',
details: { id: 'tc-1' },
});

const mapped = mapInvokeError('trackChanges.decide' as any, error);
expect(mapped.code).toBe('TARGET_NOT_FOUND');
expect(mapped.details).toEqual({ operationId: 'trackChanges.decide', details: { id: 'tc-1' } });
});

test('keeps track-changes accept/reject helper missing ids backward compatible', () => {
const error = Object.assign(new Error('Tracked change "tc-1" was not found.'), {
code: 'TARGET_NOT_FOUND',
details: { id: 'tc-1' },
});

const accept = mapInvokeError('trackChanges.decide' as any, error, { commandName: 'track-changes accept' });
const reject = mapInvokeError('trackChanges.decide' as any, error, { commandName: 'track-changes reject' });
const canonical = mapInvokeError('trackChanges.decide' as any, error, { commandName: 'track-changes decide' });

expect(accept.code).toBe('TRACK_CHANGE_NOT_FOUND');
expect(reject.code).toBe('TRACK_CHANGE_NOT_FOUND');
expect(canonical.code).toBe('TARGET_NOT_FOUND');
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -205,6 +231,24 @@ describe('mapFailedReceipt: plan-engine code passthrough', () => {
expect(result!.code).toBe('COMMAND_FAILED');
});

test('maps helper trackChanges.decide TARGET_NOT_FOUND receipts to TRACK_CHANGE_NOT_FOUND', () => {
const receipt = {
success: false,
failure: {
code: 'TARGET_NOT_FOUND',
message: 'Tracked change "tc-1" was not found.',
},
};

const helper = mapFailedReceipt('trackChanges.decide' as any, receipt, { commandName: 'track-changes accept' });
const canonical = mapFailedReceipt('trackChanges.decide' as any, receipt, {
commandName: 'track-changes decide',
});

expect(helper?.code).toBe('TRACK_CHANGE_NOT_FOUND');
expect(canonical?.code).toBe('TARGET_NOT_FOUND');
});

test('plan-engine code MATCH_NOT_FOUND passes through with structured details', () => {
const receipt = {
success: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Verifies that `openDocument` forwards `editorOpenOptions.modules.trackChanges`
* through to `Editor.open()`.
*
* This runs in a subprocess so `mock.module` cannot leak into other tests.
*/
import { describe, expect, test } from 'bun:test';
import { join } from 'path';

const WORKER_SCRIPT = join(import.meta.dir, '_open-document-track-changes-worker.ts');

describe('openDocument track changes forwarding', () => {
test('trackChanges replacement mode reaches Editor.open()', async () => {
const proc = Bun.spawn(['bun', 'run', WORKER_SCRIPT], {
cwd: join(import.meta.dir, '../../..'),
stdout: 'pipe',
stderr: 'pipe',
});

const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();

if (exitCode !== 0) {
throw new Error(`Worker failed (code ${exitCode}):\n${stderr || stdout}`);
}

const result = JSON.parse(stdout.trim());
expect(result.editorOpenCalled).toBe(true);
expect(result.capturedTrackChanges).toEqual({ replacements: 'independent' });
});
});
28 changes: 28 additions & 0 deletions apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,32 @@ describe('operation runtime metadata', () => {
const optionNames = openOptions.map((o) => o.name);
expect(optionNames).toContain('password');
});

test('doc.open metadata includes trackChanges JSON param', () => {
const openMeta = CLI_OPERATION_METADATA['doc.open'];
const trackChangesParam = openMeta.params.find((p) => p.name === 'trackChanges');

expect(trackChangesParam).toBeDefined();
expect(trackChangesParam!.kind).toBe('jsonFlag');
expect(trackChangesParam!.type).toBe('json');
expect(trackChangesParam!.flag).toBe('track-changes-json');
expect(trackChangesParam!.schema).toEqual({
type: 'object',
properties: {
replacements: {
type: 'string',
enum: ['paired', 'independent'],
description: 'Tracked replacement grouping mode.',
},
},
});
});

test('doc.open option specs include track-changes-json flag', () => {
const openOptions = CLI_OPERATION_OPTION_SPECS['doc.open'];
const trackChangesOption = openOptions.find((o) => o.name === 'track-changes-json');

expect(trackChangesOption).toBeDefined();
expect(trackChangesOption!.type).toBe('string');
});
});
Loading
Loading