test(cli): add JSON escaping tests for step show output (#557)
CI / test (pull_request) Successful in 1m38s

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 16:53:07 +00:00
parent 3b498069b6
commit d8cba5eea0
@@ -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<typeof createFsStore>;
schemas: {
workflow: Hash;
startNode: Hash;
stepNode: Hash;
text: Hash;
};
turnType: Hash;
detailType: Hash;
};
async function setupTest(casDir: string): Promise<TestSetup> {
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<CasRef> {
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 <<EOF\nfield1\tfield2\tfield3\nEOF",
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => 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);
});
});