Skip to content

Commit 1b85001

Browse files
authored
Merge pull request #3580 from superdoc-dev/nick/merge-main-stable-1.38.0
Merge main into stable
2 parents d5a260a + c429b92 commit 1b85001

650 files changed

Lines changed: 51334 additions & 10554 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ Do not hand-edit `COMMAND_CATALOG`, `OPERATION_MEMBER_PATH_MAP`, `OPERATION_REFE
7272
- `pnpm test` - unit tests
7373
- `pnpm dev` - dev server from `examples/`
7474
- `pnpm check:types` - raw TS compile across all referenced projects (`tsc -b tsconfig.references.json`). Does NOT run the public-interface chain. Legacy alias: `pnpm run type-check`.
75-
- `pnpm check:public` - **canonical pre-merge command for typed public surfaces.** Validates both `superdoc` (tier discipline + jsdoc ratchet + public-method fixture coverage + vite build + postbuild chain + consumer typecheck matrix + deep-type audit + package-shape + snapshots + classification closure) and Document API (contract parity + output staleness + examples + overview). ~5 min. Non-mutating. Combines `check:public:superdoc` + `check:public:docapi`.
76-
- `pnpm check:public:superdoc` - SuperDoc public package surface only. Wraps ten stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `public-method-coverage`, `build`, `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`. Legacy alias: `pnpm run check:public-contract`.
75+
- `pnpm check:public` - **canonical pre-merge command for typed public surfaces.** Validates both `superdoc` (tier discipline + jsdoc ratchet + ts-jsdoc hygiene + public-method fixture coverage + vite build + postbuild chain + consumer typecheck matrix + deep-type audit + package-shape + snapshots + classification closure) and Document API (contract parity + output staleness + examples + overview). ~5 min. Non-mutating. Combines `check:public:superdoc` + `check:public:docapi`.
76+
- `pnpm check:public:superdoc` - SuperDoc public package surface only. Wraps twelve stages in cheap-to-expensive order: `contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `jsdoc-hygiene-ts-test`, `jsdoc-hygiene-ts`, `public-method-coverage`, `build`, `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, `package-shape`, `export-snapshots`, `root-classification-closure`. Legacy alias: `pnpm run check:public-contract`.
7777
- `pnpm check:public:docapi` - Document API public surface only. Wraps four stages: `contract-parity`, `contract-outputs`, `examples`, `overview-alignment`. Clean-checkout safe: gitignored generated artifacts are built in memory; tracked outputs (reference docs, overview block) are compared byte-for-byte. No mutation. Legacy alias: `pnpm run docapi:check`.
7878
- `pnpm generate:docapi` - regenerate Document API outputs after editing the contract (alias of `docapi:sync`). Writes gitignored Document API generated artifacts. Run only when you need the artifacts materialized locally (SDK builds, publishing); `check:public:docapi` does not require it.
7979
- `pnpm generate:all` - regenerate schemas, SDK clients, tool catalogs, reference docs.

apps/cli/src/__tests__/cli.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ const ENCRYPTED_FIXTURE_SOURCE = join(
6565
REPO_ROOT,
6666
'packages/super-editor/src/editors/v1/core/ooxml-encryption/fixtures/encrypted-advanced-text.docx',
6767
);
68+
const TRACKED_CHANGE_FIXTURE_SOURCE = join(
69+
REPO_ROOT,
70+
'packages/super-editor/src/editors/v1/tests/data/basic-tracked-change.docx',
71+
);
6872
const execFileAsync = promisify(execFile);
6973
const ZIP_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
7074

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

2341+
test('save --mode final exports accepted OOXML instead of copying review-preserving revisions', async () => {
2342+
const trackedSource = join(TEST_DIR, 'save-final-source.docx');
2343+
const savedOut = join(TEST_DIR, 'save-final-output.docx');
2344+
await copyFile(TRACKED_CHANGE_FIXTURE_SOURCE, trackedSource);
2345+
2346+
const openResult = await runCli(['open', trackedSource, '--session', 'final-export']);
2347+
expect(openResult.code).toBe(0);
2348+
2349+
const saveResult = await runCli([
2350+
'save',
2351+
'--session',
2352+
'final-export',
2353+
'--mode',
2354+
'final',
2355+
'--out',
2356+
savedOut,
2357+
'--force',
2358+
]);
2359+
expect(saveResult.code).toBe(0);
2360+
2361+
const saveEnvelope = parseJsonOutput<SuccessEnvelope<{ mode: string; output: { path: string } }>>(saveResult);
2362+
expect(saveEnvelope.data.mode).toBe('final');
2363+
expect(saveEnvelope.data.output.path).toBe(savedOut);
2364+
2365+
const documentXml = await readDocxPart(savedOut, 'word/document.xml');
2366+
expect(documentXml).not.toContain('<w:ins');
2367+
expect(documentXml).not.toContain('<w:del');
2368+
});
2369+
23372370
test('save --in-place detects source drift unless forced', async () => {
23382371
const driftSource = join(TEST_DIR, 'drift-source.docx');
23392372
await copyFile(SAMPLE_DOC, driftSource);

apps/cli/src/__tests__/conformance/scenarios.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function skippedSuccessScenario(operationId: CliOperationId) {
3636
});
3737
}
3838

39-
type SuccessScenarioFactory = (harness: ConformanceHarness) => Promise<ScenarioInvocation>;
39+
type ScenarioFactory = (harness: ConformanceHarness) => Promise<ScenarioInvocation>;
4040

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

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

33603360
const CANONICAL_OPERATION_IDS = Object.keys(CLI_OPERATION_COMMAND_KEYS) as CliOperationId[];
3361+
const SUCCESS_SCENARIOS_BY_OPERATION: Partial<Record<CliOperationId, ScenarioFactory>> = SUCCESS_SCENARIOS;
33613362
const AUTO_SKIPPED_OPERATION_IDS = CANONICAL_OPERATION_IDS.filter(
3362-
(operationId) => SUCCESS_SCENARIOS[operationId] == null,
3363+
(operationId) => SUCCESS_SCENARIOS_BY_OPERATION[operationId] == null,
33633364
);
33643365

33653366
const RUNTIME_CONFORMANCE_SKIP = new Set<CliOperationId>([
33663367
...EXPLICIT_RUNTIME_CONFORMANCE_SKIP,
33673368
...AUTO_SKIPPED_OPERATION_IDS,
33683369
]);
33693370

3371+
const FAILURE_SCENARIOS: Partial<Record<CliOperationId, ScenarioFactory>> = {
3372+
'doc.trackChanges.decide': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
3373+
const stateDir = await harness.createStateDir('doc-trackChanges-decide-missing-id');
3374+
const fixture = await harness.addTrackedChangeFixture(stateDir, 'doc-trackChanges-decide-missing-id');
3375+
return {
3376+
stateDir,
3377+
args: [
3378+
...commandTokens('doc.trackChanges.decide'),
3379+
fixture.docPath,
3380+
'--decision',
3381+
'accept',
3382+
'--target-json',
3383+
JSON.stringify({ id: 'missing-track-change-id' }),
3384+
'--out',
3385+
harness.createOutputPath('doc-trackChanges-decide-missing-id-output'),
3386+
],
3387+
};
3388+
},
3389+
};
3390+
3391+
const EXPECTED_FAILURE_CODES: Partial<Record<CliOperationId, string[]>> = {
3392+
'doc.trackChanges.decide': ['TARGET_NOT_FOUND'],
3393+
};
3394+
33703395
export const OPERATION_SCENARIOS = CANONICAL_OPERATION_IDS.map((operationId) => {
3371-
const success = SUCCESS_SCENARIOS[operationId] ?? skippedSuccessScenario(operationId);
3396+
const success = SUCCESS_SCENARIOS_BY_OPERATION[operationId] ?? skippedSuccessScenario(operationId);
33723397
const scenario: OperationScenario = {
33733398
operationId,
33743399
success,
3375-
failure: genericInvalidArgumentFailure(operationId),
3376-
expectedFailureCodes: ['INVALID_ARGUMENT', 'MISSING_REQUIRED'],
3400+
failure: FAILURE_SCENARIOS[operationId] ?? genericInvalidArgumentFailure(operationId),
3401+
expectedFailureCodes: EXPECTED_FAILURE_CODES[operationId] ?? ['INVALID_ARGUMENT', 'MISSING_REQUIRED'],
33773402
...(RUNTIME_CONFORMANCE_SKIP.has(operationId) ? { skipRuntimeConformance: true } : {}),
33783403
};
33793404
return scenario;
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Subprocess worker for the openDocument track-changes forwarding test.
3+
*/
4+
import { mock } from 'bun:test';
5+
6+
let editorOpenCalled = false;
7+
let capturedTrackChanges: unknown;
8+
9+
const MockEditor = {
10+
open: mock(async (_source: unknown, options: Record<string, unknown> = {}) => {
11+
editorOpenCalled = true;
12+
capturedTrackChanges = (options.modules as { trackChanges?: unknown } | undefined)?.trackChanges;
13+
return {
14+
destroy: () => {},
15+
exportDocument: async () => new Uint8Array(),
16+
};
17+
}),
18+
};
19+
20+
mock.module('superdoc/super-editor', () => ({
21+
Editor: MockEditor,
22+
BLANK_DOCX_BASE64: '',
23+
DocxEncryptionError: class DocxEncryptionError extends Error {
24+
code: string;
25+
constructor(code: string, message: string) {
26+
super(message);
27+
this.code = code;
28+
}
29+
},
30+
getDocumentApiAdapters: () => ({}),
31+
markdownToPmDoc: () => null,
32+
initPartsRuntime: () => ({ dispose: () => {} }),
33+
syncCommentEntitiesFromCollaboration: () => new Set<string>(),
34+
}));
35+
36+
mock.module('@superdoc/document-api', () => ({
37+
createDocumentApi: () => ({}),
38+
}));
39+
40+
mock.module('happy-dom', () => ({
41+
Window: class {
42+
document = {
43+
createElement: () => ({}),
44+
body: { appendChild: () => {}, innerHTML: '' },
45+
};
46+
happyDOM = { abort: () => {} };
47+
close() {}
48+
},
49+
}));
50+
51+
async function main() {
52+
const { openDocument } = await import('../../lib/document');
53+
54+
const io = {
55+
stdout: () => {},
56+
stderr: () => {},
57+
readStdinBytes: async () => new Uint8Array(),
58+
};
59+
60+
let opened: { dispose(): void } | undefined;
61+
try {
62+
opened = await openDocument(undefined, io, {
63+
editorOpenOptions: {
64+
modules: {
65+
trackChanges: {
66+
replacements: 'independent',
67+
},
68+
},
69+
},
70+
});
71+
} finally {
72+
opened?.dispose();
73+
}
74+
75+
console.log(JSON.stringify({ editorOpenCalled, capturedTrackChanges }));
76+
}
77+
78+
main().catch((e) => {
79+
console.error(e);
80+
process.exit(1);
81+
});

apps/cli/src/__tests__/lib/error-mapping.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,32 @@ describe('mapInvokeError', () => {
1414
expect(mapped.message).toBe('blocks.delete requires a target.');
1515
expect(mapped.details).toEqual({ operationId: 'blocks.delete', details: { field: 'target' } });
1616
});
17+
18+
test('preserves TARGET_NOT_FOUND for trackChanges.decide stale ids', () => {
19+
const error = Object.assign(new Error('Tracked change "tc-1" was not found.'), {
20+
code: 'TARGET_NOT_FOUND',
21+
details: { id: 'tc-1' },
22+
});
23+
24+
const mapped = mapInvokeError('trackChanges.decide' as any, error);
25+
expect(mapped.code).toBe('TARGET_NOT_FOUND');
26+
expect(mapped.details).toEqual({ operationId: 'trackChanges.decide', details: { id: 'tc-1' } });
27+
});
28+
29+
test('keeps track-changes accept/reject helper missing ids backward compatible', () => {
30+
const error = Object.assign(new Error('Tracked change "tc-1" was not found.'), {
31+
code: 'TARGET_NOT_FOUND',
32+
details: { id: 'tc-1' },
33+
});
34+
35+
const accept = mapInvokeError('trackChanges.decide' as any, error, { commandName: 'track-changes accept' });
36+
const reject = mapInvokeError('trackChanges.decide' as any, error, { commandName: 'track-changes reject' });
37+
const canonical = mapInvokeError('trackChanges.decide' as any, error, { commandName: 'track-changes decide' });
38+
39+
expect(accept.code).toBe('TRACK_CHANGE_NOT_FOUND');
40+
expect(reject.code).toBe('TRACK_CHANGE_NOT_FOUND');
41+
expect(canonical.code).toBe('TARGET_NOT_FOUND');
42+
});
1743
});
1844

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

234+
test('maps helper trackChanges.decide TARGET_NOT_FOUND receipts to TRACK_CHANGE_NOT_FOUND', () => {
235+
const receipt = {
236+
success: false,
237+
failure: {
238+
code: 'TARGET_NOT_FOUND',
239+
message: 'Tracked change "tc-1" was not found.',
240+
},
241+
};
242+
243+
const helper = mapFailedReceipt('trackChanges.decide' as any, receipt, { commandName: 'track-changes accept' });
244+
const canonical = mapFailedReceipt('trackChanges.decide' as any, receipt, {
245+
commandName: 'track-changes decide',
246+
});
247+
248+
expect(helper?.code).toBe('TRACK_CHANGE_NOT_FOUND');
249+
expect(canonical?.code).toBe('TARGET_NOT_FOUND');
250+
});
251+
208252
test('plan-engine code MATCH_NOT_FOUND passes through with structured details', () => {
209253
const receipt = {
210254
success: false,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Verifies that `openDocument` forwards `editorOpenOptions.modules.trackChanges`
3+
* through to `Editor.open()`.
4+
*
5+
* This runs in a subprocess so `mock.module` cannot leak into other tests.
6+
*/
7+
import { describe, expect, test } from 'bun:test';
8+
import { join } from 'path';
9+
10+
const WORKER_SCRIPT = join(import.meta.dir, '_open-document-track-changes-worker.ts');
11+
12+
describe('openDocument track changes forwarding', () => {
13+
test('trackChanges replacement mode reaches Editor.open()', async () => {
14+
const proc = Bun.spawn(['bun', 'run', WORKER_SCRIPT], {
15+
cwd: join(import.meta.dir, '../../..'),
16+
stdout: 'pipe',
17+
stderr: 'pipe',
18+
});
19+
20+
const exitCode = await proc.exited;
21+
const stdout = await new Response(proc.stdout).text();
22+
const stderr = await new Response(proc.stderr).text();
23+
24+
if (exitCode !== 0) {
25+
throw new Error(`Worker failed (code ${exitCode}):\n${stderr || stdout}`);
26+
}
27+
28+
const result = JSON.parse(stdout.trim());
29+
expect(result.editorOpenCalled).toBe(true);
30+
expect(result.capturedTrackChanges).toEqual({ replacements: 'independent' });
31+
});
32+
});

apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,32 @@ describe('operation runtime metadata', () => {
7575
const optionNames = openOptions.map((o) => o.name);
7676
expect(optionNames).toContain('password');
7777
});
78+
79+
test('doc.open metadata includes trackChanges JSON param', () => {
80+
const openMeta = CLI_OPERATION_METADATA['doc.open'];
81+
const trackChangesParam = openMeta.params.find((p) => p.name === 'trackChanges');
82+
83+
expect(trackChangesParam).toBeDefined();
84+
expect(trackChangesParam!.kind).toBe('jsonFlag');
85+
expect(trackChangesParam!.type).toBe('json');
86+
expect(trackChangesParam!.flag).toBe('track-changes-json');
87+
expect(trackChangesParam!.schema).toEqual({
88+
type: 'object',
89+
properties: {
90+
replacements: {
91+
type: 'string',
92+
enum: ['paired', 'independent'],
93+
description: 'Tracked replacement grouping mode.',
94+
},
95+
},
96+
});
97+
});
98+
99+
test('doc.open option specs include track-changes-json flag', () => {
100+
const openOptions = CLI_OPERATION_OPTION_SPECS['doc.open'];
101+
const trackChangesOption = openOptions.find((o) => o.name === 'track-changes-json');
102+
103+
expect(trackChangesOption).toBeDefined();
104+
expect(trackChangesOption!.type).toBe('string');
105+
});
78106
});

0 commit comments

Comments
 (0)