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
3 changes: 3 additions & 0 deletions .github/workflows/ci-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ jobs:
with:
python-version: '3.12'

- name: Install Python test dependencies
run: python3 -m pip install pytest pytest-asyncio

- name: Install dependencies
run: pnpm install --frozen-lockfile

Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci-superdoc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,12 @@ jobs:
- name: Build superdoc (CLI runtime dependency)
run: pnpm run build:superdoc

- name: Generate SDK sources (gitignored generated client)
run: pnpm run generate:all

- name: Build Node SDK (CLI preset ops dependency)
run: pnpm --prefix packages/sdk/langs/node run build

- name: Run CLI tests
run: pnpm run test:cli

Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ tests/consumer-typecheck/tsconfig.matrix.json

# Markdown files
*.md
packages/sdk/langs/node/src/embedded-prompts.generated.ts
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ Before adding a visual or direction-aware path, run:

```bash
# Painter must not import upstream packages or the concrete v1 adapter.
rg "@superdoc/(super-editor|style-engine|layout-bridge|layout-resolved)" packages/layout-engine/painters/dom/src
# Runtime sources only; test files may import @superdoc/layout-resolved.
rg "@superdoc/(super-editor|style-engine|layout-bridge|layout-resolved)" packages/layout-engine/painters/dom/src -g '!*.test.ts' -g '!_test-utils.ts'
```

More checks in `packages/layout-engine/AGENTS.md`.
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"predev": "node scripts/ensure-superdoc-build.js",
"dev": "bun run src/index.ts",
"prebuild": "node scripts/ensure-superdoc-build.js",
"build": "bun build src/index.ts --outdir dist --target node --format esm && node scripts/copy-runtime-assets.js",
"build": "bun build src/index.ts --outdir dist --target node --format esm && node scripts/copy-runtime-assets.js && rm -rf dist/prompts && mkdir -p dist/prompts && cp ../../packages/sdk/langs/node/src/prompts/*.md dist/prompts/",
"prebuild:native": "node scripts/ensure-superdoc-build.js",
"build:native": "bun build src/index.ts --compile --outfile dist/superdoc && node scripts/copy-runtime-assets.js",
"build:native:all": "node scripts/build-native-cli.js --all",
Expand Down Expand Up @@ -45,6 +45,7 @@
"yjs": "catalog:"
},
"devDependencies": {
"@superdoc-dev/sdk": "workspace:*",
"@superdoc/document-api": "workspace:*",
"@superdoc/super-editor": "workspace:*",
"@types/bun": "catalog:",
Expand Down
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 @@ -15,8 +15,14 @@ import { writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { tmpdir } from 'node:os';

// This build script deliberately reaches into document-api SOURCE (it runs
// pre-build, and the package's `exports` map exposes neither ./src/* nor
// ./scripts/*, so the alias form the lint rule suggests would not resolve).
// eslint-disable-next-line import-x/no-relative-packages
import { COMMAND_CATALOG } from '../../../packages/document-api/src/contract/command-catalog.ts';
// eslint-disable-next-line import-x/no-relative-packages
import { INTENT_GROUP_META } from '../../../packages/document-api/src/contract/operation-definitions.ts';
// eslint-disable-next-line import-x/no-relative-packages
import { buildContractSnapshot } from '../../../packages/document-api/scripts/lib/contract-snapshot.ts';
import { ensureDocumentApiBuild } from './ensure-superdoc-build.js';

Expand Down
53 changes: 53 additions & 0 deletions apps/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2352,6 +2352,59 @@ describe('superdoc CLI', () => {
expect(closeResult.code).toBe(0);
});

test('expected revision protects execute code and preset dispatch', async () => {
await runCli(['open', SAMPLE_DOC, '--session', 'llm-guard']);

const advance = await runCli([
'execute',
'code',
'--session',
'llm-guard',
'--code',
"doc.create.paragraph({ text: 'REVISION_GUARD_BASELINE' }); return 'ok';",
]);
expect(advance.code).toBe(0);

const staleExecute = await runCli([
'execute',
'code',
'--session',
'llm-guard',
'--expected-revision',
'0',
'--code',
"doc.create.paragraph({ text: 'STALE_EXECUTE_CODE' }); return 'ok';",
]);
expect(staleExecute.code).toBe(1);
expect(parseJsonOutput<ErrorEnvelope>(staleExecute).error.code).toBe('REVISION_MISMATCH');

const stalePreset = await runCli([
'preset',
'dispatch',
'--session',
'llm-guard',
'--preset',
'core',
'--tool-name',
'superdoc_perform_action',
'--args-json',
JSON.stringify({ action: 'insert_paragraphs', text: 'STALE_PRESET_DISPATCH' }),
'--expected-revision',
'0',
]);
expect(stalePreset.code).toBe(1);
expect(parseJsonOutput<ErrorEnvelope>(stalePreset).error.code).toBe('REVISION_MISMATCH');

const textResult = await runCli(['get-text', '--session', 'llm-guard']);
expect(textResult.code).toBe(0);
expect(textResult.stdout).toContain('REVISION_GUARD_BASELINE');
expect(textResult.stdout).not.toContain('STALE_EXECUTE_CODE');
expect(textResult.stdout).not.toContain('STALE_PRESET_DISPATCH');

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

test('session use switches default session', async () => {
const alphaOpen = await runCli(['open', SAMPLE_DOC, '--session', 'alpha']);
expect(alphaOpen.code).toBe(0);
Expand Down
58 changes: 58 additions & 0 deletions apps/cli/src/__tests__/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,64 @@ describe('CLI host mode', () => {
HOST_TEST_TIMEOUT_MS,
);

test(
'host request timeout blocks delayed execute-code mutations',
async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), 'superdoc-host-test-'));
cleanup.push(stateDir);
await mkdir(stateDir, { recursive: true });

const docCopy = path.join(stateDir, 'doc.docx');
await copyFile(await resolveSourceDocFixture(), docCopy);

const host = launchHost(stateDir, ['--request-timeout-ms', '1000']);
const sessionId = 'timeout-guard';
const marker = 'HOST_TIMEOUT_LATE_MUTATION';

const open = await host.request('cli.invoke', {
argv: ['open', docCopy, '--session', sessionId],
stdinBase64: '',
});
expect(open.error).toBeUndefined();

const delayedMutation = await host.request('cli.invoke', {
argv: [
'preset',
'dispatch',
'--session',
sessionId,
'--preset',
'core',
'--tool-name',
'superdoc_execute_code',
'--args-json',
JSON.stringify({
code: `await new Promise((resolve) => setTimeout(resolve, 1200)); doc.create.paragraph({ text: '${marker}' }); return 'late';`,
}),
],
stdinBase64: '',
});
if (delayedMutation.error) {
expect(delayedMutation.error.code).toBe(-32011);
} else {
const payload = delayedMutation.result as { data?: { ok?: boolean; error?: { message?: string } } };
expect(payload.data?.ok).toBe(false);
}

await new Promise((resolve) => setTimeout(resolve, 500));

const text = await host.request('cli.invoke', {
argv: ['get-text', '--session', sessionId],
stdinBase64: '',
});
expect(text.error).toBeUndefined();
expect(JSON.stringify(text.result)).not.toContain(marker);

await host.shutdown();
},
HOST_TEST_TIMEOUT_MS,
);

test(
'rejects --request-timeout-ms with a non-numeric value',
async () => {
Expand Down
128 changes: 128 additions & 0 deletions apps/cli/src/__tests__/lib/execute-code-rollback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, expect, test } from 'bun:test';
import { executeCodeWithRollback } from '../../lib/execute-code-rollback';
import { runPresetDispatch } from '../../lib/preset-ops';
import type { EditorWithDoc } from '../../lib/document';

/**
* Fake v1 editor: a doc with a real revision counter plus the ProseMirror-ish
* state/dispatch surface the rollback envelope uses (state.doc snapshot,
* state.tr.replaceWith, dispatch). Restoring bumps the revision like a real
* editor transaction would — the envelope must normalize that away.
*/
function makeFakeEditor() {
let revision = 0;
let content = 'ORIGINAL';
const doc = {
info: () => ({ revision: String(revision) }),
mutate: (next: string) => {
content = next;
revision += 1;
return { ok: true };
},
};
const snapshot = { content: { size: 10, restoreTo: 'ORIGINAL' } };
const editor = {
doc,
state: {
doc: snapshot,
get tr() {
return {
replaceWith: (_from: number, _to: number, restored: { restoreTo: string }) => ({ restored }),
};
},
},
dispatch: (tr: { restored: { restoreTo: string } }) => {
content = tr.restored.restoreTo;
revision += 1; // a restore transaction still advances the editor revision
},
};
return {
editor: editor as unknown as EditorWithDoc,
getContent: () => content,
};
}

describe('executeCodeWithRollback', () => {
test('crash after mutation restores the document and does not count as mutated', async () => {
const { editor, getContent } = makeFakeEditor();
const outcome = await executeCodeWithRollback(editor, "doc.mutate('BROKEN'); throw new Error('boom');");
expect(outcome.result.ok).toBe(false);
expect(outcome.result.rolledBack).toBe(true);
expect(outcome.mutated).toBe(false);
expect(outcome.revisionAfter).toBe(outcome.revisionBefore);
expect(getContent()).toBe('ORIGINAL');
});

test('successful script keeps its mutations and reports mutated', async () => {
const { editor, getContent } = makeFakeEditor();
const outcome = await executeCodeWithRollback(editor, "doc.mutate('CHANGED'); return 'done';");
expect(outcome.result.ok).toBe(true);
expect(outcome.result.rolledBack).toBeUndefined();
expect(outcome.mutated).toBe(true);
expect(getContent()).toBe('CHANGED');
});

test('read-only script mutates nothing', async () => {
const { editor, getContent } = makeFakeEditor();
const outcome = await executeCodeWithRollback(editor, 'return doc.info().revision;');
expect(outcome.result.ok).toBe(true);
expect(outcome.mutated).toBe(false);
expect(getContent()).toBe('ORIGINAL');
});

test('timeout blocks late document calls from mutating the session', async () => {
const { editor, getContent } = makeFakeEditor();
const outcome = await executeCodeWithRollback(
editor,
"await new Promise((resolve) => setTimeout(resolve, 25)); doc.mutate('LATE'); return 'late';",
{ timeoutMs: 5 },
);

expect(outcome.result.ok).toBe(false);
expect(outcome.mutated).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 40));
expect(getContent()).toBe('ORIGINAL');
});

test('delayed work after a successful return cannot mutate the session', async () => {
const { editor, getContent } = makeFakeEditor();
const outcome = await executeCodeWithRollback(
editor,
"setTimeout(() => doc.mutate('LATE'), 25); return 'scheduled';",
{ timeoutMs: 100 },
);

expect(outcome.result.ok).toBe(true);
expect(outcome.mutated).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 40));
expect(getContent()).toBe('ORIGINAL');
});
});

describe('preset dispatch superdoc_execute_code (shim parity)', () => {
test('a crashing script dispatched through the core preset is rolled back', async () => {
const { editor, getContent } = makeFakeEditor();
const result = (await runPresetDispatch(
'core',
'superdoc_execute_code',
{ code: "doc.mutate('BROKEN'); throw new Error('boom');" },
editor,
)) as { ok?: boolean; rolledBack?: boolean };
expect(result.ok).toBe(false);
expect(result.rolledBack).toBe(true);
// Reviewer repro (PR #264): the paragraph used to survive the crash.
expect(getContent()).toBe('ORIGINAL');
});

test('a successful script dispatched through the core preset keeps its edits', async () => {
const { editor, getContent } = makeFakeEditor();
const result = (await runPresetDispatch(
'core',
'superdoc_execute_code',
{ code: "doc.mutate('CHANGED'); return 'ok';" },
editor,
)) as { ok?: boolean };
expect(result.ok).toBe(true);
expect(getContent()).toBe('CHANGED');
});
});
18 changes: 18 additions & 0 deletions apps/cli/src/__tests__/lib/manual-command-allowlist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,20 @@ describe('manual command allowlist', () => {
'close',
'insert tab',
'insert line-break',
// CLI/SDK-only session op: model-authored JS against the live editor.doc.
'execute code',
'session list',
'session save',
'session close',
'session set-default',
'session use',
// LLM-tools preset proxies (cross-language surface for the Python SDK).
'preset list',
'preset get-catalog',
'preset get-tools',
'preset get-system-prompt',
'preset get-mcp-prompt',
'preset dispatch',
]);
});

Expand All @@ -28,10 +37,17 @@ describe('manual command allowlist', () => {
'doc.close',
'doc.insertTab',
'doc.insertLineBreak',
'doc.executeCode',
'doc.session.list',
'doc.session.save',
'doc.session.close',
'doc.session.setDefault',
'doc.preset.list',
'doc.preset.getCatalog',
'doc.preset.getTools',
'doc.preset.getSystemPrompt',
'doc.preset.getMcpPrompt',
'doc.preset.dispatch',
]);
});

Expand All @@ -44,10 +60,12 @@ describe('manual command allowlist', () => {
expect(actual).toEqual([
'call.ts',
'close.ts',
'execute-code.ts',
'insert-inline-special.ts',
'install.ts',
'legacy-compat.ts',
'open.ts',
'preset.ts',
'save.ts',
'session-close.ts',
'session-list.ts',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe('operation runtime metadata', () => {
expect(openOptions.map((o) => o.name)).not.toContain('runtime');
});

test('final recipe-provider parity operations expose their promoted CLI params', () => {
test('final action-provider parity operations expose their promoted CLI params', () => {
const blocksListMeta = CLI_OPERATION_METADATA['doc.blocks.list'];
expect(blocksListMeta.params.find((p) => p.name === 'in')?.flag).toBe('in-json');

Expand Down
Loading
Loading