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
40 changes: 40 additions & 0 deletions apps/cli/scripts/export-sdk-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,46 @@ const INTENT_NAMES = {
'doc.query.match': 'query_match',
'doc.mutations.preview': 'preview_mutations',
'doc.mutations.apply': 'apply_mutations',
'doc.create.table': 'create_table',
'doc.tables.convertFromText': 'convert_text_to_table',
'doc.tables.delete': 'delete_table',
'doc.tables.clearContents': 'clear_table_contents',
'doc.tables.move': 'move_table',
'doc.tables.split': 'split_table',
'doc.tables.convertToText': 'convert_table_to_text',
'doc.tables.setLayout': 'set_table_layout',
'doc.tables.insertRow': 'insert_table_row',
'doc.tables.deleteRow': 'delete_table_row',
'doc.tables.setRowHeight': 'set_table_row_height',
'doc.tables.distributeRows': 'distribute_table_rows',
'doc.tables.setRowOptions': 'set_table_row_options',
'doc.tables.insertColumn': 'insert_table_column',
'doc.tables.deleteColumn': 'delete_table_column',
'doc.tables.setColumnWidth': 'set_table_column_width',
'doc.tables.distributeColumns': 'distribute_table_columns',
'doc.tables.insertCell': 'insert_table_cell',
'doc.tables.deleteCell': 'delete_table_cell',
'doc.tables.mergeCells': 'merge_table_cells',
'doc.tables.unmergeCells': 'unmerge_table_cells',
'doc.tables.splitCell': 'split_table_cell',
'doc.tables.setCellProperties': 'set_table_cell_properties',
'doc.tables.sort': 'sort_table',
'doc.tables.setAltText': 'set_table_alt_text',
'doc.tables.setStyle': 'set_table_style',
'doc.tables.clearStyle': 'clear_table_style',
'doc.tables.setStyleOption': 'set_table_style_option',
'doc.tables.setBorder': 'set_table_border',
'doc.tables.clearBorder': 'clear_table_border',
'doc.tables.applyBorderPreset': 'apply_table_border_preset',
'doc.tables.setShading': 'set_table_shading',
'doc.tables.clearShading': 'clear_table_shading',
'doc.tables.setTablePadding': 'set_table_padding',
'doc.tables.setCellPadding': 'set_table_cell_padding',
'doc.tables.setCellSpacing': 'set_table_cell_spacing',
'doc.tables.clearCellSpacing': 'clear_table_cell_spacing',
'doc.tables.get': 'get_table',
'doc.tables.getCells': 'get_table_cells',
'doc.tables.getProperties': 'get_table_properties',
} as const satisfies Record<DocBackedCliOpId, string>;

// ---------------------------------------------------------------------------
Expand Down
68 changes: 68 additions & 0 deletions apps/cli/src/__tests__/conformance/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,74 @@ export class ConformanceHarness {
return { sessionId, docPath };
}

/**
* Creates a doc with a 3x3 table and opens it in a session.
*
* Because sdBlockId is regenerated on each document open, nodeIds are only
* stable within a single session. This method creates the table, then opens
* the output doc in a persistent session and discovers the table nodeId via
* `find`. Subsequent commands must use `--session` to stay in the same
* address space.
*/
async createTableFixture(
stateDir: string,
label: string,
): Promise<{ docPath: string; tableNodeId: string; cellNodeId: string; sessionId: string }> {
const sourceDoc = await this.copyFixtureDoc(`${label}-source`);
const outDoc = this.createOutputPath(`${label}-with-table`);

const { result } = await this.runCli(
['create', 'table', sourceDoc, '--rows', '3', '--columns', '3', '--out', outDoc],
stateDir,
);
if (result.code !== 0) {
throw new Error(`Failed to create table fixture for ${label}`);
}

// Open the output doc in a session so the nodeId stays stable
const sessionId = `table-${label}-session`;
const open = await this.runCli(['open', outDoc, '--session', sessionId], stateDir);
if (open.result.code !== 0) {
throw new Error(`Failed to open table session for ${label}`);
}

// Discover the table nodeId within the session
const { result: findResult, envelope: findEnvelope } = await this.runCli(
['find', '--session', sessionId, '--type', 'node', '--node-type', 'table', '--limit', '1'],
stateDir,
);
if (findResult.code !== 0) {
throw new Error(`Unable to find table in session for ${label}`);
}
assertSuccessEnvelope(findEnvelope);
const data = findEnvelope.data as {
result?: { items?: Array<{ address?: { nodeId?: string } }> };
};
const tableNodeId = data.result?.items?.[0]?.address?.nodeId;
if (!tableNodeId) {
throw new Error(`No table found in session for ${label}`);
}

// Discover a cell nodeId within the same session
const { result: cellResult, envelope: cellEnvelope } = await this.runCli(
['find', '--session', sessionId, '--type', 'node', '--node-type', 'tableCell', '--limit', '1'],
stateDir,
);
if (cellResult.code !== 0) {
throw new Error(`Unable to find table cell in session for ${label}`);
}
assertSuccessEnvelope(cellEnvelope);
const cellData = cellEnvelope.data as {
result?: { items?: Array<{ address?: { nodeId?: string } }> };
};
const cellNodeId = cellData.result?.items?.[0]?.address?.nodeId;
if (!cellNodeId) {
throw new Error(`No table cell found in session for ${label}`);
}

return { docPath: outDoc, tableNodeId, cellNodeId, sessionId };
}

nextId(): string {
this.#counter += 1;
return String(this.#counter).padStart(4, '0');
Expand Down
252 changes: 252 additions & 0 deletions apps/cli/src/__tests__/conformance/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,108 @@ function genericInvalidArgumentFailure(operationId: CliOperationId) {
});
}

// ---------------------------------------------------------------------------
// Table scenario helpers (DRY builders for the 40 table operations)
// ---------------------------------------------------------------------------

/** Creates a table in a session and runs a table mutation operation on it. */
function tableMutationScenario(
op: string,
extraArgs: string[],
): (harness: ConformanceHarness) => Promise<ScenarioInvocation> {
return async (harness) => {
const label = `table-${op.replace(/\./g, '-')}`;
const stateDir = await harness.createStateDir(`${label}-success`);
const { tableNodeId, sessionId } = await harness.createTableFixture(stateDir, label);
return {
stateDir,
args: [
...commandTokens(`doc.${op}` as CliOperationId),
'--session',
sessionId,
'--node-id',
tableNodeId,
...extraArgs,
'--out',
harness.createOutputPath(`${label}-out`),
],
};
};
}

/** Creates a table in a session and runs a table read operation on it. */
function tableReadScenario(
op: string,
extraArgs: string[] = [],
): (harness: ConformanceHarness) => Promise<ScenarioInvocation> {
return async (harness) => {
const label = `table-${op.replace(/\./g, '-')}`;
const stateDir = await harness.createStateDir(`${label}-success`);
const { tableNodeId, sessionId } = await harness.createTableFixture(stateDir, label);
return {
stateDir,
args: [
...commandTokens(`doc.${op}` as CliOperationId),
'--session',
sessionId,
'--node-id',
tableNodeId,
...extraArgs,
],
};
};
}

/** Creates a table in a session and runs a cell-level mutation on it using --node-id with cellNodeId. */
function cellMutationScenario(
op: string,
extraArgs: string[],
): (harness: ConformanceHarness) => Promise<ScenarioInvocation> {
return async (harness) => {
const label = `table-${op.replace(/\./g, '-')}`;
const stateDir = await harness.createStateDir(`${label}-success`);
const { cellNodeId, sessionId } = await harness.createTableFixture(stateDir, label);
return {
stateDir,
args: [
...commandTokens(`doc.${op}` as CliOperationId),
'--session',
sessionId,
'--node-id',
cellNodeId,
...extraArgs,
'--out',
harness.createOutputPath(`${label}-out`),
],
};
};
}

/** Table-scoped mutation in a session: uses --table-node-id instead of --node-id. */
function tableScopedMutationScenario(
op: string,
extraArgs: string[],
): (harness: ConformanceHarness) => Promise<ScenarioInvocation> {
return async (harness) => {
const label = `table-${op.replace(/\./g, '-')}`;
const stateDir = await harness.createStateDir(`${label}-success`);
const { tableNodeId, sessionId } = await harness.createTableFixture(stateDir, label);
return {
stateDir,
args: [
...commandTokens(`doc.${op}` as CliOperationId),
'--session',
sessionId,
'--table-node-id',
tableNodeId,
...extraArgs,
'--out',
harness.createOutputPath(`${label}-out`),
],
};
};
}

export const SUCCESS_SCENARIOS = {
'doc.open': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('doc-open-success');
Expand Down Expand Up @@ -705,6 +807,156 @@ export const SUCCESS_SCENARIOS = {
args: ['session', 'set-default', '--session', 'session-default-success'],
};
},

// ---------------------------------------------------------------------------
// Table operations
// ---------------------------------------------------------------------------

'doc.create.table': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('create-table-success');
const docPath = await harness.copyFixtureDoc('create-table');
return {
stateDir,
args: [
'create',
'table',
docPath,
'--rows',
'3',
'--columns',
'3',
'--out',
harness.createOutputPath('create-table-out'),
],
};
},
'doc.tables.convertFromText': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const label = 'table-convertFromText';
const stateDir = await harness.createStateDir(`${label}-success`);
const { sessionId } = await harness.createTableFixture(stateDir, label);
// convertFromText targets a paragraph, not a table — find the first paragraph in the session
const { result, envelope } = await harness.runCli(
['find', '--session', sessionId, '--type', 'node', '--node-type', 'paragraph', '--limit', '1'],
stateDir,
);
if (result.code !== 0 || envelope.ok !== true) {
throw new Error('Failed to find paragraph for convertFromText conformance scenario.');
}
const paraNodeId = (envelope.data as { result?: { items?: Array<{ address?: { nodeId?: string } }> } }).result
?.items?.[0]?.address?.nodeId;
if (!paraNodeId) throw new Error('No paragraph found for convertFromText scenario.');
return {
stateDir,
args: [
...commandTokens('doc.tables.convertFromText'),
'--session',
sessionId,
'--node-id',
paraNodeId,
'--delimiter-json',
JSON.stringify('tab'),
'--out',
harness.createOutputPath(`${label}-out`),
],
};
},
'doc.tables.delete': tableMutationScenario('tables.delete', []),
'doc.tables.clearContents': tableMutationScenario('tables.clearContents', []),
'doc.tables.move': tableMutationScenario('tables.move', [
'--destination-json',
JSON.stringify({ kind: 'documentEnd' }),
]),
'doc.tables.split': tableMutationScenario('tables.split', ['--at-row-index', '1']),
'doc.tables.convertToText': tableMutationScenario('tables.convertToText', ['--delimiter', 'tab']),
'doc.tables.setLayout': tableMutationScenario('tables.setLayout', ['--alignment', 'center']),
'doc.tables.insertRow': tableScopedMutationScenario('tables.insertRow', ['--row-index', '0', '--position', 'below']),
'doc.tables.deleteRow': tableScopedMutationScenario('tables.deleteRow', ['--row-index', '0']),
'doc.tables.setRowHeight': tableScopedMutationScenario('tables.setRowHeight', [
'--row-index',
'0',
'--height-pt',
'36',
'--rule',
'atLeast',
]),
'doc.tables.distributeRows': tableMutationScenario('tables.distributeRows', []),
'doc.tables.setRowOptions': tableScopedMutationScenario('tables.setRowOptions', [
'--row-index',
'0',
'--allow-break-across-pages',
]),
'doc.tables.insertColumn': tableScopedMutationScenario('tables.insertColumn', [
'--column-index',
'0',
'--position',
'right',
]),
'doc.tables.deleteColumn': tableScopedMutationScenario('tables.deleteColumn', ['--column-index', '0']),
'doc.tables.setColumnWidth': tableScopedMutationScenario('tables.setColumnWidth', [
'--column-index',
'0',
'--width-pt',
'72',
]),
'doc.tables.distributeColumns': tableMutationScenario('tables.distributeColumns', []),
'doc.tables.insertCell': cellMutationScenario('tables.insertCell', ['--mode', 'shiftRight']),
'doc.tables.deleteCell': cellMutationScenario('tables.deleteCell', ['--mode', 'shiftLeft']),
'doc.tables.mergeCells': tableScopedMutationScenario('tables.mergeCells', [
'--start-json',
JSON.stringify({ rowIndex: 0, columnIndex: 0 }),
'--end-json',
JSON.stringify({ rowIndex: 0, columnIndex: 1 }),
]),
'doc.tables.unmergeCells': cellMutationScenario('tables.unmergeCells', []),
'doc.tables.splitCell': cellMutationScenario('tables.splitCell', ['--rows', '2', '--columns', '1']),
'doc.tables.setCellProperties': cellMutationScenario('tables.setCellProperties', ['--vertical-align', 'center']),
'doc.tables.sort': tableMutationScenario('tables.sort', [
'--keys-json',
JSON.stringify([{ columnIndex: 0, direction: 'ascending', type: 'text' }]),
]),
'doc.tables.setAltText': tableMutationScenario('tables.setAltText', ['--title', 'Test Table']),
'doc.tables.setStyle': tableMutationScenario('tables.setStyle', ['--style-id', 'TableGrid']),
'doc.tables.clearStyle': tableMutationScenario('tables.clearStyle', []),
'doc.tables.setStyleOption': tableMutationScenario('tables.setStyleOption', ['--flag', 'headerRow', '--enabled']),
'doc.tables.setBorder': tableMutationScenario('tables.setBorder', [
'--edge',
'top',
'--line-style',
'single',
'--line-weight-pt',
'1',
'--color',
'000000',
]),
'doc.tables.clearBorder': tableMutationScenario('tables.clearBorder', ['--edge', 'top']),
'doc.tables.applyBorderPreset': tableMutationScenario('tables.applyBorderPreset', ['--preset', 'all']),
'doc.tables.setShading': tableMutationScenario('tables.setShading', ['--color', 'FF0000']),
'doc.tables.clearShading': tableMutationScenario('tables.clearShading', []),
'doc.tables.setTablePadding': tableMutationScenario('tables.setTablePadding', [
'--top-pt',
'5',
'--right-pt',
'5',
'--bottom-pt',
'5',
'--left-pt',
'5',
]),
'doc.tables.setCellPadding': cellMutationScenario('tables.setCellPadding', [
'--top-pt',
'5',
'--right-pt',
'5',
'--bottom-pt',
'5',
'--left-pt',
'5',
]),
'doc.tables.setCellSpacing': tableMutationScenario('tables.setCellSpacing', ['--spacing-pt', '2']),
'doc.tables.clearCellSpacing': tableMutationScenario('tables.clearCellSpacing', []),
'doc.tables.get': tableReadScenario('tables.get'),
'doc.tables.getCells': tableReadScenario('tables.getCells'),
'doc.tables.getProperties': tableReadScenario('tables.getProperties'),
} as const satisfies Record<CliOperationId, (harness: ConformanceHarness) => Promise<ScenarioInvocation>>;

export const OPERATION_SCENARIOS = (Object.keys(SUCCESS_SCENARIOS) as CliOperationId[]).map((operationId) => {
Expand Down
Loading
Loading