* fix: improve CLI flow log streaming, sub-job listing, and failure handling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add hierarchical flow status in job get and aggregated flow logs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove duplicate ansi color hint in job logs output Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update cli-commands skill with new job/flow features Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add integration tests for flow job inspection and log aggregation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove internal friction discovery doc from branch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: trim cli-commands skill to reduce context bloat Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update job command descriptions and regenerate skills.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: commit auto-generated files from system_prompts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review comments on flow streaming and test assertions - Move for-loop waiting logic outside --silent guard (Cubic #2) - Break outer loop when for-loop module fails (Cubic #3) - Strengthen test assertion: toContain("a") -> toContain("a: Generate data") (Cubic #1) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: generator regex truncating descriptions with parentheses The .command() regex used [^)]+ for the second arg, stopping at the first ')' inside description strings like "(machine-friendly)". Now matches quoted strings properly before falling back. Fixes 6 truncated descriptions across job, flow, and script commands. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
/**
|
|
* Integration tests for `wmill job` commands:
|
|
* list, get, result, logs, cancel
|
|
*/
|
|
|
|
import { expect, test, describe } from "bun:test";
|
|
import { withTestBackend } from "./test_backend.ts";
|
|
import {
|
|
setupWorkspaceProfile,
|
|
createRemoteScript,
|
|
createRemoteFlow,
|
|
createRemoteMultiStepFlow,
|
|
createRemoteFailingFlow,
|
|
runRemoteScript,
|
|
runRemoteFlow,
|
|
waitForJob,
|
|
} from "./new_commands_helpers.ts";
|
|
|
|
describe("job command", () => {
|
|
test("job list returns valid JSON", async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await setupWorkspaceProfile(backend);
|
|
|
|
const uniqueId = Date.now();
|
|
const scriptPath = `f/test/job_test_${uniqueId}`;
|
|
await createRemoteScript(backend, scriptPath);
|
|
const jobId = await runRemoteScript(backend, scriptPath);
|
|
await waitForJob(backend, jobId);
|
|
|
|
const result = await backend.runCLICommand(
|
|
["job", "list", "--json"],
|
|
tempDir
|
|
);
|
|
|
|
expect(result.code).toEqual(0);
|
|
const parsed = JSON.parse(result.stdout);
|
|
expect(Array.isArray(parsed)).toBe(true);
|
|
expect(parsed.some((j: any) => j.id === jobId)).toBe(true);
|
|
});
|
|
});
|
|
|
|
test("job list shows table output", async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await setupWorkspaceProfile(backend);
|
|
|
|
const uniqueId = Date.now();
|
|
const scriptPath = `f/test/job_table_${uniqueId}`;
|
|
await createRemoteScript(backend, scriptPath);
|
|
const jobId = await runRemoteScript(backend, scriptPath);
|
|
await waitForJob(backend, jobId);
|
|
|
|
const result = await backend.runCLICommand(
|
|
["job", "list"],
|
|
tempDir
|
|
);
|
|
|
|
expect(result.code).toEqual(0);
|
|
expect(result.stdout).toContain("ID");
|
|
expect(result.stdout).toContain("Status");
|
|
expect(result.stdout).toContain(jobId);
|
|
});
|
|
});
|
|
|
|
test("job list --script-path filters correctly", async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await setupWorkspaceProfile(backend);
|
|
|
|
const uniqueId = Date.now();
|
|
const scriptPath = `f/test/filter_test_${uniqueId}`;
|
|
await createRemoteScript(backend, scriptPath);
|
|
const jobId = await runRemoteScript(backend, scriptPath);
|
|
await waitForJob(backend, jobId);
|
|
|
|
const result = await backend.runCLICommand(
|
|
["job", "list", "--json", "--script-path", scriptPath],
|
|
tempDir
|
|
);
|
|
|
|
expect(result.code).toEqual(0);
|
|
const parsed = JSON.parse(result.stdout);
|
|
expect(parsed.every((j: any) => j.script_path === scriptPath)).toBe(true);
|
|
});
|
|
});
|
|
|
|
test("job get returns job details", async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await setupWorkspaceProfile(backend);
|
|
|
|
const uniqueId = Date.now();
|
|
const scriptPath = `f/test/job_get_${uniqueId}`;
|
|
await createRemoteScript(backend, scriptPath);
|
|
const jobId = await runRemoteScript(backend, scriptPath);
|
|
await waitForJob(backend, jobId);
|
|
|
|
const result = await backend.runCLICommand(
|
|
["job", "get", jobId],
|
|
tempDir
|
|
);
|
|
|
|
expect(result.code).toEqual(0);
|
|
expect(result.stdout).toContain("ID:");
|
|
expect(result.stdout).toContain(jobId);
|
|
expect(result.stdout).toContain("Status:");
|
|
expect(result.stdout).toContain("success");
|
|
});
|
|
});
|
|
|
|
test("job get --json returns valid JSON", async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await setupWorkspaceProfile(backend);
|
|
|
|
const uniqueId = Date.now();
|
|
const scriptPath = `f/test/job_get_json_${uniqueId}`;
|
|
await createRemoteScript(backend, scriptPath);
|
|
const jobId = await runRemoteScript(backend, scriptPath);
|
|
await waitForJob(backend, jobId);
|
|
|
|
const result = await backend.runCLICommand(
|
|
["job", "get", jobId, "--json"],
|
|
tempDir
|
|
);
|
|
|
|
expect(result.code).toEqual(0);
|
|
const parsed = JSON.parse(result.stdout);
|
|
expect(parsed.id).toBe(jobId);
|
|
expect(parsed.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
test("job result returns job result as JSON", async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await setupWorkspaceProfile(backend);
|
|
|
|
const uniqueId = Date.now();
|
|
const scriptPath = `f/test/job_result_${uniqueId}`;
|
|
await createRemoteScript(backend, scriptPath);
|
|
const jobId = await runRemoteScript(backend, scriptPath);
|
|
await waitForJob(backend, jobId);
|
|
|
|
const result = await backend.runCLICommand(
|
|
["job", "result", jobId],
|
|
tempDir
|
|
);
|
|
|
|
expect(result.code).toEqual(0);
|
|
// Result may be in stdout or combined output
|
|
const output = result.stdout.trim();
|
|
expect(output.length).toBeGreaterThan(0);
|
|
const parsed = JSON.parse(output);
|
|
expect(parsed).toBe("hello");
|
|
});
|
|
});
|
|
|
|
test("job logs returns job logs", async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await setupWorkspaceProfile(backend);
|
|
|
|
const uniqueId = Date.now();
|
|
const scriptPath = `f/test/job_logs_${uniqueId}`;
|
|
await createRemoteScript(backend, scriptPath);
|
|
const jobId = await runRemoteScript(backend, scriptPath);
|
|
await waitForJob(backend, jobId);
|
|
|
|
const result = await backend.runCLICommand(
|
|
["job", "logs", jobId],
|
|
tempDir
|
|
);
|
|
|
|
expect(result.code).toEqual(0);
|
|
expect(result.stdout.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
test("job logs for flow job aggregates step logs", async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await setupWorkspaceProfile(backend);
|
|
|
|
const uniqueId = Date.now();
|
|
const flowPath = `f/test/flow_logs_${uniqueId}`;
|
|
await createRemoteMultiStepFlow(backend, flowPath);
|
|
const jobId = await runRemoteFlow(backend, flowPath);
|
|
await waitForJob(backend, jobId);
|
|
|
|
const result = await backend.runCLICommand(
|
|
["job", "logs", jobId],
|
|
tempDir
|
|
);
|
|
|
|
expect(result.code).toEqual(0);
|
|
// Should show labeled step headers instead of "no direct logs"
|
|
expect(result.stdout).toContain("======");
|
|
expect(result.stdout).toContain("a: Generate data");
|
|
});
|
|
});
|
|
|
|
test("default action (wmill job) lists jobs", async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await setupWorkspaceProfile(backend);
|
|
|
|
const result = await backend.runCLICommand(
|
|
["job", "--json"],
|
|
tempDir
|
|
);
|
|
|
|
expect(result.code).toEqual(0);
|
|
const parsed = JSON.parse(result.stdout);
|
|
expect(Array.isArray(parsed)).toBe(true);
|
|
});
|
|
});
|
|
|
|
test("job --help shows all subcommands", async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
const result = await backend.runCLICommand(["job", "--help"], tempDir);
|
|
const output = result.stdout + result.stderr;
|
|
expect(output).toContain("list");
|
|
expect(output).toContain("get");
|
|
expect(output).toContain("result");
|
|
expect(output).toContain("logs");
|
|
expect(output).toContain("cancel");
|
|
expect(output).toContain("--failed");
|
|
expect(output).toContain("--running");
|
|
expect(output).toContain("--parent");
|
|
expect(output).toContain("--is-flow-step");
|
|
});
|
|
});
|
|
|
|
test("job get for flow shows hierarchical step tree", async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await setupWorkspaceProfile(backend);
|
|
|
|
const uniqueId = Date.now();
|
|
const flowPath = `f/test/flow_get_${uniqueId}`;
|
|
await createRemoteMultiStepFlow(backend, flowPath);
|
|
const jobId = await runRemoteFlow(backend, flowPath);
|
|
await waitForJob(backend, jobId);
|
|
|
|
const result = await backend.runCLICommand(
|
|
["job", "get", jobId],
|
|
tempDir
|
|
);
|
|
|
|
expect(result.code).toEqual(0);
|
|
expect(result.stdout).toContain("Steps:");
|
|
// Should show step IDs from the flow definition
|
|
expect(result.stdout).toContain("a");
|
|
expect(result.stdout).toContain("b");
|
|
// Should show status icons (✓ for success)
|
|
expect(result.stdout).toContain("✓");
|
|
});
|
|
});
|
|
|
|
test("job get --json for flow includes flow_status", async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await setupWorkspaceProfile(backend);
|
|
|
|
const uniqueId = Date.now();
|
|
const flowPath = `f/test/flow_json_${uniqueId}`;
|
|
await createRemoteMultiStepFlow(backend, flowPath);
|
|
const jobId = await runRemoteFlow(backend, flowPath);
|
|
await waitForJob(backend, jobId);
|
|
|
|
const result = await backend.runCLICommand(
|
|
["job", "get", jobId, "--json"],
|
|
tempDir
|
|
);
|
|
|
|
expect(result.code).toEqual(0);
|
|
const parsed = JSON.parse(result.stdout);
|
|
expect(parsed.flow_status).toBeDefined();
|
|
expect(parsed.flow_status.modules).toBeDefined();
|
|
expect(parsed.flow_status.modules.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
test("job list --parent shows sub-jobs of a flow", async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await setupWorkspaceProfile(backend);
|
|
|
|
const uniqueId = Date.now();
|
|
const flowPath = `f/test/flow_parent_${uniqueId}`;
|
|
await createRemoteMultiStepFlow(backend, flowPath);
|
|
const jobId = await runRemoteFlow(backend, flowPath);
|
|
await waitForJob(backend, jobId);
|
|
|
|
const result = await backend.runCLICommand(
|
|
["job", "list", "--json", "--parent", jobId],
|
|
tempDir
|
|
);
|
|
|
|
expect(result.code).toEqual(0);
|
|
const parsed = JSON.parse(result.stdout);
|
|
expect(Array.isArray(parsed)).toBe(true);
|
|
// A 2-step flow should have at least 2 sub-jobs
|
|
expect(parsed.length).toBeGreaterThanOrEqual(2);
|
|
// All sub-jobs should reference the parent flow
|
|
expect(parsed.every((j: any) => j.parent_job === jobId)).toBe(true);
|
|
});
|
|
});
|
|
|
|
test("job list --all includes sub-jobs", async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await setupWorkspaceProfile(backend);
|
|
|
|
const uniqueId = Date.now();
|
|
const flowPath = `f/test/flow_all_${uniqueId}`;
|
|
await createRemoteMultiStepFlow(backend, flowPath);
|
|
const jobId = await runRemoteFlow(backend, flowPath);
|
|
await waitForJob(backend, jobId);
|
|
|
|
const result = await backend.runCLICommand(
|
|
["job", "list", "--json", "--all"],
|
|
tempDir
|
|
);
|
|
|
|
expect(result.code).toEqual(0);
|
|
const parsed = JSON.parse(result.stdout);
|
|
// Should contain both the parent flow and its sub-jobs
|
|
const parentJob = parsed.find((j: any) => j.id === jobId);
|
|
const subJobs = parsed.filter((j: any) => j.parent_job === jobId);
|
|
expect(parentJob).toBeDefined();
|
|
expect(subJobs.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
});
|
|
|
|
test("job get for failed flow shows failure status", async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await setupWorkspaceProfile(backend);
|
|
|
|
const uniqueId = Date.now();
|
|
const flowPath = `f/test/flow_fail_${uniqueId}`;
|
|
await createRemoteFailingFlow(backend, flowPath);
|
|
const jobId = await runRemoteFlow(backend, flowPath);
|
|
await waitForJob(backend, jobId);
|
|
|
|
const result = await backend.runCLICommand(
|
|
["job", "get", jobId],
|
|
tempDir
|
|
);
|
|
|
|
expect(result.code).toEqual(0);
|
|
expect(result.stdout).toContain("failure");
|
|
expect(result.stdout).toContain("Steps:");
|
|
// Step a should succeed, step b should fail
|
|
expect(result.stdout).toContain("✓");
|
|
expect(result.stdout).toContain("✗");
|
|
});
|
|
});
|
|
});
|