Compare commits

...

8 Commits

Author SHA1 Message Date
xingyue aab846498b fix: fix 7 failing tests (OCAS_DIR → OCAS_HOME + restore workflow destructure)
CI / check (pull_request) Failing after 55s
Root cause: tests used OCAS_DIR env var but store.ts reads OCAS_HOME,
causing tests to hit the global ~/.ocas vars instead of temp dirs.

- store-unified-threads.test.ts: OCAS_DIR → OCAS_HOME (3 tests)
- thread-resume.test.ts: OCAS_DIR → OCAS_HOME (3 tests)
- current-role.test.ts: restore { thread, workflow } destructure
  that was incorrectly removed by biome unsafe fix (1 test)

Result: 745 passed, 0 failed, 1 skipped

Closes #49
2026-06-04 17:51:21 +08:00
xiaomo f56e24cf82 Merge pull request 'test: expand E2E coverage — suspend, count, mustache, completed resume' (#51) from test/33-more-e2e into main
CI / check (push) Failing after 1m28s
test: expand E2E coverage — suspend, count, mustache, completed resume (#51)
2026-06-04 09:04:09 +00:00
xiaoju 974c2b8f1b test: add E2E tests for suspend/resume, --count, mustache, and completed resume (#33)
CI / check (pull_request) Failing after 1m40s
4 new E2E scenarios:
4. $SUSPEND → resume lifecycle (suspendedRole/suspendMessage metadata)
5. --count 3 runs entire pipeline in one invocation
6. mustache template variables rendered into edgePrompt
7. completed thread resume (衔尾蛇: end → start, CAS chain preserved)

Total: 7 E2E scenarios, all passing.
2026-06-04 09:03:01 +00:00
xiaomo 6e7276425d Merge pull request 'chore: fix biome check errors (40 → 0)' (#50) from chore/fix-biome-check into main
CI / check (push) Failing after 1m16s
chore: fix biome check errors (40 → 0) (#50)
2026-06-04 09:01:49 +00:00
xingyue dbb7885ffd chore: fix biome check errors (40 → 0)
CI / check (pull_request) Failing after 1m39s
- Auto-fix: import sorting, formatting (17 files)
- Unsafe auto-fix: unused vars, template literals (7 files)
- Manual: nursery/noConsole → suspicious/noConsole suppression
- Manual: suppress noExcessiveCognitiveComplexity for cmdThreadResume and parseWorkflowPayload
- Manual: remove unused destructured vars in current-role tests

Closes #48
2026-06-04 16:45:45 +08:00
xiaomo cd7e4e77ff Merge pull request 'feat: agent-mock package for deterministic E2E testing (#33)' (#44) from test/33-mock-agent into main
CI / check (push) Failing after 1m38s
feat: agent-mock package for deterministic E2E testing (#44)
2026-06-04 08:38:51 +00:00
xiaomo 64a8bab5ce Merge pull request 'fix: resolve workflow from CAS chain in collectCompletedThreads' (#47) from fix/completed-thread-workflow into main
CI / check (push) Failing after 1m33s
fix: resolve workflow from CAS chain in collectCompletedThreads (#47)
2026-06-04 08:38:06 +00:00
xingyue 06af1dc668 fix: resolve workflow from CAS chain in collectCompletedThreads
CI / check (pull_request) Failing after 1m28s
Instead of hardcoding workflow as empty string for completed/cancelled
threads, use resolveWorkflowFromHead to get the actual workflow hash
from the CAS chain, consistent with active thread handling.

Closes #46
2026-06-04 15:35:08 +08:00
29 changed files with 606 additions and 244 deletions
@@ -1,8 +1,8 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createMemoryStore } from "@ocas/core";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { storeBuiltinDetail } from "../src/detail.js";
import { appendSessionTurn, initSessionDir } from "../src/session.js";
import type { BuiltinTurnPayload } from "../src/types.js";
@@ -1,51 +1,51 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { readFileTool } from "../src/tools/read-file.js";
import { writeFile, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { readFileTool } from "../src/tools/read-file.js";
const testDir = join(tmpdir(), `read-file-test-${Date.now()}`);
const ctx = { cwd: testDir, storageRoot: testDir };
beforeAll(async () => {
await mkdir(testDir, { recursive: true });
await writeFile(join(testDir, "hello.txt"), "hello world", "utf8");
await mkdir(testDir, { recursive: true });
await writeFile(join(testDir, "hello.txt"), "hello world", "utf8");
});
afterAll(async () => {
await rm(testDir, { recursive: true, force: true });
await rm(testDir, { recursive: true, force: true });
});
describe("readFileTool", () => {
it("reads a file successfully", async () => {
const result = await readFileTool.execute({ path: "hello.txt" }, ctx);
expect(result).toBe("hello world");
});
it("reads a file successfully", async () => {
const result = await readFileTool.execute({ path: "hello.txt" }, ctx);
expect(result).toBe("hello world");
});
it("returns error for non-existent file", async () => {
const result = await readFileTool.execute({ path: "nope.txt" }, ctx);
expect(result).toMatch(/^Error:/);
});
it("returns error for non-existent file", async () => {
const result = await readFileTool.execute({ path: "nope.txt" }, ctx);
expect(result).toMatch(/^Error:/);
});
it("returns error for directory", async () => {
const result = await readFileTool.execute({ path: "." }, ctx);
expect(result).toBe("Error: not a file");
});
it("returns error for directory", async () => {
const result = await readFileTool.execute({ path: "." }, ctx);
expect(result).toBe("Error: not a file");
});
it("returns error when path is not a string", async () => {
const result = await readFileTool.execute({ path: 123 }, ctx);
expect(result).toBe("Error: path must be a string");
});
it("returns error when path is not a string", async () => {
const result = await readFileTool.execute({ path: 123 }, ctx);
expect(result).toBe("Error: path must be a string");
});
it("returns error when args is null", async () => {
const result = await readFileTool.execute(null, ctx);
expect(result).toBe("Error: path must be a string");
});
it("returns error when args is null", async () => {
const result = await readFileTool.execute(null, ctx);
expect(result).toBe("Error: path must be a string");
});
it("returns error for file exceeding 512KB limit", async () => {
const bigFile = join(testDir, "big.txt");
await writeFile(bigFile, Buffer.alloc(512 * 1024 + 1, 65));
const result = await readFileTool.execute({ path: "big.txt" }, ctx);
expect(result).toMatch(/Error:.*limit/);
});
it("returns error for file exceeding 512KB limit", async () => {
const bigFile = join(testDir, "big.txt");
await writeFile(bigFile, Buffer.alloc(512 * 1024 + 1, 65));
const result = await readFileTool.execute({ path: "big.txt" }, ctx);
expect(result).toMatch(/Error:.*limit/);
});
});
@@ -1,38 +1,38 @@
import { describe, it, expect } from "vitest";
import { runCommandTool } from "../src/tools/run-command.js";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import { runCommandTool } from "../src/tools/run-command.js";
const ctx = { cwd: tmpdir(), storageRoot: tmpdir() };
describe("runCommandTool", () => {
it("runs echo command and checks stdout", async () => {
const result = await runCommandTool.execute({ command: "echo hello" }, ctx);
expect(result).toContain("hello");
expect(result).toContain("stdout");
});
it("runs echo command and checks stdout", async () => {
const result = await runCommandTool.execute({ command: "echo hello" }, ctx);
expect(result).toContain("hello");
expect(result).toContain("stdout");
});
it("returns exit code", async () => {
const result = await runCommandTool.execute({ command: "exit 0" }, ctx);
expect(result).toContain("exit_code: 0");
});
it("returns exit code", async () => {
const result = await runCommandTool.execute({ command: "exit 0" }, ctx);
expect(result).toContain("exit_code: 0");
});
it("returns non-zero exit code", async () => {
const result = await runCommandTool.execute({ command: "exit 42" }, ctx);
expect(result).toContain("exit_code: 42");
});
it("returns non-zero exit code", async () => {
const result = await runCommandTool.execute({ command: "exit 42" }, ctx);
expect(result).toContain("exit_code: 42");
});
it("returns error when command is not a string", async () => {
const result = await runCommandTool.execute({ command: 123 }, ctx);
expect(result).toBe("Error: command must be a string");
});
it("returns error when command is not a string", async () => {
const result = await runCommandTool.execute({ command: 123 }, ctx);
expect(result).toBe("Error: command must be a string");
});
it("returns error when args is null", async () => {
const result = await runCommandTool.execute(null, ctx);
expect(result).toBe("Error: command must be a string");
});
it("returns error when args is null", async () => {
const result = await runCommandTool.execute(null, ctx);
expect(result).toBe("Error: command must be a string");
});
it("custom cwd works", async () => {
const result = await runCommandTool.execute({ command: "pwd", cwd: "/tmp" }, ctx);
expect(result).toContain("/tmp");
});
it("custom cwd works", async () => {
const result = await runCommandTool.execute({ command: "pwd", cwd: "/tmp" }, ctx);
expect(result).toContain("/tmp");
});
});
@@ -3,13 +3,13 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { BuiltinTurnPayload } from "../src/types.js";
import {
appendSessionTurn,
initSessionDir,
readSessionTurns,
removeSession,
} from "../src/session.js";
import type { BuiltinTurnPayload } from "../src/types.js";
describe("session", () => {
let storageRoot: string;
@@ -1,43 +1,43 @@
import { describe, it, expect, afterAll } from "vitest";
import { writeFileTool } from "../src/tools/write-file.js";
import { readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { writeFileTool } from "../src/tools/write-file.js";
const testDir = join(tmpdir(), `write-file-test-${Date.now()}`);
const ctx = { cwd: testDir, storageRoot: testDir };
afterAll(async () => {
await rm(testDir, { recursive: true, force: true });
await rm(testDir, { recursive: true, force: true });
});
describe("writeFileTool", () => {
it("writes file successfully", async () => {
const result = await writeFileTool.execute({ path: "out.txt", content: "hi" }, ctx);
expect(result).toMatch(/Wrote 2 bytes/);
const content = await readFile(join(testDir, "out.txt"), "utf8");
expect(content).toBe("hi");
});
it("writes file successfully", async () => {
const result = await writeFileTool.execute({ path: "out.txt", content: "hi" }, ctx);
expect(result).toMatch(/Wrote 2 bytes/);
const content = await readFile(join(testDir, "out.txt"), "utf8");
expect(content).toBe("hi");
});
it("creates parent directories", async () => {
const result = await writeFileTool.execute({ path: "a/b/c.txt", content: "nested" }, ctx);
expect(result).toMatch(/Wrote/);
const content = await readFile(join(testDir, "a/b/c.txt"), "utf8");
expect(content).toBe("nested");
});
it("creates parent directories", async () => {
const result = await writeFileTool.execute({ path: "a/b/c.txt", content: "nested" }, ctx);
expect(result).toMatch(/Wrote/);
const content = await readFile(join(testDir, "a/b/c.txt"), "utf8");
expect(content).toBe("nested");
});
it("returns error when path is not a string", async () => {
const result = await writeFileTool.execute({ path: 123, content: "x" }, ctx);
expect(result).toBe("Error: path and content must be strings");
});
it("returns error when path is not a string", async () => {
const result = await writeFileTool.execute({ path: 123, content: "x" }, ctx);
expect(result).toBe("Error: path and content must be strings");
});
it("returns error when content is not a string", async () => {
const result = await writeFileTool.execute({ path: "x.txt", content: 42 }, ctx);
expect(result).toBe("Error: path and content must be strings");
});
it("returns error when content is not a string", async () => {
const result = await writeFileTool.execute({ path: "x.txt", content: 42 }, ctx);
expect(result).toBe("Error: path and content must be strings");
});
it("returns error when args is null", async () => {
const result = await writeFileTool.execute(null, ctx);
expect(result).toBe("Error: path and content must be strings");
});
it("returns error when args is null", async () => {
const result = await writeFileTool.execute(null, ctx);
expect(result).toBe("Error: path and content must be strings");
});
});
@@ -6,12 +6,7 @@ import type { CasRef, ThreadId } from "@united-workforce/protocol";
import { describe, expect, test } from "vitest";
import { createMarker, deleteMarker } from "../background/index.js";
import { cmdThreadList, cmdThreadShow, cmdThreadStart } from "../commands/thread.js";
import {
completeThread,
createUwfStore,
loadActiveThreads,
setThread,
} from "../store.js";
import { completeThread, createUwfStore, loadActiveThreads, setThread } from "../store.js";
const OUTPUT_SCHEMA = {
type: "object" as const,
@@ -287,11 +282,11 @@ describe("currentRole field", () => {
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
const { thread, workflow } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
const tid = thread as ThreadId;
const uwfForIndex = await createUwfStore(storageRoot);
const head = loadActiveThreads(uwfForIndex.varStore)[tid]!.head;
loadActiveThreads(uwfForIndex.varStore)[tid]!.head;
completeThread(uwfForIndex.varStore, tid, "completed");
const result = await cmdThreadShow(storageRoot, tid);
@@ -308,11 +303,11 @@ describe("currentRole field", () => {
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
const { thread, workflow } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
const tid = thread as ThreadId;
const uwfForIndex = await createUwfStore(storageRoot);
const head = loadActiveThreads(uwfForIndex.varStore)[tid]!.head;
loadActiveThreads(uwfForIndex.varStore)[tid]!.head;
completeThread(uwfForIndex.varStore, tid, "cancelled");
const result = await cmdThreadShow(storageRoot, tid);
@@ -366,7 +361,7 @@ describe("currentRole field", () => {
const comp = await cmdThreadStart(storageRoot, wf, "completed", tmpDir);
const compId = comp.thread as ThreadId;
const uwfForIndex = await createUwfStore(storageRoot);
const compHead = loadActiveThreads(uwfForIndex.varStore)[compId]!.head;
const _compHead = loadActiveThreads(uwfForIndex.varStore)[compId]!.head;
completeThread(uwfForIndex.varStore, compId, "completed");
const list = await cmdThreadList(storageRoot, null, null, null, 0, 100);
@@ -106,9 +106,13 @@ async function addWorkflow(workflowFixture: string, workflowName: string): Promi
type ExecResult = { stdout: string; stderr: string; exitCode: number };
function runExec(threadId: string): ExecResult {
function runExec(threadId: string, count: number | null = null): ExecResult {
const args = [CLI_PATH, "thread", "exec", threadId];
if (count !== null) {
args.push("--count", String(count));
}
try {
const stdout = execFileSync(process.execPath, [CLI_PATH, "thread", "exec", threadId], {
const stdout = execFileSync(process.execPath, args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, UWF_HOME: uwfHome, OCAS_HOME: casDir },
@@ -126,11 +130,38 @@ function runExec(threadId: string): ExecResult {
}
}
/** Invoke `uwf thread resume <threadId> -p <prompt>` through the built CLI. */
function runResume(threadId: string, prompt: string): ExecResult {
try {
const stdout = execFileSync(
process.execPath,
[CLI_PATH, "thread", "resume", threadId, "-p", prompt],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, UWF_HOME: uwfHome, OCAS_HOME: casDir },
cwd: tmpDir,
timeout: 30000,
},
);
return { stdout, stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
status?: number;
};
return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", exitCode: err.status ?? 1 };
}
}
type StepOutputJson = {
thread: string;
head: string;
status: string;
currentRole: string | null;
suspendedRole: string | null;
suspendMessage: string | null;
done: boolean;
};
@@ -293,4 +324,159 @@ describe("E2E mock-agent: full uwf pipeline", () => {
expect(entry!.status).not.toBe("completed");
expect(entry!.head).toBe(step1.head);
});
test("4. planner $SUSPEND then resume re-runs planner and reaches $END", async () => {
await writeMockConfig("e2e-suspend.mock.yaml");
const workflowHash = await addWorkflow("e2e-suspend.workflow.yaml", "test-suspend");
const start = await cmdThreadStart(uwfHome, workflowHash, "Analyze the task", uwfHome, tmpDir);
const threadId = start.thread;
// Step 1 → planner emits insufficient_info → thread suspends.
const step1 = execStep(threadId);
expect(step1.status).toBe("suspended");
expect(step1.done).toBe(false);
expect(step1.currentRole).toBeNull();
expect(step1.suspendedRole).toBe("planner");
expect(step1.suspendMessage).toBe("Need more info: missing requirements");
// Thread index entry reflects the suspension with rendered metadata.
const suspendedEntry = getThread((await createUwfStore(uwfHome)).varStore, threadId);
expect(suspendedEntry).not.toBeNull();
expect(suspendedEntry!.status).toBe("suspended");
expect(suspendedEntry!.suspendedRole).toBe("planner");
expect(suspendedEntry!.suspendMessage).toBe("Need more info: missing requirements");
// Resume re-runs the planner role; the second scripted step is `ready` → $END.
const resume = runResume(threadId, "Here are the requirements");
expect(resume.exitCode).toBe(0);
const resumeOut = JSON.parse(resume.stdout.trim()) as StepOutputJson;
expect(resumeOut.status).toBe("completed");
expect(resumeOut.done).toBe(true);
expect(resumeOut.currentRole).toBeNull();
expect(resumeOut.suspendedRole).toBeNull();
// CAS chain: suspended planner step → resumed planner step.
const store = await openStore(casDir);
const s1 = getStepNode(store, step1.head);
const s2 = getStepNode(store, resumeOut.head);
expect(s1.role).toBe("planner");
expect(s2.role).toBe("planner");
expect(s2.prev).toBe(step1.head);
expect(getStatus(store, s1.output)).toBe("insufficient_info");
expect(getStatus(store, s2.output)).toBe("ready");
const finalEntry = getThread((await createUwfStore(uwfHome)).varStore, threadId);
expect(finalEntry).not.toBeNull();
expect(finalEntry!.status).toBe("completed");
expect(finalEntry!.head).toBe(resumeOut.head);
});
test("5. --count 3 runs the whole linear pipeline in one invocation", async () => {
await writeMockConfig("e2e-count.mock.yaml");
const workflowHash = await addWorkflow("e2e-count.workflow.yaml", "test-count");
const start = await cmdThreadStart(uwfHome, workflowHash, "Ship the feature", uwfHome, tmpDir);
const threadId = start.thread;
// Single invocation with --count 3 → moderator drives analyst → developer → reviewer → $END.
const { stdout, stderr, exitCode } = runExec(threadId, 3);
expect(exitCode, `stderr: ${stderr}`).toBe(0);
// Multi-step exec emits a JSON array (one entry per executed step).
const results = JSON.parse(stdout.trim()) as StepOutputJson[];
expect(Array.isArray(results)).toBe(true);
expect(results).toHaveLength(3);
expect(results[0].status).toBe("idle");
expect(results[0].currentRole).toBe("developer");
expect(results[1].status).toBe("idle");
expect(results[1].currentRole).toBe("reviewer");
expect(results[2].status).toBe("completed");
expect(results[2].done).toBe(true);
// Verify the CAS chain holds 3 step nodes in the correct order.
const store = await openStore(casDir);
const n1 = getStepNode(store, results[0].head);
const n2 = getStepNode(store, results[1].head);
const n3 = getStepNode(store, results[2].head);
expect([n1.role, n2.role, n3.role]).toEqual(["analyst", "developer", "reviewer"]);
expect(n1.prev).toBeNull();
expect(n2.prev).toBe(results[0].head);
expect(n3.prev).toBe(results[1].head);
expect(new Set([n1.start, n2.start, n3.start]).size).toBe(1);
const finalEntry = getThread((await createUwfStore(uwfHome)).varStore, threadId);
expect(finalEntry).not.toBeNull();
expect(finalEntry!.status).toBe("completed");
expect(finalEntry!.head).toBe(results[2].head);
});
test("6. mustache edge prompt renders planner variables into the worker step", async () => {
await writeMockConfig("e2e-mustache.mock.yaml");
const workflowHash = await addWorkflow("e2e-mustache.workflow.yaml", "test-mustache");
const start = await cmdThreadStart(uwfHome, workflowHash, "Plan the task", uwfHome, tmpDir);
const threadId = start.thread;
// Step 1 → planner emits branch + repoPath.
const step1 = execStep(threadId);
expect(step1.status).toBe("idle");
expect(step1.currentRole).toBe("worker");
// Step 2 → worker; the moderator renders the templated edge prompt before spawning it.
const step2 = execStep(threadId);
expect(step2.done).toBe(true);
expect(step2.status).toBe("completed");
const store = await openStore(casDir);
const plannerStep = getStepNode(store, step1.head);
expect(getStatus(store, plannerStep.output)).toBe("ready");
// The worker step's edgePrompt is the mustache-rendered template.
const workerStep = getStepNode(store, step2.head);
expect(workerStep.role).toBe("worker");
expect(workerStep.edgePrompt).toContain("fix/42-auth");
expect(workerStep.edgePrompt).toContain("/tmp/my-repo");
expect(workerStep.edgePrompt).toBe("Work on branch fix/42-auth in /tmp/my-repo");
});
test("7. completed thread can be resumed (衔尾蛇: end → start)", async () => {
// Reuse the suspend workflow (planner with ready → $END), but mock data
// goes straight to ready on first run, then ready again after resume.
await writeMockConfig("e2e-completed-resume.mock.yaml");
const workflowHash = await addWorkflow("e2e-suspend.workflow.yaml", "test-suspend");
const start = await cmdThreadStart(uwfHome, workflowHash, "Do the work", uwfHome, tmpDir);
const threadId = start.thread;
// Step 1: planner outputs ready → $END → thread completed.
const step1 = execStep(threadId);
expect(step1.done).toBe(true);
expect(step1.status).toBe("completed");
const uwf1 = await createUwfStore(uwfHome);
const entry1 = getThread(uwf1.varStore, threadId);
expect(entry1).not.toBeNull();
expect(entry1!.status).toBe("completed");
// Resume the completed thread — should re-evaluate $START → planner.
const resumeResult = runResume(threadId, "Additional context for round 2");
expect(resumeResult.exitCode).toBe(0);
// After resume step, planner ran again (step index 1 in mock) → ready → $END.
const uwf2 = await createUwfStore(uwfHome);
const entry2 = getThread(uwf2.varStore, threadId);
expect(entry2).not.toBeNull();
expect(entry2!.status).toBe("completed");
// Head should have advanced (not the same as step1).
expect(entry2!.head).not.toBe(step1.head);
// CAS chain: step2.prev === step1 head (chain is preserved across resume).
const store = await openStore(casDir);
const resumeOutput = JSON.parse(resumeResult.stdout.trim());
const step2Node = getStepNode(store, resumeOutput.head);
expect(step2Node.role).toBe("planner");
expect(step2Node.prev).toBe(step1.head);
});
});
@@ -0,0 +1,15 @@
steps:
# Step 0: planner → ready → $END (thread completes)
- role: planner
output: |
---
$status: ready
---
Initial plan complete.
# Step 1: after resume, planner runs again from $START → ready → $END again
- role: planner
output: |
---
$status: ready
---
Revised plan after resume.
@@ -0,0 +1,19 @@
steps:
- role: analyst
output: |
---
$status: analyzed
---
Analysis complete.
- role: developer
output: |
---
$status: implemented
---
Implementation complete.
- role: reviewer
output: |
---
$status: approved
---
Approved.
@@ -0,0 +1,45 @@
name: test-count
description: 3-step linear pipeline (analyst -> developer -> reviewer -> $END)
roles:
analyst:
description: Analyzes the task
goal: Analyze the task
capabilities: []
procedure: Analyze it
output: Output the analysis and set $status to analyzed
frontmatter:
oneOf:
- properties:
$status: { const: analyzed }
required: [$status]
developer:
description: Implements the change
goal: Implement the change
capabilities: []
procedure: Write code
output: Output the implementation and set $status to implemented
frontmatter:
oneOf:
- properties:
$status: { const: implemented }
required: [$status]
reviewer:
description: Reviews the change
goal: Review the change
capabilities: []
procedure: Review code
output: Approve and set $status to approved
frontmatter:
oneOf:
- properties:
$status: { const: approved }
required: [$status]
graph:
$START:
_: { role: analyst, prompt: 'Analyze the task' }
analyst:
analyzed: { role: developer, prompt: 'Implement the change' }
developer:
implemented: { role: reviewer, prompt: 'Review the change' }
reviewer:
approved: { role: '$END', prompt: 'Done' }
@@ -0,0 +1,15 @@
steps:
- role: planner
output: |
---
$status: ready
branch: fix/42-auth
repoPath: /tmp/my-repo
---
Planned the work.
- role: worker
output: |
---
$status: done
---
Work complete.
@@ -0,0 +1,34 @@
name: test-mustache
description: Planner emits template variables consumed by the worker edge prompt
roles:
planner:
description: Plans work and emits branch + repo path
goal: Plan the task
capabilities: []
procedure: Decide the branch and repo path
output: Set $status to ready and emit branch and repoPath
frontmatter:
oneOf:
- properties:
$status: { const: ready }
branch: { type: string }
repoPath: { type: string }
required: [$status, branch, repoPath]
worker:
description: Works on the planned branch
goal: Do the work
capabilities: []
procedure: Do it
output: Output the result and set $status to done
frontmatter:
oneOf:
- properties:
$status: { const: done }
required: [$status]
graph:
$START:
_: { role: planner, prompt: 'Plan the task' }
planner:
ready: { role: worker, prompt: 'Work on branch {{{branch}}} in {{{repoPath}}}' }
worker:
done: { role: '$END', prompt: 'Complete' }
@@ -0,0 +1,14 @@
steps:
- role: planner
output: |
---
$status: insufficient_info
reason: missing requirements
---
I need more information before I can plan this.
- role: planner
output: |
---
$status: ready
---
I now have what I need. Ready to proceed.
@@ -0,0 +1,24 @@
name: test-suspend
description: Planner can suspend for more info or finish when ready
roles:
planner:
description: Plans work and may request more info
goal: Analyze the task
capabilities: []
procedure: Analyze the task and decide if more info is needed
output: Set $status to insufficient_info (with reason) or ready
frontmatter:
oneOf:
- properties:
$status: { const: insufficient_info }
reason: { type: string }
required: [$status, reason]
- properties:
$status: { const: ready }
required: [$status]
graph:
$START:
_: { role: planner, prompt: 'Analyze the task' }
planner:
insufficient_info: { role: '$SUSPEND', prompt: 'Need more info: {{{reason}}}' }
ready: { role: '$END', prompt: 'Done' }
@@ -15,7 +15,7 @@ import {
async function makeUwfStore(storageRoot: string) {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
process.env.OCAS_DIR = casDir;
process.env.OCAS_HOME = casDir;
return createUwfStore(storageRoot);
}
@@ -72,8 +72,8 @@ async function markThreadRunning(storageRoot: string, threadId: ThreadId, workfl
async function completeThread(
storageRoot: string,
threadId: ThreadId,
workflowHash: CasRef,
headHash: CasRef,
_workflowHash: CasRef,
_headHash: CasRef,
) {
const uwfIdx = await createUwfStore(storageRoot);
completeThreadInStore(uwfIdx.varStore, threadId, "completed");
@@ -491,7 +491,7 @@ describe("uwf thread resume - completed threads", () => {
cwd: tmpDir,
});
process.env.OCAS_DIR = casDir;
process.env.OCAS_HOME = casDir;
const workerOutputHash = await store.cas.put(outputSchemaHash, { $status: "_" });
const reviewerOutputHash = await store.cas.put(outputSchemaHash, { $status: "_" });
@@ -539,9 +539,9 @@ describe("uwf thread resume - completed threads", () => {
const { createUwfStore, getThread } = await import("../store.js");
const verifyUwf = await createUwfStore(tmpDir);
const verifyEntry = getThread(verifyUwf.varStore, THREAD_ID);
// biome-ignore lint/nursery/noConsole: test debugging
// biome-ignore lint/suspicious/noConsole: test debugging
console.log("Seeded entry status:", verifyEntry?.status);
// biome-ignore lint/nursery/noConsole: test debugging
// biome-ignore lint/suspicious/noConsole: test debugging
console.log("Seeded entry:", JSON.stringify(verifyEntry, null, 2));
const promptCapturePath = join(tmpDir, "captured-prompt-completed.txt");
@@ -601,7 +601,7 @@ echo '${adapterJson}'
);
if (result.status !== 0) {
// biome-ignore lint/nursery/noConsole: test debugging
// biome-ignore lint/suspicious/noConsole: test debugging
console.error("Command failed:", result.stderr);
}
@@ -654,7 +654,7 @@ echo '${adapterJson}'
cwd: tmpDir,
});
process.env.OCAS_DIR = casDir;
process.env.OCAS_HOME = casDir;
await seedThreads(tmpDir, {
[THREAD_ID]: {
head: startHash,
@@ -702,7 +702,7 @@ echo '${adapterJson}'
cwd: tmpDir,
});
process.env.OCAS_DIR = casDir;
process.env.OCAS_HOME = casDir;
await seedThreads(tmpDir, { [THREAD_ID]: startHash });
const result = runUwf(["thread", "resume", THREAD_ID], casDir);
@@ -6,12 +6,7 @@ import type { CasRef, ThreadId } from "@united-workforce/protocol";
import { describe, expect, test } from "vitest";
import { createMarker, deleteMarker } from "../background/index.js";
import { cmdThreadShow, cmdThreadStart } from "../commands/thread.js";
import {
completeThread,
createUwfStore,
loadAllThreads,
setThread,
} from "../store.js";
import { completeThread, createUwfStore, loadAllThreads, setThread } from "../store.js";
const OUTPUT_SCHEMA = {
type: "object" as const,
@@ -205,7 +200,7 @@ describe("thread show status field", () => {
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
const workflow = startResult.workflow;
const _workflow = startResult.workflow;
// Get the head hash before moving to history
const uwfForIndex = await createUwfStore(storageRoot);
@@ -234,7 +229,7 @@ describe("thread show status field", () => {
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
const workflow = startResult.workflow;
const _workflow = startResult.workflow;
// Get the head hash before moving to history
const uwfForIndex = await createUwfStore(storageRoot);
@@ -263,7 +258,7 @@ describe("thread show status field", () => {
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
const workflow = startResult.workflow;
const _workflow = startResult.workflow;
// Get the head hash before moving to history
const uwfForIndex = await createUwfStore(storageRoot);
+9 -13
View File
@@ -585,19 +585,20 @@ async function collectActiveThreads(
}
function collectCompletedThreads(
varStore: VarStore,
uwf: UwfStore,
activeIds: Set<ThreadId>,
): ThreadListItemWithStatus[] {
const items: ThreadListItemWithStatus[] = [];
const history = loadHistoryThreads(varStore);
const history = loadHistoryThreads(uwf.varStore);
const seen = new Set<ThreadId>(); // Deduplication (issue #470)
for (const [threadId, entry] of Object.entries(history)) {
if (!activeIds.has(threadId as ThreadId) && !seen.has(threadId as ThreadId)) {
seen.add(threadId as ThreadId);
const status = entry.status;
const workflow = resolveWorkflowFromHead(uwf, entry.head);
items.push({
thread: threadId as ThreadId,
workflow: "", // Will be resolved later if needed
workflow: workflow ?? "",
head: entry.head,
status,
currentRole: null,
@@ -662,7 +663,7 @@ export async function cmdThreadList(
statusFilter.includes("cancelled");
if (includeCompleted) {
const activeIds = new Set(items.map((i) => i.thread));
const completedItems = collectCompletedThreads(uwf.varStore, activeIds);
const completedItems = collectCompletedThreads(uwf, activeIds);
items = items.concat(completedItems);
}
@@ -1030,6 +1031,7 @@ function archiveThread(uwf: UwfStore, threadId: ThreadId, _workflow: CasRef, _he
completeThread(uwf.varStore, threadId, "completed");
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: orchestration function with inherent branching
export async function cmdThreadResume(
storageRoot: string,
threadId: ThreadId,
@@ -1056,13 +1058,7 @@ export async function cmdThreadResume(
if (entry.status === "completed" || entry.status === "cancelled") {
status = entry.status;
} else {
status = await resolveActiveThreadStatus(
storageRoot,
threadId,
uwf,
headHash,
workflowHash,
);
status = await resolveActiveThreadStatus(storageRoot, threadId, uwf, headHash, workflowHash);
}
if (status !== "suspended" && status !== "completed") {
@@ -1277,7 +1273,7 @@ function resolveResumeStepTarget(
}
async function resolveModeratorStepTarget(
storageRoot: string,
_storageRoot: string,
threadId: ThreadId,
entry: ThreadIndexEntry,
headHash: CasRef,
@@ -1346,7 +1342,7 @@ async function resolveModeratorStepTarget(
}
async function finalizeAgentStep(
storageRoot: string,
_storageRoot: string,
threadId: ThreadId,
workflowHash: CasRef,
workflow: WorkflowPayload,
+1 -10
View File
@@ -6,13 +6,7 @@ import { join } from "node:path";
import { bootstrap, type Hash, type Store, type VarStore } from "@ocas/core";
import { createFsStore, createSqliteVarStore } from "@ocas/fs";
import type {
CasRef,
ThreadId,
ThreadIndexEntry,
ThreadListItem,
ThreadsIndex,
} from "@united-workforce/protocol";
import type { CasRef, ThreadId, ThreadIndexEntry, ThreadsIndex } from "@united-workforce/protocol";
import { parseThreadsIndex } from "@united-workforce/protocol";
import { parse } from "yaml";
@@ -26,7 +20,6 @@ export const REGISTRY_VAR_PREFIX = "@uwf/registry/";
/** Variable name prefix for active thread entries (`@uwf/thread/<thread-id>`). */
export const THREAD_VAR_PREFIX = "@uwf/thread/";
/** A workflow entry discovered from the project-local .workflows/ directory. */
export type ProjectWorkflowEntry = {
/** Workflow name (from YAML `name` field, equals filename stem). */
@@ -154,7 +147,6 @@ export function getThreadsPath(storageRoot: string): string {
return join(storageRoot, "threads.yaml");
}
export type UwfStore = {
storageRoot: string;
store: Store;
@@ -387,7 +379,6 @@ export function completeThread(
setThread(varStore, threadId, completed);
}
type LegacyHistoryEntry = {
thread: ThreadId;
workflow: CasRef;
+1
View File
@@ -96,6 +96,7 @@ export function checkWorkflowFilenameConsistency(
}
/** Validate YAML-parsed workflow document shape (outputSchema may be inline JSON Schema). */
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: validation function with many field checks
export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
if (!isRecord(raw)) {
return null;
+10 -2
View File
@@ -21,7 +21,10 @@ export function normalizeThreadIndexEntry(raw: unknown): ThreadIndexEntry | null
head: head as CasRef,
suspendedRole: typeof suspendedRole === "string" ? suspendedRole : null,
suspendMessage: typeof suspendMessage === "string" ? suspendMessage : null,
status: typeof status === "string" ? (status as "idle" | "running" | "suspended" | "completed" | "cancelled") : "idle",
status:
typeof status === "string"
? (status as "idle" | "running" | "suspended" | "completed" | "cancelled")
: "idle",
completedAt: typeof completedAt === "number" ? completedAt : null,
};
}
@@ -79,7 +82,12 @@ export function serializeThreadIndexEntry(
entry: ThreadIndexEntry,
): string | Record<string, string | number> {
// Compact string only for idle status with no suspend metadata
if (entry.status === "idle" && entry.suspendedRole === null && entry.suspendMessage === null && entry.completedAt === null) {
if (
entry.status === "idle" &&
entry.suspendedRole === null &&
entry.suspendMessage === null &&
entry.completedAt === null
) {
return entry.head;
}
@@ -1,15 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseArgv } from "../src/run.js";
describe("parseArgv", () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let stderrSpy: ReturnType<typeof vi.spyOn>;
let _stderrSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as never);
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((() => true) as never);
_stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((() => true) as never);
});
afterEach(() => {
@@ -17,22 +17,37 @@ describe("parseArgv", () => {
});
it("returns threadId, role, prompt for valid argv", () => {
const result = parseArgv(["node", "script", "--thread", "abc123", "--role", "developer", "--prompt", "do stuff"]);
const result = parseArgv([
"node",
"script",
"--thread",
"abc123",
"--role",
"developer",
"--prompt",
"do stuff",
]);
expect(result).toEqual({ threadId: "abc123", role: "developer", prompt: "do stuff" });
});
it("exits when --thread is missing", () => {
expect(() => parseArgv(["node", "script", "--role", "dev", "--prompt", "x"])).toThrow("process.exit");
expect(() => parseArgv(["node", "script", "--role", "dev", "--prompt", "x"])).toThrow(
"process.exit",
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("exits when --role is missing", () => {
expect(() => parseArgv(["node", "script", "--thread", "t1", "--prompt", "x"])).toThrow("process.exit");
expect(() => parseArgv(["node", "script", "--thread", "t1", "--prompt", "x"])).toThrow(
"process.exit",
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("exits when --prompt is missing", () => {
expect(() => parseArgv(["node", "script", "--thread", "t1", "--role", "dev"])).toThrow("process.exit");
expect(() => parseArgv(["node", "script", "--thread", "t1", "--role", "dev"])).toThrow(
"process.exit",
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
+18 -13
View File
@@ -1,14 +1,14 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
import { describe, expect, it } from "vitest";
import {
resolveStorageRoot,
getDefaultStorageRoot,
getCasDir,
getConfigPath,
getDefaultStorageRoot,
getEnvPath,
getGlobalCasDir,
normalizeWorkflowConfig,
resolveStorageRoot,
} from "../src/storage.js";
const VALID_CONFIG = {
@@ -79,28 +79,33 @@ describe("normalizeWorkflowConfig", () => {
});
it("throws when defaultAgent missing", () => {
expect(() => normalizeWorkflowConfig({ ...VALID_CONFIG, defaultAgent: undefined }))
.toThrow("defaultAgent and defaultModel");
expect(() => normalizeWorkflowConfig({ ...VALID_CONFIG, defaultAgent: undefined })).toThrow(
"defaultAgent and defaultModel",
);
});
it("throws when defaultModel missing", () => {
expect(() => normalizeWorkflowConfig({ ...VALID_CONFIG, defaultModel: 42 }))
.toThrow("defaultAgent and defaultModel");
expect(() => normalizeWorkflowConfig({ ...VALID_CONFIG, defaultModel: 42 })).toThrow(
"defaultAgent and defaultModel",
);
});
it("throws on invalid providers entry", () => {
expect(() => normalizeWorkflowConfig({ ...VALID_CONFIG, providers: { bad: "string" } }))
.toThrow("config.providers.bad must be a mapping");
expect(() =>
normalizeWorkflowConfig({ ...VALID_CONFIG, providers: { bad: "string" } }),
).toThrow("config.providers.bad must be a mapping");
});
it("throws on invalid models entry", () => {
expect(() => normalizeWorkflowConfig({ ...VALID_CONFIG, models: { m: { provider: 123, name: "x" } } }))
.toThrow("config.models.m requires provider and name");
expect(() =>
normalizeWorkflowConfig({ ...VALID_CONFIG, models: { m: { provider: 123, name: "x" } } }),
).toThrow("config.models.m requires provider and name");
});
it("throws on invalid agents entry", () => {
expect(() => normalizeWorkflowConfig({ ...VALID_CONFIG, agents: "bad" }))
.toThrow("config.agents must be a mapping");
expect(() => normalizeWorkflowConfig({ ...VALID_CONFIG, agents: "bad" })).toThrow(
"config.agents must be a mapping",
);
});
it("returns null for undefined modelOverrides", () => {
+3 -3
View File
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import {
CROCKFORD_BASE32_ALPHABET,
encodeCrockfordBase32Bits,
decodeCrockfordBase32Bits,
encodeUint64AsCrockford,
decodeCrockfordToUint64,
encodeCrockfordBase32Bits,
encodeUint64AsCrockford,
} from "../src/base32.js";
describe("CROCKFORD_BASE32_ALPHABET", () => {
@@ -105,7 +105,7 @@ describe("encodeUint64AsCrockford / decodeCrockfordToUint64", () => {
});
it("roundtrips arbitrary value", () => {
const value = 0xDEAD_BEEF_CAFE_BABEn;
const value = 0xdead_beef_cafe_baben;
const encoded = encodeUint64AsCrockford(value);
const decoded = decodeCrockfordToUint64(encoded);
expect(decoded).toEqual({ ok: true, value });
+25 -25
View File
@@ -1,38 +1,38 @@
import { describe, it, expect } from 'vitest';
import { assertValidLogTag } from '../src/process-logger/log-tag.js';
import { describe, expect, it } from "vitest";
import { assertValidLogTag } from "../src/process-logger/log-tag.js";
describe('assertValidLogTag', () => {
it('accepts valid 8-char Crockford Base32 tags', () => {
expect(() => assertValidLogTag('0123ABCD')).not.toThrow();
expect(() => assertValidLogTag('VWXYZ789')).not.toThrow();
expect(() => assertValidLogTag('00000000')).not.toThrow();
expect(() => assertValidLogTag('ZZZZZZZZ')).not.toThrow();
describe("assertValidLogTag", () => {
it("accepts valid 8-char Crockford Base32 tags", () => {
expect(() => assertValidLogTag("0123ABCD")).not.toThrow();
expect(() => assertValidLogTag("VWXYZ789")).not.toThrow();
expect(() => assertValidLogTag("00000000")).not.toThrow();
expect(() => assertValidLogTag("ZZZZZZZZ")).not.toThrow();
});
it('accepts lowercase (converted via toUpperCase)', () => {
expect(() => assertValidLogTag('abcdefgh')).not.toThrow();
expect(() => assertValidLogTag('0a1b2c3d')).not.toThrow();
it("accepts lowercase (converted via toUpperCase)", () => {
expect(() => assertValidLogTag("abcdefgh")).not.toThrow();
expect(() => assertValidLogTag("0a1b2c3d")).not.toThrow();
});
it('throws on too short', () => {
expect(() => assertValidLogTag('1234567')).toThrow();
expect(() => assertValidLogTag('')).toThrow();
it("throws on too short", () => {
expect(() => assertValidLogTag("1234567")).toThrow();
expect(() => assertValidLogTag("")).toThrow();
});
it('throws on too long', () => {
expect(() => assertValidLogTag('123456789')).toThrow();
it("throws on too long", () => {
expect(() => assertValidLogTag("123456789")).toThrow();
});
it('throws on invalid chars I, L, O, U', () => {
expect(() => assertValidLogTag('IIIIIIII')).toThrow();
expect(() => assertValidLogTag('LLLLLLLL')).toThrow();
expect(() => assertValidLogTag('OOOOOOOO')).toThrow();
expect(() => assertValidLogTag('UUUUUUUU')).toThrow();
it("throws on invalid chars I, L, O, U", () => {
expect(() => assertValidLogTag("IIIIIIII")).toThrow();
expect(() => assertValidLogTag("LLLLLLLL")).toThrow();
expect(() => assertValidLogTag("OOOOOOOO")).toThrow();
expect(() => assertValidLogTag("UUUUUUUU")).toThrow();
});
it('throws on special characters', () => {
expect(() => assertValidLogTag('1234567!')).toThrow();
expect(() => assertValidLogTag('ABCD-EFG')).toThrow();
expect(() => assertValidLogTag('ABCD EFG')).toThrow();
it("throws on special characters", () => {
expect(() => assertValidLogTag("1234567!")).toThrow();
expect(() => assertValidLogTag("ABCD-EFG")).toThrow();
expect(() => assertValidLogTag("ABCD EFG")).toThrow();
});
});
+18 -18
View File
@@ -1,40 +1,40 @@
import { describe, it, expect } from 'vitest';
import { mergeRefsWithContentHash, normalizeRefsField } from '../src/refs-field.js';
import { describe, expect, it } from "vitest";
import { mergeRefsWithContentHash, normalizeRefsField } from "../src/refs-field.js";
describe('mergeRefsWithContentHash', () => {
it('appends a new content hash', () => {
expect(mergeRefsWithContentHash(['a', 'b'], 'c')).toEqual(['a', 'b', 'c']);
describe("mergeRefsWithContentHash", () => {
it("appends a new content hash", () => {
expect(mergeRefsWithContentHash(["a", "b"], "c")).toEqual(["a", "b", "c"]);
});
it('skips duplicate content hash', () => {
expect(mergeRefsWithContentHash(['a', 'b'], 'b')).toEqual(['a', 'b']);
it("skips duplicate content hash", () => {
expect(mergeRefsWithContentHash(["a", "b"], "b")).toEqual(["a", "b"]);
});
it('preserves order', () => {
expect(mergeRefsWithContentHash(['x', 'y'], 'z')).toEqual(['x', 'y', 'z']);
it("preserves order", () => {
expect(mergeRefsWithContentHash(["x", "y"], "z")).toEqual(["x", "y", "z"]);
});
it('handles empty refs', () => {
expect(mergeRefsWithContentHash([], 'a')).toEqual(['a']);
it("handles empty refs", () => {
expect(mergeRefsWithContentHash([], "a")).toEqual(["a"]);
});
});
describe('normalizeRefsField', () => {
it('returns empty array for non-array', () => {
describe("normalizeRefsField", () => {
it("returns empty array for non-array", () => {
expect(normalizeRefsField(null)).toEqual([]);
expect(normalizeRefsField(undefined)).toEqual([]);
expect(normalizeRefsField(42)).toEqual([]);
});
it('passes through string array', () => {
expect(normalizeRefsField(['a', 'b'])).toEqual(['a', 'b']);
it("passes through string array", () => {
expect(normalizeRefsField(["a", "b"])).toEqual(["a", "b"]);
});
it('filters non-strings from mixed array', () => {
expect(normalizeRefsField(['a', 1, 'b', null])).toEqual(['a', 'b']);
it("filters non-strings from mixed array", () => {
expect(normalizeRefsField(["a", 1, "b", null])).toEqual(["a", "b"]);
});
it('handles empty array', () => {
it("handles empty array", () => {
expect(normalizeRefsField([])).toEqual([]);
});
});
+19 -19
View File
@@ -1,36 +1,36 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '../src/result.js';
import { describe, expect, it } from "vitest";
import { err, ok } from "../src/result.js";
describe('result', () => {
describe('ok', () => {
it('wraps a value', () => {
describe("result", () => {
describe("ok", () => {
it("wraps a value", () => {
const r = ok(42);
expect(r).toEqual({ ok: true, value: 42 });
});
it('wraps a string value', () => {
const r = ok('hello');
it("wraps a string value", () => {
const r = ok("hello");
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe('hello');
if (r.ok) expect(r.value).toBe("hello");
});
});
describe('err', () => {
it('wraps an error', () => {
const r = err('fail');
expect(r).toEqual({ ok: false, error: 'fail' });
describe("err", () => {
it("wraps an error", () => {
const r = err("fail");
expect(r).toEqual({ ok: false, error: "fail" });
});
it('wraps an Error object', () => {
const e = new Error('boom');
it("wraps an Error object", () => {
const e = new Error("boom");
const r = err(e);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe(e);
});
});
describe('type narrowing', () => {
it('narrows ok result', () => {
describe("type narrowing", () => {
it("narrows ok result", () => {
const r = ok(10) as ReturnType<typeof ok<number>> | ReturnType<typeof err<string>>;
if (r.ok) {
expect(r.value).toBe(10);
@@ -39,10 +39,10 @@ describe('result', () => {
}
});
it('narrows err result', () => {
const r = err('bad') as ReturnType<typeof ok<number>> | ReturnType<typeof err<string>>;
it("narrows err result", () => {
const r = err("bad") as ReturnType<typeof ok<number>> | ReturnType<typeof err<string>>;
if (!r.ok) {
expect(r.error).toBe('bad');
expect(r.error).toBe("bad");
} else {
expect.unreachable();
}
+17 -13
View File
@@ -1,25 +1,29 @@
import { describe, it, expect } from 'vitest';
import { homedir } from 'node:os';
import { getDefaultStorageRoot, getDefaultWorkflowStorageRoot, getGlobalCasDir } from '../src/storage-root.js';
import { homedir } from "node:os";
import { describe, expect, it } from "vitest";
import {
getDefaultStorageRoot,
getDefaultWorkflowStorageRoot,
getGlobalCasDir,
} from "../src/storage-root.js";
describe('getDefaultStorageRoot', () => {
it('returns homedir + /.uwf', () => {
expect(getDefaultStorageRoot()).toBe(homedir() + '/.uwf');
describe("getDefaultStorageRoot", () => {
it("returns homedir + /.uwf", () => {
expect(getDefaultStorageRoot()).toBe(`${homedir()}/.uwf`);
});
});
describe('getDefaultWorkflowStorageRoot', () => {
it('returns same as getDefaultStorageRoot (deprecated alias)', () => {
describe("getDefaultWorkflowStorageRoot", () => {
it("returns same as getDefaultStorageRoot (deprecated alias)", () => {
expect(getDefaultWorkflowStorageRoot()).toBe(getDefaultStorageRoot());
});
});
describe('getGlobalCasDir', () => {
it('appends /cas to given storage root', () => {
expect(getGlobalCasDir('/tmp/test')).toBe('/tmp/test/cas');
describe("getGlobalCasDir", () => {
it("appends /cas to given storage root", () => {
expect(getGlobalCasDir("/tmp/test")).toBe("/tmp/test/cas");
});
it('falls back to default when undefined', () => {
expect(getGlobalCasDir(undefined)).toBe(homedir() + '/.uwf/cas');
it("falls back to default when undefined", () => {
expect(getGlobalCasDir(undefined)).toBe(`${homedir()}/.uwf/cas`);
});
});