Skip to content
Merged
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
6 changes: 6 additions & 0 deletions apps/cli/scripts/export-sdk-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ const INTENT_NAMES = {
'doc.sections.setLinkToPrevious': 'set_section_link_to_previous',
'doc.sections.setPageBorders': 'set_section_page_borders',
'doc.sections.clearPageBorders': 'clear_section_page_borders',
'doc.create.tableOfContents': 'create_table_of_contents',
'doc.lists.list': 'list_lists',
'doc.lists.get': 'get_list',
'doc.lists.insert': 'insert_list',
Expand All @@ -105,6 +106,11 @@ const INTENT_NAMES = {
'doc.trackChanges.list': 'list_tracked_changes',
'doc.trackChanges.get': 'get_tracked_change',
'doc.trackChanges.decide': 'decide_tracked_change',
'doc.toc.list': 'list_table_of_contents',
'doc.toc.get': 'get_table_of_contents',
'doc.toc.configure': 'configure_table_of_contents',
'doc.toc.update': 'update_table_of_contents',
'doc.toc.remove': 'remove_table_of_contents',
'doc.query.match': 'query_match',
'doc.mutations.preview': 'preview_mutations',
'doc.mutations.apply': 'apply_mutations',
Expand Down
59 changes: 58 additions & 1 deletion apps/cli/src/__tests__/conformance/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { copyFile, mkdtemp, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { run } from '../../index';
import { resolveListDocFixture, resolveSourceDocFixture } from '../fixtures';
import { resolveListDocFixture, resolveSourceDocFixture, resolveTocDocFixture } from '../fixtures';

type RunResult = {
code: number;
Expand Down Expand Up @@ -48,6 +48,12 @@ export type ListItemAddress = {
nodeId: string;
};

export type TocAddress = {
kind: 'block';
nodeType: 'tableOfContents';
nodeId: string;
};

function parseEnvelope(raw: RunResult): CommandEnvelope {
const source = raw.stdout.trim() || raw.stderr.trim();
if (!source) {
Expand Down Expand Up @@ -119,6 +125,36 @@ export class ConformanceHarness {
return filePath;
}

async copyTocFixtureDoc(label: string, stateDir: string): Promise<string> {
const filePath = path.join(this.docsDir, `${this.nextId()}-${label}.docx`);

try {
await copyFile(await resolveTocDocFixture(), filePath);
const probe = await this.runCli(['toc', 'list', filePath, '--limit', '1'], stateDir);
if (probe.result.code === 0) {
return filePath;
}
} catch {
// Fall back to creating a TOC fixture from the generic source doc.
}

const sourceDoc = await this.copyFixtureDoc(`${label}-seed`);
const seededPath = path.join(this.docsDir, `${this.nextId()}-${label}-seeded.docx`);
const { result, envelope } = await this.runCli(
['create', 'table-of-contents', sourceDoc, '--out', seededPath],
stateDir,
);

if (result.code !== 0 || envelope.ok !== true) {
const details = envelope.ok
? 'unexpected non-success envelope'
: `${envelope.error.code}: ${envelope.error.message}`;
throw new Error(`Unable to seed TOC fixture for ${label}: ${details}`);
}

return seededPath;
}

createOutputPath(label: string): string {
return path.join(this.docsDir, `${this.nextId()}-${label}.docx`);
}
Expand Down Expand Up @@ -236,6 +272,27 @@ export class ConformanceHarness {
return address;
}

async firstTocAddress(docPath: string, stateDir: string): Promise<TocAddress> {
const { result, envelope } = await this.runCli(['toc', 'list', docPath, '--limit', '1'], stateDir);
if (result.code !== 0) {
throw new Error(`Unable to resolve first table of contents for ${docPath}`);
}

assertSuccessEnvelope(envelope);
const data = envelope.data as {
result?: {
items?: Array<{
address?: TocAddress;
}>;
};
};
const address = data.result?.items?.[0]?.address;
if (!address) {
throw new Error(`No table of contents address found in ${docPath}`);
}
return address;
}

async addCommentFixture(
stateDir: string,
label: string,
Expand Down
66 changes: 66 additions & 0 deletions apps/cli/src/__tests__/conformance/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,43 @@ function tableScopedMutationScenario(
};
}

function tocMutationScenario(
op: string,
extraArgs: string[],
): (harness: ConformanceHarness) => Promise<ScenarioInvocation> {
return async (harness) => {
const label = `toc-${op.replace(/\./g, '-')}`;
const stateDir = await harness.createStateDir(`${label}-success`);
const docPath = await harness.copyTocFixtureDoc(`${label}-source`, stateDir);
const tocTarget = await harness.firstTocAddress(docPath, stateDir);
return {
stateDir,
args: [
...commandTokens(`doc.${op}` as CliOperationId),
docPath,
'--target-json',
JSON.stringify(tocTarget),
...extraArgs,
'--out',
harness.createOutputPath(`${label}-out`),
],
};
};
}

function tocReadWithTargetScenario(op: string): (harness: ConformanceHarness) => Promise<ScenarioInvocation> {
return async (harness) => {
const label = `toc-${op.replace(/\./g, '-')}`;
const stateDir = await harness.createStateDir(`${label}-success`);
const docPath = await harness.copyTocFixtureDoc(`${label}-source`, stateDir);
const tocTarget = await harness.firstTocAddress(docPath, stateDir);
return {
stateDir,
args: [...commandTokens(`doc.${op}` as CliOperationId), docPath, '--target-json', JSON.stringify(tocTarget)],
};
};
}

export const SUCCESS_SCENARIOS = {
'doc.open': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('doc-open-success');
Expand Down Expand Up @@ -582,6 +619,23 @@ export const SUCCESS_SCENARIOS = {
],
};
},
'doc.create.tableOfContents': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('doc-create-toc-success');
const docPath = await harness.copyFixtureDoc('doc-create-toc');
return {
stateDir,
args: [
...commandTokens('doc.create.tableOfContents'),
docPath,
'--at-json',
JSON.stringify({ kind: 'documentStart' }),
'--config-json',
JSON.stringify({ hyperlinks: true, outlineLevels: { from: 1, to: 3 } }),
'--out',
harness.createOutputPath('doc-create-toc-output'),
],
};
},
'doc.create.paragraph': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('doc-create-paragraph-success');
const docPath = await harness.copyFixtureDoc('doc-create-paragraph');
Expand Down Expand Up @@ -1160,6 +1214,18 @@ export const SUCCESS_SCENARIOS = {
],
};
},
'doc.toc.list': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('doc-toc-list-success');
const docPath = await harness.copyTocFixtureDoc('doc-toc-list', stateDir);
return {
stateDir,
args: [...commandTokens('doc.toc.list'), docPath, '--limit', '1'],
};
},
'doc.toc.get': tocReadWithTargetScenario('toc.get'),
'doc.toc.configure': tocMutationScenario('toc.configure', ['--patch-json', JSON.stringify({ hyperlinks: false })]),
'doc.toc.update': tocMutationScenario('toc.update', []),
'doc.toc.remove': tocMutationScenario('toc.remove', []),
'doc.session.list': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('doc-session-list-success');
await harness.openSessionFixture(stateDir, 'doc-session-list', 'session-list-success');
Expand Down
13 changes: 13 additions & 0 deletions apps/cli/src/__tests__/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,15 @@ const LIST_SOURCE_DOC_CANDIDATES = [
path.join(REPO_ROOT, 'e2e-tests/test-data/basic-documents/lists-complex-items.docx'),
];

const TOC_SOURCE_DOC_CANDIDATES = [
path.join(REPO_ROOT, 'test-corpus/basic/table-of-contents.docx'),
path.join(REPO_ROOT, 'test-corpus/basic/table-of-contents-sdt.docx'),
path.join(REPO_ROOT, 'test-corpus/layout/toc-with-heading2.docx'),
];

let resolvedSourceDoc: string | null = null;
let resolvedListSourceDoc: string | null = null;
let resolvedTocSourceDoc: string | null = null;

async function resolveFixture(candidates: string[], fixtureLabel: string): Promise<string> {
for (const candidate of candidates) {
Expand All @@ -42,3 +49,9 @@ export async function resolveListDocFixture(): Promise<string> {
resolvedListSourceDoc = await resolveFixture(LIST_SOURCE_DOC_CANDIDATES, 'list');
return resolvedListSourceDoc;
}

export async function resolveTocDocFixture(): Promise<string> {
if (resolvedTocSourceDoc != null) return resolvedTocSourceDoc;
resolvedTocSourceDoc = await resolveFixture(TOC_SOURCE_DOC_CANDIDATES, 'table-of-contents');
return resolvedTocSourceDoc;
}
25 changes: 25 additions & 0 deletions apps/cli/src/cli/operation-hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export const SUCCESS_VERB: Record<CliExposedOperationId, string> = {
'styles.apply': 'applied stylesheet defaults',
'create.paragraph': 'created paragraph',
'create.heading': 'created heading',
'create.tableOfContents': 'created table of contents',
'lists.list': 'listed items',
'lists.get': 'resolved list item',
'lists.insert': 'inserted list item',
Expand All @@ -73,6 +74,11 @@ export const SUCCESS_VERB: Record<CliExposedOperationId, string> = {
'trackChanges.list': 'listed tracked changes',
'trackChanges.get': 'resolved tracked change',
'trackChanges.decide': 'reviewed tracked change',
'toc.list': 'listed tables of contents',
'toc.get': 'resolved table of contents',
'toc.configure': 'configured table of contents',
'toc.update': 'updated table of contents',
'toc.remove': 'removed table of contents',
'query.match': 'matched selectors',
'mutations.preview': 'previewed mutations',
'mutations.apply': 'applied mutations',
Expand Down Expand Up @@ -164,6 +170,7 @@ export const OUTPUT_FORMAT: Record<CliExposedOperationId, OutputFormat> = {
'styles.apply': 'receipt',
'create.paragraph': 'createResult',
'create.heading': 'createResult',
'create.tableOfContents': 'createResult',
'lists.list': 'listResult',
'lists.get': 'listItemInfo',
'lists.insert': 'listsMutationResult',
Expand All @@ -180,6 +187,11 @@ export const OUTPUT_FORMAT: Record<CliExposedOperationId, OutputFormat> = {
'trackChanges.list': 'trackChangeList',
'trackChanges.get': 'trackChangeInfo',
'trackChanges.decide': 'trackChangeMutationReceipt',
'toc.list': 'plain',
'toc.get': 'plain',
'toc.configure': 'plain',
'toc.update': 'plain',
'toc.remove': 'plain',
'query.match': 'plain',
'mutations.preview': 'plain',
'mutations.apply': 'plain',
Expand Down Expand Up @@ -255,6 +267,7 @@ export const RESPONSE_ENVELOPE_KEY: Record<CliExposedOperationId, string | null>
'styles.apply': 'receipt',
'create.paragraph': 'result',
'create.heading': 'result',
'create.tableOfContents': 'result',
'lists.list': 'result',
'lists.get': 'item',
'lists.insert': 'result',
Expand All @@ -271,6 +284,11 @@ export const RESPONSE_ENVELOPE_KEY: Record<CliExposedOperationId, string | null>
'trackChanges.list': 'result',
'trackChanges.get': 'change',
'trackChanges.decide': 'receipt',
'toc.list': 'result',
'toc.get': 'result',
'toc.configure': 'result',
'toc.update': 'result',
'toc.remove': 'result',
'query.match': 'result',
'mutations.preview': 'result',
'mutations.apply': 'result',
Expand Down Expand Up @@ -352,6 +370,7 @@ export type OperationFamily =
| 'comments'
| 'lists'
| 'tables'
| 'toc'
| 'textMutation'
| 'create'
| 'blocks'
Expand All @@ -374,6 +393,7 @@ export const OPERATION_FAMILY: Record<CliExposedOperationId, OperationFamily> =
'styles.apply': 'general',
'create.paragraph': 'create',
'create.heading': 'create',
'create.tableOfContents': 'create',
'lists.list': 'lists',
'lists.get': 'lists',
'lists.insert': 'lists',
Expand All @@ -390,6 +410,11 @@ export const OPERATION_FAMILY: Record<CliExposedOperationId, OperationFamily> =
'trackChanges.list': 'trackChanges',
'trackChanges.get': 'trackChanges',
'trackChanges.decide': 'trackChanges',
'toc.list': 'query',
'toc.get': 'query',
'toc.configure': 'toc',
'toc.update': 'toc',
'toc.remove': 'toc',
'query.match': 'query',
'mutations.preview': 'general',
'mutations.apply': 'general',
Expand Down
44 changes: 42 additions & 2 deletions apps/cli/src/lib/error-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,30 @@ function mapBlocksError(operationId: CliExposedOperationId, error: unknown, code
return new CliError('COMMAND_FAILED', message, { operationId, details });
}

function mapTocError(operationId: CliExposedOperationId, error: unknown, code: string | undefined): CliError {
const message = extractErrorMessage(error);
const details = extractErrorDetails(error);

// Plan-engine errors pass through with original code and structured details
const planEngineError = tryMapPlanEngineError(operationId, error, code);
if (planEngineError) return planEngineError;

if (code === 'TARGET_NOT_FOUND') {
return new CliError('TARGET_NOT_FOUND', message, { operationId, details });
}

if (code === 'INVALID_TARGET') {
return new CliError('INVALID_ARGUMENT', message, { operationId, details });
}

if (code === 'COMMAND_UNAVAILABLE') {
return new CliError('COMMAND_FAILED', message, { operationId, details });
}

if (error instanceof CliError) return error;
return new CliError('COMMAND_FAILED', message, { operationId, details });
}

function mapQueryError(operationId: CliExposedOperationId, error: unknown, code: string | undefined): CliError {
const message = extractErrorMessage(error);
const details = extractErrorDetails(error);
Expand Down Expand Up @@ -273,6 +297,7 @@ const FAMILY_MAPPERS: Record<
comments: mapCommentsError,
lists: mapListsError,
tables: mapTablesError,
toc: mapTocError,
textMutation: mapTextMutationError,
create: mapCreateError,
blocks: mapBlocksError,
Expand All @@ -287,14 +312,18 @@ const FAMILY_MAPPERS: Record<
},
};

function resolveOperationFamily(operationId: CliExposedOperationId): OperationFamily {
return (OPERATION_FAMILY as Record<string, OperationFamily | undefined>)[operationId] ?? 'general';
}

/**
* Maps an invoke() exception to a CLI error with the appropriate error code.
* Called by the generic dispatch path after every invoke() failure.
*/
export function mapInvokeError(operationId: CliExposedOperationId, error: unknown): CliError {
if (error instanceof CliError) return error;
const code = extractErrorCode(error);
const family = OPERATION_FAMILY[operationId];
const family = resolveOperationFamily(operationId);
return FAMILY_MAPPERS[family](operationId, error, code);
}

Expand Down Expand Up @@ -329,7 +358,7 @@ export function mapFailedReceipt(operationId: CliExposedOperationId, result: unk
if (result.success) return null;

const failure = result.failure;
const family = OPERATION_FAMILY[operationId];
const family = resolveOperationFamily(operationId);

if (!failure) {
return new CliError('COMMAND_FAILED', `${operationId}: operation failed.`, { operationId });
Expand Down Expand Up @@ -406,6 +435,17 @@ export function mapFailedReceipt(operationId: CliExposedOperationId, result: unk
return new CliError('COMMAND_FAILED', failureMessage, { operationId, failure });
}

// TOC family
if (family === 'toc') {
if (failureCode === 'TARGET_NOT_FOUND') {
return new CliError('TARGET_NOT_FOUND', failureMessage, { operationId, failure });
}
if (failureCode === 'INVALID_TARGET') {
return new CliError('INVALID_ARGUMENT', failureMessage, { operationId, failure });
}
return new CliError('COMMAND_FAILED', failureMessage, { operationId, failure });
}

// Tables family
if (family === 'tables') {
if (failureCode === 'TARGET_NOT_FOUND') {
Expand Down
Loading
Loading