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
147 changes: 134 additions & 13 deletions apps/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,11 +279,11 @@ describe('superdoc CLI', () => {
expect(result.stdout).not.toContain('<doc> Document path or stdin');
});

test('describe command doc.insert includes --target and --text flags', async () => {
test('describe command doc.insert includes --target and --value flags', async () => {
const result = await runCli(['describe', 'command', 'doc.insert', '--output', 'pretty']);
expect(result.code).toBe(0);
expect(result.stdout).toContain('--target');
expect(result.stdout).toContain('--text');
expect(result.stdout).toContain('--value');
});

test('call executes an operation from canonical input payload', async () => {
Expand Down Expand Up @@ -501,7 +501,7 @@ describe('superdoc CLI', () => {
'--input-json',
JSON.stringify({
doc: source,
text: 'CALL_INSERT_TOKEN_1597',
value: 'CALL_INSERT_TOKEN_1597',
out,
}),
]);
Expand Down Expand Up @@ -838,7 +838,7 @@ describe('superdoc CLI', () => {
insertSource,
'--target-json',
JSON.stringify(collapsedTarget),
'--text',
'--value',
'CLI_INSERT_TOKEN_1597',
'--out',
insertOut,
Expand All @@ -861,7 +861,7 @@ describe('superdoc CLI', () => {
const insertResult = await runCli([
'insert',
insertSource,
'--text',
'--value',
'CLI_DEFAULT_INSERT_TOKEN_1597',
'--out',
insertOut,
Expand Down Expand Up @@ -911,7 +911,7 @@ describe('superdoc CLI', () => {
const insertResult = await runCli([
'insert',
blankFirstOut,
'--text',
'--value',
'CLI_BLANK_INSERT_TOKEN_1597',
'--out',
insertOut,
Expand Down Expand Up @@ -956,7 +956,7 @@ describe('superdoc CLI', () => {
target.blockId,
'--offset',
'0',
'--text',
'--value',
'CLI_BLOCKID_OFFSET_INSERT_1597',
'--out',
insertOut,
Expand Down Expand Up @@ -989,7 +989,7 @@ describe('superdoc CLI', () => {
insertSource,
'--block-id',
target.blockId,
'--text',
'--value',
'CLI_BLOCKID_ONLY_INSERT_1597',
'--out',
insertOut,
Expand All @@ -1012,7 +1012,16 @@ describe('superdoc CLI', () => {
const insertOut = join(TEST_DIR, 'insert-offset-no-blockid-out.docx');
await copyFile(SAMPLE_DOC, insertSource);

const result = await runCli(['insert', insertSource, '--offset', '5', '--text', 'should-fail', '--out', insertOut]);
const result = await runCli([
'insert',
insertSource,
'--offset',
'5',
'--value',
'should-fail',
'--out',
insertOut,
]);

expect(result.code).toBe(1);
const envelope = parseJsonOutput<ErrorEnvelope>(result);
Expand Down Expand Up @@ -1208,7 +1217,7 @@ describe('superdoc CLI', () => {
deleteSource,
'--target-json',
JSON.stringify(collapsedTarget),
'--text',
'--value',
'CLI_DELETE_TOKEN_1597',
'--out',
insertedOut,
Expand Down Expand Up @@ -1661,7 +1670,7 @@ describe('superdoc CLI', () => {
const openResult = await runCli(['open', SAMPLE_DOC]);
expect(openResult.code).toBe(0);

const insertResult = await runCli(['insert', '--text', 'STATEFUL_DEFAULT_INSERT_1597']);
const insertResult = await runCli(['insert', '--value', 'STATEFUL_DEFAULT_INSERT_1597']);
expect(insertResult.code).toBe(0);

const insertEnvelope = parseJsonOutput<
Expand Down Expand Up @@ -1690,7 +1699,7 @@ describe('superdoc CLI', () => {

const insertResult = await runCli([
'insert',
'--text',
'--value',
'STATEFUL_INSERT_EXPORT_FAILURE_1597',
'--out',
blockedOutPath,
Expand Down Expand Up @@ -1938,7 +1947,7 @@ describe('superdoc CLI', () => {
test('session save persists a specific session and keeps it open', async () => {
await runCli(['open', SAMPLE_DOC, '--session', 'alpha']);

const insertResult = await runCli(['insert', '--session', 'alpha', '--text', 'SESSION_SAVE_TOKEN_1597']);
const insertResult = await runCli(['insert', '--session', 'alpha', '--value', 'SESSION_SAVE_TOKEN_1597']);
expect(insertResult.code).toBe(0);

const savedOut = join(TEST_DIR, 'session-save-alpha.docx');
Expand Down Expand Up @@ -1997,4 +2006,116 @@ describe('superdoc CLI', () => {
const findEnvelope = parseJsonOutput<ErrorEnvelope>(findResult);
expect(findEnvelope.error.code).toBe('PROJECT_CONTEXT_MISMATCH');
});

// -- open --content-override / --override-type validation --

test('open rejects --content-override without --override-type', async () => {
const result = await runCli(['open', SAMPLE_DOC, '--content-override', '# Hello']);
expect(result.code).toBe(1);
const envelope = parseJsonOutput<ErrorEnvelope>(result);
expect(envelope.error.code).toBe('INVALID_ARGUMENT');
expect(envelope.error.message).toContain('--override-type');
});

test('open rejects --override-type without --content-override', async () => {
const result = await runCli(['open', SAMPLE_DOC, '--override-type', 'markdown']);
expect(result.code).toBe(1);
const envelope = parseJsonOutput<ErrorEnvelope>(result);
expect(envelope.error.code).toBe('INVALID_ARGUMENT');
expect(envelope.error.message).toContain('--content-override');
});

test('open rejects invalid --override-type value', async () => {
const result = await runCli(['open', SAMPLE_DOC, '--content-override', 'x', '--override-type', 'xml']);
expect(result.code).toBe(1);
const envelope = parseJsonOutput<ErrorEnvelope>(result);
expect(envelope.error.code).toBe('INVALID_ARGUMENT');
expect(envelope.error.message).toContain('markdown, html, text');
});

test('open with --override-type text applies content semantically', async () => {
const openResult = await runCli([
'open',
SAMPLE_DOC,
'--content-override',
'Override text content',
'--override-type',
'text',
]);
expect(openResult.code).toBe(0);

// Verify the override text is actually present in the document
const findResult = await runCli(['find', '--type', 'text', '--pattern', 'Override text content']);
expect(findResult.code).toBe(0);
const findEnvelope = parseJsonOutput<SuccessEnvelope<{ result: { total: number } }>>(findResult);
expect(findEnvelope.data.result.total).toBeGreaterThan(0);

const closeResult = await runCli(['close', '--discard']);
expect(closeResult.code).toBe(0);
});

test('open with --override-type text preserves leading whitespace literally', async () => {
const literalText = ' foo';

const openResult = await runCli(['open', SAMPLE_DOC, '--content-override', literalText, '--override-type', 'text']);
expect(openResult.code).toBe(0);

const findResult = await runCli(['find', '--type', 'text', '--pattern', literalText]);
expect(findResult.code).toBe(0);
const findEnvelope = parseJsonOutput<SuccessEnvelope<{ result: { total: number } }>>(findResult);
expect(findEnvelope.data.result.total).toBeGreaterThan(0);

const closeResult = await runCli(['close', '--discard']);
expect(closeResult.code).toBe(0);
});

test('open with --override-type markdown applies content semantically', async () => {
const openResult = await runCli([
'open',
SAMPLE_DOC,
'--content-override',
'# Markdown Override Heading',
'--override-type',
'markdown',
]);
expect(openResult.code).toBe(0);

// Verify the markdown content is present in the document
const findResult = await runCli(['find', '--type', 'text', '--pattern', 'Markdown Override Heading']);
expect(findResult.code).toBe(0);
const findEnvelope = parseJsonOutput<SuccessEnvelope<{ result: { total: number } }>>(findResult);
expect(findEnvelope.data.result.total).toBeGreaterThan(0);

const closeResult = await runCli(['close', '--discard']);
expect(closeResult.code).toBe(0);
});

test('open with --override-type html rejects in headless CLI', async () => {
const openResult = await runCli([
'open',
SAMPLE_DOC,
'--content-override',
'<p>HTML Override</p>',
'--override-type',
'html',
]);
expect(openResult.code).toBe(1);
const envelope = parseJsonOutput<ErrorEnvelope>(openResult);
expect(envelope.error.code).toBe('UNSUPPORTED_FORMAT');
expect(envelope.error.message).toContain('HTML');
});

test('open with --content-override empty string is accepted (not silently ignored)', async () => {
const openResult = await runCli(['open', SAMPLE_DOC, '--content-override', '', '--override-type', 'text']);
expect(openResult.code).toBe(0);

// Verify original document content was replaced (find for known original text should fail)
const findOriginal = await runCli(['find', '--type', 'text', '--pattern', 'Wilde']);
expect(findOriginal.code).toBe(0);
const findEnvelope = parseJsonOutput<SuccessEnvelope<{ result: { total: number } }>>(findOriginal);
expect(findEnvelope.data.result.total).toBe(0);

const closeResult = await runCli(['close', '--discard']);
expect(closeResult.code).toBe(0);
});
});
2 changes: 1 addition & 1 deletion apps/cli/src/__tests__/conformance/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ export class ConformanceHarness {
sourceDoc,
'--target-json',
JSON.stringify(collapsedTarget),
'--text',
'--value',
'TRACKED_CONFORMANCE_TOKEN',
'--change-mode',
'tracked',
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/__tests__/conformance/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ export const SUCCESS_SCENARIOS = {
docPath,
'--target-json',
JSON.stringify(collapsed),
'--text',
'--value',
'CONFORMANCE_INSERT',
'--out',
harness.createOutputPath('doc-insert-output'),
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/__tests__/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ describe('CLI host mode', () => {
docPath,
'--target-json',
JSON.stringify(collapsedTarget),
'--text',
'--value',
'HOST_CONFORMANCE_INSERT',
'--out',
path.join(stateDir, 'host-conformance-insert.docx'),
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/__tests__/lib/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ describe('validateCreateParagraphInput', () => {

expect(result.at).toEqual({
kind: 'before',
nodeId: 'p1',
target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' },
});
});

Expand All @@ -156,7 +156,7 @@ describe('validateCreateParagraphInput', () => {

expect(result.at).toEqual({
kind: 'after',
nodeId: 'p2',
target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p2' },
});
});

Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/cli/cli-only-operation-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export interface CliOnlyOperationDefinition {
export const CLI_ONLY_OPERATION_DEFINITIONS: Record<CliOnlyOperation, CliOnlyOperationDefinition> = {
open: {
category: 'lifecycle',
description: 'Open a document and create a persistent editing session.',
description:
'Open a document and create a persistent editing session. Optionally override the document body with contentOverride + overrideType (markdown, html, or text).',
requiresDocumentContext: false,
intentName: 'open_document',
sdkMetadata: { mutates: false, idempotency: 'non-idempotent', supportsTrackedMode: false, supportsDryRun: false },
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/cli/operation-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,8 @@ const CLI_ONLY_METADATA: Record<CliOnlyOperationId, CliOperationMetadata> = {
{ name: 'collaboration', kind: 'jsonFlag', flag: 'collaboration-json', type: 'json' },
{ name: 'collabDocumentId', kind: 'flag', flag: 'collab-document-id', type: 'string' },
{ name: 'collabUrl', kind: 'flag', flag: 'collab-url', type: 'string' },
{ name: 'contentOverride', kind: 'flag', flag: 'content-override', type: 'string' },
{ name: 'overrideType', kind: 'flag', flag: 'override-type', type: 'string' },
],
constraints: null,
},
Expand Down
45 changes: 44 additions & 1 deletion apps/cli/src/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { parseOperationArgs } from '../lib/operation-args';
import { generateSessionId } from '../lib/session';
import type { CommandContext, CommandExecution } from '../lib/types';

const VALID_OVERRIDE_TYPES = new Set(['markdown', 'html', 'text']);

export async function runOpen(tokens: string[], context: CommandContext): Promise<CommandExecution> {
const { parsed, help } = parseOperationArgs('doc.open', tokens, {
commandName: 'open',
Expand All @@ -28,12 +30,14 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis
data: {
usage: [
'superdoc open [doc] [--session <id>]',
'superdoc open [doc] --content-override <content> --override-type <markdown|html|text>',
'superdoc open [doc] --collaboration-json "{...}" [--session <id>]',
],
},
pretty: [
'Usage:',
' superdoc open [doc] [--session <id>]',
' superdoc open [doc] --content-override <content> --override-type <markdown|html|text>',
' superdoc open [doc] --collaboration-json "{...}" [--session <id>]',
].join('\n'),
};
Expand All @@ -45,6 +49,23 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis
const collaborationPayload = await resolveJsonInput(parsed, 'collaboration');
const collabUrl = getStringOption(parsed, 'collab-url');
const collabDocumentId = getStringOption(parsed, 'collab-document-id');
const contentOverride = getStringOption(parsed, 'content-override');
const overrideType = getStringOption(parsed, 'override-type');

// Validate contentOverride / overrideType co-requirement.
// Use != null checks so that intentional empty-string overrides are honored.
if (contentOverride != null && !overrideType) {
throw new CliError('INVALID_ARGUMENT', 'open: --content-override requires --override-type.');
}
if (overrideType && contentOverride == null) {
throw new CliError('INVALID_ARGUMENT', 'open: --override-type requires --content-override.');
}
if (overrideType && !VALID_OVERRIDE_TYPES.has(overrideType)) {
throw new CliError(
'INVALID_ARGUMENT',
`open: --override-type must be one of: markdown, html, text. Got "${overrideType}".`,
);
}

if (collaborationPayload != null && (collabUrl || collabDocumentId)) {
throw new CliError(
Expand All @@ -53,6 +74,14 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis
);
}

// Content override is incompatible with collaboration mode
if (contentOverride != null && (collaborationPayload != null || collabUrl)) {
throw new CliError(
'INVALID_ARGUMENT',
'open: --content-override is incompatible with collaboration mode. Content override is a template-initialization operation.',
);
}

let collaborationInput;
if (collaborationPayload != null) {
collaborationInput = parseCollaborationInput(collaborationPayload);
Expand All @@ -69,6 +98,20 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis
const collaboration = collaborationInput ? resolveCollaborationProfile(collaborationInput, sessionId) : undefined;
const sessionType = collaboration ? 'collab' : 'local';

// Build editor open options from override params
const editorOpenOptions: Record<string, string> = {};
if (contentOverride != null && overrideType) {
if (overrideType === 'markdown') {
editorOpenOptions.markdown = contentOverride;
} else if (overrideType === 'html') {
editorOpenOptions.html = contentOverride;
} else if (overrideType === 'text') {
// Plain text bypass — handed off to document.ts which builds PM
// paragraphs directly, preserving all whitespace without markdown parsing.
editorOpenOptions.plainText = contentOverride;
}
}

return withContextLock(
context.io,
'open',
Expand Down Expand Up @@ -104,7 +147,7 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis

const opened = collaboration
? await openCollaborativeDocument(doc!, context.io, collaboration)
: await openDocument(doc, context.io);
: await openDocument(doc, context.io, { editorOpenOptions });
let adoptedToHostPool = false;
try {
const output = await exportToPath(opened.editor, paths.workingDocPath, true);
Expand Down
Loading
Loading