test: add unit tests for core modules (#35)
CI / check (pull_request) Failing after 1m39s

Cover high-priority untested modules:
- util: base32, result, refs-field, storage-root, log-tag
- util-agent: storage (normalizeWorkflowConfig, resolveStorageRoot), run (parseArgv)
- agent-builtin: tools (read-file, write-file, run-command), session, detail

627 → 719 tests (+92), all passing.

Refs #35
This commit is contained in:
2026-06-04 04:35:33 +00:00
parent c3ec4ac6df
commit 06e959e7a5
14 changed files with 873 additions and 0 deletions
+1
View File
@@ -25,6 +25,7 @@
"@types/node": "^25.7.0",
"@types/xxhashjs": "^0.2.4",
"@united-workforce/agent-hermes": "workspace:*",
"@vitest/coverage-v8": "^4.1.8",
"typescript": "^5.8.3",
"vitest": "^3.2.1",
"yaml": "^2.9.0"
@@ -0,0 +1,49 @@
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 { storeBuiltinDetail } from "../src/detail.js";
import { appendSessionTurn, initSessionDir } from "../src/session.js";
import type { BuiltinTurnPayload } from "../src/types.js";
describe("storeBuiltinDetail", () => {
let storageRoot: string;
beforeEach(async () => {
storageRoot = await mkdtemp(join(tmpdir(), "builtin-detail-storage-"));
});
afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true });
});
const makeTurn = (role: "assistant" | "tool", content: string): BuiltinTurnPayload => ({
role,
content,
toolCalls: null,
reasoning: null,
});
test("stores detail with turns, returns hash and turnCount", async () => {
const store = createMemoryStore();
await initSessionDir(storageRoot);
const sid = "detail-test";
await appendSessionTurn(storageRoot, sid, makeTurn("tool", "question"));
await appendSessionTurn(storageRoot, sid, makeTurn("assistant", "answer"));
const result = await storeBuiltinDetail(store, storageRoot, sid, "test-model", 1000, 2000);
expect(result.turnCount).toBe(2);
expect(typeof result.detailHash).toBe("string");
expect(result.detailHash.length).toBeGreaterThan(0);
});
test("empty session returns turnCount 0", async () => {
const store = createMemoryStore();
const sid = "empty-session";
const result = await storeBuiltinDetail(store, storageRoot, sid, "test-model", 1000, 2000);
expect(result.turnCount).toBe(0);
expect(typeof result.detailHash).toBe("string");
});
});
@@ -0,0 +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 { tmpdir } from "node:os";
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");
});
afterAll(async () => {
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("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 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 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/);
});
});
@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { runCommandTool } from "../src/tools/run-command.js";
import { tmpdir } from "node:os";
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("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 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("custom cwd works", async () => {
const result = await runCommandTool.execute({ command: "pwd", cwd: "/tmp" }, ctx);
expect(result).toContain("/tmp");
});
});
@@ -0,0 +1,65 @@
import { existsSync } from "node:fs";
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";
describe("session", () => {
let storageRoot: string;
beforeEach(async () => {
storageRoot = await mkdtemp(join(tmpdir(), "builtin-session-"));
});
afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true });
});
const makeTurn = (role: "assistant" | "tool", content: string): BuiltinTurnPayload => ({
role,
content,
toolCalls: null,
reasoning: null,
});
test("initSessionDir creates directory", async () => {
await initSessionDir(storageRoot);
expect(existsSync(join(storageRoot, "sessions"))).toBe(true);
});
test("append + read roundtrip", async () => {
await initSessionDir(storageRoot);
const sid = "test-session-1";
const t1 = makeTurn("tool", "hello");
const t2 = makeTurn("assistant", "hi there");
await appendSessionTurn(storageRoot, sid, t1);
await appendSessionTurn(storageRoot, sid, t2);
const turns = await readSessionTurns(storageRoot, sid);
expect(turns).toEqual([t1, t2]);
});
test("read from non-existent returns []", async () => {
const turns = await readSessionTurns(storageRoot, "no-such-session");
expect(turns).toEqual([]);
});
test("removeSession deletes file", async () => {
await initSessionDir(storageRoot);
const sid = "to-remove";
await appendSessionTurn(storageRoot, sid, makeTurn("tool", "bye"));
await removeSession(storageRoot, sid);
const turns = await readSessionTurns(storageRoot, sid);
expect(turns).toEqual([]);
});
test("removeSession on non-existent does not throw", async () => {
await expect(removeSession(storageRoot, "ghost")).resolves.toBeUndefined();
});
});
@@ -0,0 +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";
const testDir = join(tmpdir(), `write-file-test-${Date.now()}`);
const ctx = { cwd: testDir, storageRoot: testDir };
afterAll(async () => {
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("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 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");
});
});
@@ -0,0 +1,38 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { parseArgv } from "../src/run.js";
describe("parseArgv", () => {
let exitSpy: 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);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns threadId, role, prompt for valid argv", () => {
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(exitSpy).toHaveBeenCalledWith(1);
});
it("exits when --role is missing", () => {
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(exitSpy).toHaveBeenCalledWith(1);
});
});
@@ -0,0 +1,165 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
resolveStorageRoot,
getDefaultStorageRoot,
getCasDir,
getConfigPath,
getEnvPath,
getGlobalCasDir,
normalizeWorkflowConfig,
} from "../src/storage.js";
const VALID_CONFIG = {
defaultAgent: "builtin",
defaultModel: "main",
providers: { openai: { baseUrl: "https://api.openai.com/v1", apiKey: "sk-xxx" } },
models: { main: { provider: "openai", name: "gpt-4" } },
agents: { builtin: { command: "uwf-builtin", args: ["--verbose"] } },
};
describe("getDefaultStorageRoot", () => {
it("returns homedir/.uwf", () => {
expect(getDefaultStorageRoot()).toBe(join(homedir(), ".uwf"));
});
});
describe("resolveStorageRoot", () => {
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
saved.UWF_STORAGE_ROOT = process.env.UWF_STORAGE_ROOT;
saved.WORKFLOW_STORAGE_ROOT = process.env.WORKFLOW_STORAGE_ROOT;
});
afterEach(() => {
for (const k of ["UWF_STORAGE_ROOT", "WORKFLOW_STORAGE_ROOT"] as const) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
});
it("uses UWF_STORAGE_ROOT first", () => {
process.env.UWF_STORAGE_ROOT = "/tmp/uwf1";
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/uwf2";
expect(resolveStorageRoot()).toBe("/tmp/uwf1");
});
it("falls back to WORKFLOW_STORAGE_ROOT", () => {
delete process.env.UWF_STORAGE_ROOT;
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/uwf2";
expect(resolveStorageRoot()).toBe("/tmp/uwf2");
});
it("falls back to default when both unset", () => {
delete process.env.UWF_STORAGE_ROOT;
delete process.env.WORKFLOW_STORAGE_ROOT;
expect(resolveStorageRoot()).toBe(getDefaultStorageRoot());
});
it("ignores empty UWF_STORAGE_ROOT", () => {
process.env.UWF_STORAGE_ROOT = "";
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/uwf2";
expect(resolveStorageRoot()).toBe("/tmp/uwf2");
});
});
describe("path helpers", () => {
it("getCasDir", () => expect(getCasDir("/root")).toBe("/root/cas"));
it("getConfigPath", () => expect(getConfigPath("/root")).toBe("/root/config.yaml"));
it("getEnvPath", () => expect(getEnvPath("/root")).toBe("/root/.env"));
});
describe("getGlobalCasDir", () => {
const saved = { OCAS_DIR: process.env.OCAS_DIR };
afterEach(() => {
if (saved.OCAS_DIR === undefined) delete process.env.OCAS_DIR;
else process.env.OCAS_DIR = saved.OCAS_DIR;
});
it("uses OCAS_DIR when set", () => {
process.env.OCAS_DIR = "/tmp/ocas";
expect(getGlobalCasDir()).toBe("/tmp/ocas");
});
it("defaults to ~/.ocas", () => {
delete process.env.OCAS_DIR;
expect(getGlobalCasDir()).toBe(join(homedir(), ".ocas"));
});
});
describe("normalizeWorkflowConfig", () => {
it("normalizes a valid config", () => {
const result = normalizeWorkflowConfig(VALID_CONFIG);
expect(result.defaultAgent).toBe("builtin");
expect(result.defaultModel).toBe("main");
expect(result.providers.openai.baseUrl).toBe("https://api.openai.com/v1");
expect(result.models.main.name).toBe("gpt-4");
expect(result.agents.builtin.command).toBe("uwf-builtin");
expect(result.agents.builtin.args).toEqual(["--verbose"]);
expect(result.modelOverrides).toBeNull();
expect(result.agentOverrides).toBeNull();
});
it("throws on non-record root", () => {
expect(() => normalizeWorkflowConfig("bad")).toThrow("root must be a mapping");
expect(() => normalizeWorkflowConfig(null)).toThrow("root must be a mapping");
expect(() => normalizeWorkflowConfig([])).toThrow("root must be a mapping");
});
it("throws when defaultAgent missing", () => {
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");
});
it("throws on invalid providers entry", () => {
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");
});
it("throws on invalid agents entry", () => {
expect(() => normalizeWorkflowConfig({ ...VALID_CONFIG, agents: "bad" }))
.toThrow("config.agents must be a mapping");
});
it("returns null for undefined modelOverrides", () => {
const result = normalizeWorkflowConfig(VALID_CONFIG);
expect(result.modelOverrides).toBeNull();
});
it("returns null for null agentOverrides", () => {
const result = normalizeWorkflowConfig({ ...VALID_CONFIG, agentOverrides: null });
expect(result.agentOverrides).toBeNull();
});
it("normalizes agentOverrides with nested roles", () => {
const config = {
...VALID_CONFIG,
agentOverrides: {
"solve-issue": { coder: "hermes", reviewer: "claude" },
},
};
const result = normalizeWorkflowConfig(config);
expect(result.agentOverrides).toEqual({
"solve-issue": { coder: "hermes", reviewer: "claude" },
});
});
it("normalizes modelOverrides", () => {
const config = { ...VALID_CONFIG, modelOverrides: { coding: "fast" } };
const result = normalizeWorkflowConfig(config);
expect(result.modelOverrides).toEqual({ coding: "fast" });
});
});
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import {
CROCKFORD_BASE32_ALPHABET,
encodeCrockfordBase32Bits,
decodeCrockfordBase32Bits,
encodeUint64AsCrockford,
decodeCrockfordToUint64,
} from "../src/base32.js";
describe("CROCKFORD_BASE32_ALPHABET", () => {
it("has exactly 32 characters", () => {
expect(CROCKFORD_BASE32_ALPHABET).toHaveLength(32);
});
it("excludes I, L, O, U", () => {
expect(CROCKFORD_BASE32_ALPHABET).not.toContain("I");
expect(CROCKFORD_BASE32_ALPHABET).not.toContain("L");
expect(CROCKFORD_BASE32_ALPHABET).not.toContain("O");
expect(CROCKFORD_BASE32_ALPHABET).not.toContain("U");
});
});
describe("encodeCrockfordBase32Bits / decodeCrockfordBase32Bits", () => {
it("roundtrips zero with bitLength=5", () => {
const encoded = encodeCrockfordBase32Bits(0n, 5);
expect(encoded).toBe("0");
const decoded = decodeCrockfordBase32Bits(encoded, 5);
expect(decoded).toEqual({ ok: true, value: 0n });
});
it("roundtrips value 31 with bitLength=5", () => {
const encoded = encodeCrockfordBase32Bits(31n, 5);
expect(encoded).toBe("Z");
const decoded = decodeCrockfordBase32Bits(encoded, 5);
expect(decoded).toEqual({ ok: true, value: 31n });
});
it("roundtrips with bitLength=10", () => {
const encoded = encodeCrockfordBase32Bits(1023n, 10);
expect(encoded).toBe("ZZ");
const decoded = decodeCrockfordBase32Bits(encoded, 10);
expect(decoded).toEqual({ ok: true, value: 1023n });
});
it("roundtrips with non-multiple-of-5 bitLength", () => {
const value = 255n; // 8 bits
const encoded = encodeCrockfordBase32Bits(value, 8);
expect(encoded).toHaveLength(2); // 8 bits -> 10 bits padded -> 2 chars
const decoded = decodeCrockfordBase32Bits(encoded, 8);
expect(decoded).toEqual({ ok: true, value });
});
it("roundtrips large value", () => {
const value = (1n << 64n) - 1n;
const encoded = encodeCrockfordBase32Bits(value, 64);
const decoded = decodeCrockfordBase32Bits(encoded, 64);
expect(decoded).toEqual({ ok: true, value });
});
it("throws on bitLength <= 0", () => {
expect(() => encodeCrockfordBase32Bits(0n, 0)).toThrow("bitLength must be positive");
expect(() => encodeCrockfordBase32Bits(0n, -1)).toThrow("bitLength must be positive");
});
it("returns error on decode with bitLength <= 0", () => {
const result = decodeCrockfordBase32Bits("0", 0);
expect(result.ok).toBe(false);
});
it("returns error on invalid character", () => {
const result = decodeCrockfordBase32Bits("U", 5);
expect(result.ok).toBe(false);
});
it("returns error on wrong encoded length", () => {
const result = decodeCrockfordBase32Bits("00", 5);
expect(result.ok).toBe(false);
});
it("handles lowercase input on decode", () => {
const encoded = encodeCrockfordBase32Bits(10n, 5);
const decoded = decodeCrockfordBase32Bits(encoded.toLowerCase(), 5);
expect(decoded).toEqual({ ok: true, value: 10n });
});
});
describe("encodeUint64AsCrockford / decodeCrockfordToUint64", () => {
it("encodes to 13 characters", () => {
expect(encodeUint64AsCrockford(0n)).toHaveLength(13);
expect(encodeUint64AsCrockford(1n)).toHaveLength(13);
});
it("roundtrips 0n", () => {
const encoded = encodeUint64AsCrockford(0n);
expect(encoded).toBe("0000000000000");
const decoded = decodeCrockfordToUint64(encoded);
expect(decoded).toEqual({ ok: true, value: 0n });
});
it("roundtrips max uint64", () => {
const max = (1n << 64n) - 1n;
const encoded = encodeUint64AsCrockford(max);
const decoded = decodeCrockfordToUint64(encoded);
expect(decoded).toEqual({ ok: true, value: max });
});
it("roundtrips arbitrary value", () => {
const value = 0xDEAD_BEEF_CAFE_BABEn;
const encoded = encodeUint64AsCrockford(value);
const decoded = decodeCrockfordToUint64(encoded);
expect(decoded).toEqual({ ok: true, value });
});
it("masks values beyond 64 bits", () => {
const over = (1n << 64n) + 42n;
const encoded = encodeUint64AsCrockford(over);
const decoded = decodeCrockfordToUint64(encoded);
expect(decoded).toEqual({ ok: true, value: 42n });
});
it("returns error for invalid input", () => {
const result = decodeCrockfordToUint64("!!!");
expect(result.ok).toBe(false);
});
it("returns error for wrong length", () => {
const result = decodeCrockfordToUint64("000");
expect(result.ok).toBe(false);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } 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();
});
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 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 special characters', () => {
expect(() => assertValidLogTag('1234567!')).toThrow();
expect(() => assertValidLogTag('ABCD-EFG')).toThrow();
expect(() => assertValidLogTag('ABCD EFG')).toThrow();
});
});
@@ -0,0 +1,40 @@
import { describe, it, expect } 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']);
});
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('handles empty refs', () => {
expect(mergeRefsWithContentHash([], 'a')).toEqual(['a']);
});
});
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('filters non-strings from mixed array', () => {
expect(normalizeRefsField(['a', 1, 'b', null])).toEqual(['a', 'b']);
});
it('handles empty array', () => {
expect(normalizeRefsField([])).toEqual([]);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '../src/result.js';
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');
expect(r.ok).toBe(true);
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' });
});
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', () => {
const r = ok(10) as ReturnType<typeof ok<number>> | ReturnType<typeof err<string>>;
if (r.ok) {
expect(r.value).toBe(10);
} else {
expect.unreachable();
}
});
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');
} else {
expect.unreachable();
}
});
});
});
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { homedir } from 'node:os';
import { getDefaultStorageRoot, getDefaultWorkflowStorageRoot, getGlobalCasDir } from '../src/storage-root.js';
describe('getDefaultStorageRoot', () => {
it('returns homedir + /.uwf', () => {
expect(getDefaultStorageRoot()).toBe(homedir() + '/.uwf');
});
});
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');
});
it('falls back to default when undefined', () => {
expect(getGlobalCasDir(undefined)).toBe(homedir() + '/.uwf/cas');
});
});
+139
View File
@@ -29,6 +29,9 @@ importers:
'@united-workforce/agent-hermes':
specifier: workspace:*
version: link:packages/agent-hermes
'@vitest/coverage-v8':
specifier: ^4.1.8
version: 4.1.8(vitest@3.2.6(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.9.3))(yaml@2.9.0))
typescript:
specifier: ^5.8.3
version: 5.9.3
@@ -411,6 +414,10 @@ packages:
'@types/react':
optional: true
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@biomejs/biome@2.4.16':
resolution: {integrity: sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==}
engines: {node: '>=14.21.3'}
@@ -1293,6 +1300,15 @@ packages:
babel-plugin-react-compiler:
optional: true
'@vitest/coverage-v8@4.1.8':
resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==}
peerDependencies:
'@vitest/browser': 4.1.8
vitest: 4.1.8
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@3.2.6':
resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==}
@@ -1310,6 +1326,9 @@ packages:
'@vitest/pretty-format@3.2.6':
resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==}
'@vitest/pretty-format@4.1.8':
resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
'@vitest/runner@3.2.6':
resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==}
@@ -1322,6 +1341,9 @@ packages:
'@vitest/utils@3.2.6':
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
'@vitest/utils@4.1.8':
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
'@xyflow/react@12.11.0':
resolution: {integrity: sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==}
peerDependencies:
@@ -1391,6 +1413,9 @@ packages:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
engines: {node: '>=4'}
ast-v8-to-istanbul@1.0.3:
resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
@@ -1952,6 +1977,10 @@ packages:
resolution: {integrity: sha512-cQOsSMS/IrDz82PVyRDvf/Q1F/bRbBVjJlh+xYOkI1qw2bWRvWGiWc+m2O0d6l4Bt1fyY+8kzJ8JFWGJqNeDBg==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
@@ -1967,6 +1996,9 @@ packages:
resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==}
engines: {node: '>=16.9.0'}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -2106,6 +2138,18 @@ packages:
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
engines: {node: '>=18'}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
@@ -2113,6 +2157,9 @@ packages:
jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2267,6 +2314,13 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magicast@0.5.3:
resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -2383,6 +2437,9 @@ packages:
resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==}
engines: {node: '>= 10'}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@@ -2731,6 +2788,9 @@ packages:
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
stdin-discarder@0.2.2:
resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
engines: {node: '>=18'}
@@ -2777,6 +2837,10 @@ packages:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
engines: {node: '>=18'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
@@ -2816,6 +2880,10 @@ packages:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
tinyspy@4.0.4:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
@@ -3334,6 +3402,8 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.16
'@bcoe/v8-coverage@1.0.2': {}
'@biomejs/biome@2.4.16':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.4.16
@@ -4093,6 +4163,20 @@ snapshots:
'@rolldown/pluginutils': 1.0.1
vite: 8.0.16(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)
'@vitest/coverage-v8@4.1.8(vitest@3.2.6(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.9.3))(yaml@2.9.0))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.8
ast-v8-to-istanbul: 1.0.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.3
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 3.2.6(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.9.3))(yaml@2.9.0)
'@vitest/expect@3.2.6':
dependencies:
'@types/chai': 5.2.3
@@ -4114,6 +4198,10 @@ snapshots:
dependencies:
tinyrainbow: 2.0.0
'@vitest/pretty-format@4.1.8':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@3.2.6':
dependencies:
'@vitest/utils': 3.2.6
@@ -4136,6 +4224,12 @@ snapshots:
loupe: 3.2.1
tinyrainbow: 2.0.0
'@vitest/utils@4.1.8':
dependencies:
'@vitest/pretty-format': 4.1.8
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@xyflow/react@12.11.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(immer@11.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@xyflow/system': 0.0.77
@@ -4203,6 +4297,12 @@ snapshots:
dependencies:
tslib: 2.8.1
ast-v8-to-istanbul@1.0.3:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
balanced-match@4.0.4: {}
baseline-browser-mapping@2.10.33: {}
@@ -4760,6 +4860,8 @@ snapshots:
graphql@16.14.1: {}
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
hasown@2.0.4:
@@ -4773,6 +4875,8 @@ snapshots:
hono@4.12.23: {}
html-escaper@2.0.2: {}
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -4869,10 +4973,25 @@ snapshots:
isexe@3.1.5: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
jiti@2.7.0: {}
jose@6.2.3: {}
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -4990,6 +5109,16 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.5.3:
dependencies:
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.8.1
math-intrinsics@1.1.0: {}
media-typer@1.1.0: {}
@@ -5085,6 +5214,8 @@ snapshots:
object-treeify@1.1.33: {}
obug@2.1.1: {}
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@@ -5497,6 +5628,8 @@ snapshots:
std-env@3.10.0: {}
std-env@4.1.0: {}
stdin-discarder@0.2.2: {}
strict-event-emitter@0.5.1: {}
@@ -5541,6 +5674,10 @@ snapshots:
dependencies:
'@tokenizer/token': 0.3.0
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
tagged-tag@1.0.0: {}
tailwind-merge@3.6.0: {}
@@ -5566,6 +5703,8 @@ snapshots:
tinyrainbow@2.0.0: {}
tinyrainbow@3.1.0: {}
tinyspy@4.0.4: {}
tldts-core@7.4.2: {}