Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e919356
feat: overlapping tracked changes
harbournick May 22, 2026
bce0a41
chore: tests for review issues
harbournick May 22, 2026
636bd53
fix: floating comments fixes
harbournick May 22, 2026
f13ec76
chore: add tests for metadata issue
harbournick May 22, 2026
66f8ffe
chore: review fix, type fix
harbournick May 22, 2026
64e4c1b
chore: add dispatch test for collab bug
harbournick May 22, 2026
0b25d5d
fix: collab mode bug
harbournick May 22, 2026
cb83b08
chore: type fixes
harbournick May 22, 2026
e5d16a1
fix: tc fixes
harbournick May 23, 2026
5dc9ad2
fix: more cases
harbournick May 23, 2026
9a5e670
fix: expose tracked mark predicate option
harbournick May 23, 2026
f9fb8c8
fix: restore tracked change comment interactions
harbournick May 23, 2026
69eb528
chore: generated files update
harbournick May 23, 2026
9eca1ed
fix: coalesce tracked inserts across run gaps
harbournick May 23, 2026
825d056
fix: remaining collab bugs
harbournick May 23, 2026
1de9cad
chore: ci fixes
harbournick May 23, 2026
a0c0b57
chore: more fixes
harbournick May 23, 2026
4b1ac90
chore: more fixes
harbournick May 23, 2026
48cfb6b
chore: type fixes
harbournick May 23, 2026
1e23eeb
fix: replacement pair
harbournick May 23, 2026
9a52aa5
chore: fix regression
harbournick May 23, 2026
04c1dc5
chore: ui and more
harbournick May 24, 2026
1855980
fix: add ui for overlapping delete, other fixes
harbournick May 24, 2026
451998e
chore: type fixes
harbournick May 24, 2026
4489541
chore: soec fixes
harbournick May 25, 2026
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
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