From d8cba5eea0d5895e335e624f5d5974bf8e21f0de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 27 May 2026 16:53:07 +0000 Subject: [PATCH] test(cli): add JSON escaping tests for step show output (#557) Add comprehensive tests verifying that `uwf step show` produces valid JSON output even when step detail nodes contain control characters (newlines, tabs, carriage returns, etc.) in tool call args and content fields. Tests cover: - Basic control characters (newlines, tabs, CR+LF) - Backslashes and quotes - Unicode control characters (U+0001-U+001F) - Nested CAS refs with control characters - Large steps with multiple tool calls - Empty/null values - YAML output format (unaffected by escaping) The tests confirm that JSON.stringify() already handles control character escaping correctly when serializing JavaScript objects to JSON. No code changes needed - these tests serve as regression guards to ensure the behavior remains correct. Fixes #557 Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/step-show-json.test.ts | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 packages/cli-workflow/src/__tests__/step-show-json.test.ts diff --git a/packages/cli-workflow/src/__tests__/step-show-json.test.ts b/packages/cli-workflow/src/__tests__/step-show-json.test.ts new file mode 100644 index 0000000..19a8206 --- /dev/null +++ b/packages/cli-workflow/src/__tests__/step-show-json.test.ts @@ -0,0 +1,363 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { bootstrap, type Hash, type JSONSchema, putSchema } from "@uncaged/json-cas"; +import { createFsStore } from "@uncaged/json-cas-fs"; +import type { CasRef, StepNodePayload } from "@uncaged/workflow-protocol"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { cmdStepShow } from "../commands/step.js"; +import { formatOutput } from "../format.js"; +import { registerUwfSchemas } from "../schemas.js"; + +const TURN_SCHEMA: JSONSchema = { + title: "test-turn", + type: "object", + required: ["index", "role", "content"], + properties: { + index: { type: "integer" }, + role: { type: "string", enum: ["assistant", "tool"] }, + content: { type: "string" }, + toolCalls: { + anyOf: [ + { + type: "array", + items: { + type: "object", + required: ["name", "args"], + properties: { + name: { type: "string" }, + args: { type: "string" }, + }, + additionalProperties: false, + }, + }, + { type: "null" }, + ], + }, + }, + additionalProperties: false, +}; + +const DETAIL_SCHEMA: JSONSchema = { + title: "test-detail", + type: "object", + required: ["turns"], + properties: { + turns: { + type: "array", + items: { type: "string", format: "cas_ref" }, + }, + }, + additionalProperties: false, +}; + +type TestSetup = { + store: ReturnType; + schemas: { + workflow: Hash; + startNode: Hash; + stepNode: Hash; + text: Hash; + }; + turnType: Hash; + detailType: Hash; +}; + +async function setupTest(casDir: string): Promise { + const store = createFsStore(casDir); + await bootstrap(store); + const schemas = await registerUwfSchemas(store); + const [turnType, detailType] = await Promise.all([ + putSchema(store, TURN_SCHEMA), + putSchema(store, DETAIL_SCHEMA), + ]); + return { store, schemas, turnType, detailType }; +} + +async function createTestStep( + setup: TestSetup, + turnPayloads: Array<{ + index: number; + role: string; + content: string; + toolCalls: Array<{ name: string; args: string }> | null; + }>, +): Promise { + const { store, schemas, turnType, detailType } = setup; + + // Create turn nodes + const turnHashes: CasRef[] = []; + for (const payload of turnPayloads) { + const turnHash = await store.put(turnType, payload); + turnHashes.push(turnHash); + } + + // Create detail node + const detailHash = await store.put(detailType, { turns: turnHashes }); + + // Create dummy start node + const startHash = await store.put(schemas.startNode, { + workflow: "0000000000000" as CasRef, + prompt: "test prompt", + cwd: "/tmp", + }); + + // Create dummy output node + const outputHash = await store.put(schemas.text, { $status: "done" }); + + // Create step node + const stepPayload: StepNodePayload = { + prev: null, + start: startHash, + role: "test-role", + agent: "test-agent", + output: outputHash, + detail: detailHash, + edgePrompt: "", + startedAtMs: Date.now(), + completedAtMs: Date.now() + 1000, + cwd: "/tmp", + }; + return store.put(schemas.stepNode, stepPayload); +} + +describe("cmdStepShow JSON serialization", () => { + let testDir: string; + let casDir: string; + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), "uwf-test-")); + casDir = join(testDir, "cas"); + await mkdir(casDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + test("escapes newlines in tool call args", async () => { + const setup = await setupTest(casDir); + const stepHash = await createTestStep(setup, [ + { + index: 0, + role: "assistant", + content: "Running command", + toolCalls: [ + { + name: "Bash", + args: "echo 'line1'\necho 'line2'", + }, + ], + }, + ]); + + const result = await cmdStepShow(testDir, stepHash); + const jsonOutput = formatOutput(result, "json"); + + expect(() => JSON.parse(jsonOutput)).not.toThrow(); + expect(jsonOutput).toContain("\\n"); + + const parsed = JSON.parse(jsonOutput); + expect(parsed.turns[0].toolCalls[0].args).toContain("\n"); + }); + + test("escapes tabs in tool call args", async () => { + const setup = await setupTest(casDir); + const stepHash = await createTestStep(setup, [ + { + index: 0, + role: "assistant", + content: "", + toolCalls: [ + { + name: "Bash", + args: "cat < JSON.parse(jsonOutput)).not.toThrow(); + expect(jsonOutput).toContain("\\t"); + }); + + test("escapes carriage returns", async () => { + const setup = await setupTest(casDir); + const stepHash = await createTestStep(setup, [ + { + index: 0, + role: "assistant", + content: "Committing changes", + toolCalls: [ + { + name: "Bash", + args: 'git commit -m "First line\r\nSecond line"', + }, + ], + }, + ]); + + const result = await cmdStepShow(testDir, stepHash); + const jsonOutput = formatOutput(result, "json"); + + expect(() => JSON.parse(jsonOutput)).not.toThrow(); + expect(jsonOutput).toContain("\\r\\n"); + }); + + test("escapes backslashes and quotes", async () => { + const setup = await setupTest(casDir); + const stepHash = await createTestStep(setup, [ + { + index: 0, + role: "assistant", + content: "", + toolCalls: [ + { + name: "Bash", + args: 'echo "He said \\"hello\\""', + }, + ], + }, + ]); + + const result = await cmdStepShow(testDir, stepHash); + const jsonOutput = formatOutput(result, "json"); + + expect(() => JSON.parse(jsonOutput)).not.toThrow(); + const parsed = JSON.parse(jsonOutput); + expect(parsed.turns).toBeDefined(); + }); + + test("handles Unicode control characters", async () => { + const setup = await setupTest(casDir); + const stepHash = await createTestStep(setup, [ + { + index: 0, + role: "assistant", + content: "", + toolCalls: [ + { + name: "Bash", + args: "echo '\u0001\u001F'", + }, + ], + }, + ]); + + const result = await cmdStepShow(testDir, stepHash); + const jsonOutput = formatOutput(result, "json"); + + expect(() => JSON.parse(jsonOutput)).not.toThrow(); + }); + + test("handles nested CAS refs with control characters", async () => { + const setup = await setupTest(casDir); + const stepHash = await createTestStep(setup, [ + { + index: 0, + role: "assistant", + content: "First turn\nwith newline", + toolCalls: [ + { + name: "Bash", + args: "cmd1\nline2", + }, + ], + }, + { + index: 1, + role: "assistant", + content: "Second turn\twith tab", + toolCalls: null, + }, + ]); + + const result = await cmdStepShow(testDir, stepHash); + const jsonOutput = formatOutput(result, "json"); + + expect(() => JSON.parse(jsonOutput)).not.toThrow(); + const parsed = JSON.parse(jsonOutput); + expect(parsed.turns).toHaveLength(2); + }); + + test("YAML output format is unaffected", async () => { + const setup = await setupTest(casDir); + const stepHash = await createTestStep(setup, [ + { + index: 0, + role: "assistant", + content: "Running command", + toolCalls: [ + { + name: "Bash", + args: "echo 'line1'\necho 'line2'", + }, + ], + }, + ]); + + const result = await cmdStepShow(testDir, stepHash); + const yamlOutput = formatOutput(result, "yaml"); + + expect(yamlOutput).toContain("turns:"); + expect(yamlOutput.length).toBeGreaterThan(0); + }); + + test("handles empty and null values", async () => { + const setup = await setupTest(casDir); + const stepHash = await createTestStep(setup, [ + { + index: 0, + role: "assistant", + content: "", + toolCalls: null, + }, + ]); + + const result = await cmdStepShow(testDir, stepHash); + const jsonOutput = formatOutput(result, "json"); + + expect(() => JSON.parse(jsonOutput)).not.toThrow(); + const parsed = JSON.parse(jsonOutput); + expect(parsed.turns).toBeDefined(); + }); + + test("handles large step with multiple tool calls", async () => { + const setup = await setupTest(casDir); + + const turns = []; + for (let i = 0; i < 25; i++) { + turns.push({ + index: i, + role: "assistant" as const, + content: `Turn ${i}\nwith newline`, + toolCalls: [ + { + name: "Bash", + args: `command${i}\nline2\tfield${i}`, + }, + { + name: "Read", + args: `/path/to/file${i}`, + }, + ], + }); + } + + const stepHash = await createTestStep(setup, turns); + + const startTime = Date.now(); + const result = await cmdStepShow(testDir, stepHash); + const jsonOutput = formatOutput(result, "json"); + const duration = Date.now() - startTime; + + expect(duration).toBeLessThan(2000); + expect(() => JSON.parse(jsonOutput)).not.toThrow(); + + const parsed = JSON.parse(jsonOutput); + expect(parsed.turns).toHaveLength(25); + }); +});