@usezombie/zombiectl 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +76 -0
  2. package/bin/zombiectl.js +11 -0
  3. package/bun.lock +29 -0
  4. package/package.json +28 -0
  5. package/scripts/run-tests.mjs +38 -0
  6. package/src/cli.js +275 -0
  7. package/src/commands/admin.js +39 -0
  8. package/src/commands/agent.js +98 -0
  9. package/src/commands/agent_harness.js +43 -0
  10. package/src/commands/agent_improvement_report.js +42 -0
  11. package/src/commands/agent_profile.js +39 -0
  12. package/src/commands/agent_proposals.js +158 -0
  13. package/src/commands/agent_scores.js +44 -0
  14. package/src/commands/core-ops.js +108 -0
  15. package/src/commands/core.js +537 -0
  16. package/src/commands/harness.js +35 -0
  17. package/src/commands/harness_activate.js +53 -0
  18. package/src/commands/harness_active.js +32 -0
  19. package/src/commands/harness_compile.js +40 -0
  20. package/src/commands/harness_source.js +72 -0
  21. package/src/commands/run_preview.js +212 -0
  22. package/src/commands/run_preview_walk.js +1 -0
  23. package/src/commands/runs.js +35 -0
  24. package/src/commands/spec_init.js +287 -0
  25. package/src/commands/workspace_billing.js +26 -0
  26. package/src/constants/error-codes.js +1 -0
  27. package/src/lib/agent-loop.js +106 -0
  28. package/src/lib/analytics.js +114 -0
  29. package/src/lib/api-paths.js +2 -0
  30. package/src/lib/browser.js +96 -0
  31. package/src/lib/http.js +149 -0
  32. package/src/lib/sse-parser.js +50 -0
  33. package/src/lib/state.js +67 -0
  34. package/src/lib/tool-executors.js +110 -0
  35. package/src/lib/walk-dir.js +41 -0
  36. package/src/program/args.js +95 -0
  37. package/src/program/auth-guard.js +12 -0
  38. package/src/program/auth-token.js +44 -0
  39. package/src/program/banner.js +46 -0
  40. package/src/program/command-registry.js +17 -0
  41. package/src/program/http-client.js +38 -0
  42. package/src/program/io.js +83 -0
  43. package/src/program/routes.js +20 -0
  44. package/src/program/suggest.js +76 -0
  45. package/src/program/validate.js +24 -0
  46. package/src/ui-progress.js +59 -0
  47. package/src/ui-theme.js +62 -0
  48. package/test/admin_config.unit.test.js +25 -0
  49. package/test/agent-loop.unit.test.js +497 -0
  50. package/test/agent_harness.unit.test.js +52 -0
  51. package/test/agent_improvement_report.unit.test.js +74 -0
  52. package/test/agent_profile.unit.test.js +156 -0
  53. package/test/agent_proposals.unit.test.js +167 -0
  54. package/test/agent_scores.unit.test.js +220 -0
  55. package/test/analytics.unit.test.js +41 -0
  56. package/test/args.unit.test.js +69 -0
  57. package/test/auth-guard.test.js +33 -0
  58. package/test/auth-token.unit.test.js +112 -0
  59. package/test/banner.unit.test.js +442 -0
  60. package/test/browser.unit.test.js +16 -0
  61. package/test/cli-analytics.unit.test.js +296 -0
  62. package/test/did-you-mean.integration.test.js +76 -0
  63. package/test/doctor-json.test.js +81 -0
  64. package/test/error-codes.unit.test.js +7 -0
  65. package/test/harness-command.unit.test.js +180 -0
  66. package/test/harness-compile.test.js +81 -0
  67. package/test/harness-lifecycle.integration.test.js +339 -0
  68. package/test/harness-source-put.test.js +72 -0
  69. package/test/harness_activate.unit.test.js +48 -0
  70. package/test/harness_active.unit.test.js +53 -0
  71. package/test/harness_compile.unit.test.js +54 -0
  72. package/test/harness_source.unit.test.js +59 -0
  73. package/test/help.test.js +276 -0
  74. package/test/helpers-fs.js +32 -0
  75. package/test/helpers.js +31 -0
  76. package/test/io.unit.test.js +57 -0
  77. package/test/login.unit.test.js +115 -0
  78. package/test/logout.unit.test.js +65 -0
  79. package/test/parse.test.js +16 -0
  80. package/test/run-preview.edge.test.js +422 -0
  81. package/test/run-preview.integration.test.js +135 -0
  82. package/test/run-preview.security.test.js +246 -0
  83. package/test/run-preview.unit.test.js +131 -0
  84. package/test/run.unit.test.js +149 -0
  85. package/test/runs-cancel.unit.test.js +288 -0
  86. package/test/runs-list.unit.test.js +105 -0
  87. package/test/skill-secret.unit.test.js +94 -0
  88. package/test/spec-init.edge.test.js +232 -0
  89. package/test/spec-init.integration.test.js +128 -0
  90. package/test/spec-init.security.test.js +285 -0
  91. package/test/spec-init.unit.test.js +160 -0
  92. package/test/specs-sync.unit.test.js +164 -0
  93. package/test/sse-parser.unit.test.js +54 -0
  94. package/test/state.unit.test.js +34 -0
  95. package/test/streamfetch.unit.test.js +211 -0
  96. package/test/suggest.test.js +75 -0
  97. package/test/tool-executors.unit.test.js +165 -0
  98. package/test/validate.test.js +81 -0
  99. package/test/workspace-add.test.js +106 -0
  100. package/test/workspace.unit.test.js +230 -0
@@ -0,0 +1,160 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { parseMakeTargets, detectTestPatterns, detectProjectStructure, generateTemplate } from "../src/commands/spec_init.js";
3
+ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import os from "node:os";
6
+
7
+ // ── parseMakeTargets ──────────────────────────────────────────────────────────
8
+
9
+ describe("parseMakeTargets", () => {
10
+ let tmpDir;
11
+
12
+ function setup(makefileContent) {
13
+ tmpDir = mkdirSync(join(os.tmpdir(), `spec-init-test-${Date.now()}`), { recursive: true }) || join(os.tmpdir(), `spec-init-test-${Date.now()}`);
14
+ // mkdirSync returns undefined on success for existing dirs; use a unique path
15
+ tmpDir = join(os.tmpdir(), `spec-init-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
16
+ mkdirSync(tmpDir, { recursive: true });
17
+ if (makefileContent !== null) writeFileSync(join(tmpDir, "Makefile"), makefileContent);
18
+ return tmpDir;
19
+ }
20
+
21
+ function cleanup() {
22
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
23
+ }
24
+
25
+ test("returns empty array when no Makefile", () => {
26
+ setup(null);
27
+ try {
28
+ expect(parseMakeTargets(tmpDir)).toEqual([]);
29
+ } finally {
30
+ cleanup();
31
+ }
32
+ });
33
+
34
+ test("parses standard make targets", () => {
35
+ setup("lint:\n\techo lint\n\ntest:\n\techo test\n\nbuild:\n\techo build\n");
36
+ try {
37
+ const targets = parseMakeTargets(tmpDir);
38
+ expect(targets).toContain("lint");
39
+ expect(targets).toContain("test");
40
+ expect(targets).toContain("build");
41
+ } finally {
42
+ cleanup();
43
+ }
44
+ });
45
+
46
+ test("ignores hidden/special targets starting with dot", () => {
47
+ setup(".PHONY: lint\nlint:\n\techo lint\n");
48
+ try {
49
+ const targets = parseMakeTargets(tmpDir);
50
+ expect(targets).not.toContain(".PHONY");
51
+ expect(targets).toContain("lint");
52
+ } finally {
53
+ cleanup();
54
+ }
55
+ });
56
+
57
+ test("parses targets with hyphens and underscores", () => {
58
+ setup("lint-zig:\n\tzig fmt\ntest_unit:\n\tbun test\n");
59
+ try {
60
+ const targets = parseMakeTargets(tmpDir);
61
+ expect(targets).toContain("lint-zig");
62
+ expect(targets).toContain("test_unit");
63
+ } finally {
64
+ cleanup();
65
+ }
66
+ });
67
+ });
68
+
69
+ // ── detectTestPatterns ────────────────────────────────────────────────────────
70
+
71
+ describe("detectTestPatterns", () => {
72
+ test("returns empty for no test files", () => {
73
+ expect(detectTestPatterns(["src/main.go", "src/server.go"])).toEqual([]);
74
+ });
75
+
76
+ test("detects tests/ directory", () => {
77
+ const patterns = detectTestPatterns(["tests/foo_test.go"]);
78
+ expect(patterns).toContain("tests/ directory");
79
+ });
80
+
81
+ test("detects *.test.* pattern", () => {
82
+ const patterns = detectTestPatterns(["src/foo.test.js"]);
83
+ expect(patterns.some((p) => p.includes("test"))).toBe(true);
84
+ });
85
+
86
+ test("detects *_test.* pattern (Go)", () => {
87
+ const patterns = detectTestPatterns(["src/server_test.go"]);
88
+ expect(patterns).toContain("*_test.*");
89
+ });
90
+
91
+ test("deduplicates patterns", () => {
92
+ const files = ["a.test.js", "b.test.js", "c.test.ts"];
93
+ const patterns = detectTestPatterns(files);
94
+ const counts = patterns.filter((p) => p.includes("test")).length;
95
+ expect(counts).toBeLessThanOrEqual(2);
96
+ });
97
+ });
98
+
99
+ // ── detectProjectStructure ────────────────────────────────────────────────────
100
+
101
+ describe("detectProjectStructure", () => {
102
+ let tmpDir;
103
+
104
+ function setup(dirs) {
105
+ tmpDir = join(os.tmpdir(), `spec-proj-${Date.now()}-${Math.random().toString(36).slice(2)}`);
106
+ mkdirSync(tmpDir, { recursive: true });
107
+ for (const d of dirs) mkdirSync(join(tmpDir, d), { recursive: true });
108
+ return tmpDir;
109
+ }
110
+
111
+ function cleanup() {
112
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
113
+ }
114
+
115
+ test("returns empty for bare repo", () => {
116
+ setup([]);
117
+ try {
118
+ expect(detectProjectStructure(tmpDir)).toEqual([]);
119
+ } finally {
120
+ cleanup();
121
+ }
122
+ });
123
+
124
+ test("detects src/ and docs/", () => {
125
+ setup(["src", "docs"]);
126
+ try {
127
+ const structure = detectProjectStructure(tmpDir);
128
+ expect(structure).toContain("src/");
129
+ expect(structure).toContain("docs/");
130
+ } finally {
131
+ cleanup();
132
+ }
133
+ });
134
+ });
135
+
136
+ // ── generateTemplate ──────────────────────────────────────────────────────────
137
+
138
+ describe("generateTemplate", () => {
139
+ test("includes detected make targets in gates section", () => {
140
+ const scan = { makeTargets: ["lint", "test", "build"], testPatterns: [], projectStructure: ["src/"] };
141
+ const tpl = generateTemplate(scan);
142
+ expect(tpl).toContain("make lint");
143
+ expect(tpl).toContain("make test");
144
+ });
145
+
146
+ test("produces valid template with empty gates section when no Makefile", () => {
147
+ const scan = { makeTargets: [], testPatterns: [], projectStructure: [] };
148
+ const tpl = generateTemplate(scan);
149
+ expect(tpl).toContain("no Makefile gates detected");
150
+ expect(tpl).toContain("Acceptance Criteria");
151
+ expect(tpl).toContain("PENDING");
152
+ });
153
+
154
+ test("includes detected project structure", () => {
155
+ const scan = { makeTargets: [], testPatterns: [], projectStructure: ["src/", "docs/"] };
156
+ const tpl = generateTemplate(scan);
157
+ expect(tpl).toContain("`src/`");
158
+ expect(tpl).toContain("`docs/`");
159
+ });
160
+ });
@@ -0,0 +1,164 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { makeNoop, makeBufferStream, ui, WS_ID } from "./helpers.js";
3
+ import { createCoreHandlers } from "../src/commands/core.js";
4
+ import { ApiError } from "../src/lib/http.js";
5
+ import { ERR_BILLING_CREDIT_EXHAUSTED } from "../src/constants/error-codes.js";
6
+
7
+ function makeDeps(overrides = {}) {
8
+ return {
9
+ clearCredentials: async () => {},
10
+ createSpinner: () => ({ start() {}, succeed() {}, fail() {} }),
11
+ newIdempotencyKey: () => "idem_test",
12
+ openUrl: async () => false,
13
+ parseFlags: (tokens) => {
14
+ const options = {};
15
+ const positionals = [];
16
+ for (let i = 0; i < tokens.length; i++) {
17
+ if (tokens[i].startsWith("--")) {
18
+ const key = tokens[i].slice(2);
19
+ const next = tokens[i + 1];
20
+ if (next && !next.startsWith("--")) { options[key] = next; i++; }
21
+ else options[key] = true;
22
+ } else { positionals.push(tokens[i]); }
23
+ }
24
+ return { options, positionals };
25
+ },
26
+ printJson: (_s, v) => {},
27
+ printKeyValue: (stream, rows) => {
28
+ for (const [key, value] of Object.entries(rows)) stream.write(`${key}: ${value}\n`);
29
+ },
30
+ printSection: (stream, title) => stream.write(`${title}\n`),
31
+ printTable: () => {},
32
+ request: async () => ({}),
33
+ saveCredentials: async () => {},
34
+ saveWorkspaces: async () => {},
35
+ ui,
36
+ writeLine: (stream, line = "") => stream.write(`${line}\n`),
37
+ apiHeaders: () => ({}),
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ describe("commandSpecsSync", () => {
43
+ test("successful sync with workspace", async () => {
44
+ const out = makeBufferStream();
45
+ let calledPath = null;
46
+ const deps = makeDeps({
47
+ request: async (_ctx, reqPath) => {
48
+ calledPath = reqPath;
49
+ return { synced_count: 3, total_pending: 0, plan_tier: "free", credit_remaining_cents: 1000, credit_currency: "USD" };
50
+ },
51
+ });
52
+ const ctx = { stdout: out.stream, stderr: makeNoop(), jsonMode: false, env: {} };
53
+ const workspaces = { current_workspace_id: WS_ID, items: [] };
54
+ const core = createCoreHandlers(ctx, workspaces, deps);
55
+ const code = await core.commandSpecsSync([]);
56
+ expect(code).toBe(0);
57
+ expect(calledPath).toContain(WS_ID);
58
+ expect(out.read()).toContain("Specs synced");
59
+ expect(out.read()).toContain(WS_ID);
60
+ expect(out.read()).toContain("credit_remaining_cents: 1000");
61
+ });
62
+
63
+ test("missing workspace_id error", async () => {
64
+ const err = makeBufferStream();
65
+ const deps = makeDeps();
66
+ const ctx = { stdout: makeNoop(), stderr: err.stream, jsonMode: false, env: {} };
67
+ const workspaces = { current_workspace_id: null, items: [] };
68
+ const core = createCoreHandlers(ctx, workspaces, deps);
69
+ const code = await core.commandSpecsSync([]);
70
+ expect(code).toBe(2);
71
+ expect(err.read()).toContain("workspace_id required");
72
+ });
73
+
74
+ test("JSON mode output", async () => {
75
+ let printed = null;
76
+ const deps = makeDeps({
77
+ request: async () => ({ synced_count: 5, total_pending: 2 }),
78
+ printJson: (_s, v) => { printed = v; },
79
+ });
80
+ const ctx = { stdout: makeNoop(), stderr: makeNoop(), jsonMode: true, env: {} };
81
+ const workspaces = { current_workspace_id: WS_ID, items: [] };
82
+ const core = createCoreHandlers(ctx, workspaces, deps);
83
+ const code = await core.commandSpecsSync([]);
84
+ expect(code).toBe(0);
85
+ expect(printed.synced_count).toBe(5);
86
+ });
87
+
88
+ test("prints upgrade path when free credit is exhausted", async () => {
89
+ const out = makeBufferStream();
90
+ const deps = makeDeps({
91
+ request: async () => ({ synced_count: 0, total_pending: 2, plan_tier: "free", credit_remaining_cents: 0, credit_currency: "USD" }),
92
+ });
93
+ const ctx = { stdout: out.stream, stderr: makeNoop(), jsonMode: false, env: {} };
94
+ const workspaces = { current_workspace_id: WS_ID, items: [] };
95
+ const core = createCoreHandlers(ctx, workspaces, deps);
96
+ const code = await core.commandSpecsSync([]);
97
+ expect(code).toBe(0);
98
+ expect(out.read()).toContain("Upgrade path:");
99
+ expect(out.read()).toContain("workspace upgrade-scale");
100
+ });
101
+
102
+ test("prints upgrade path when sync is rejected with credit exhausted", async () => {
103
+ const err = makeBufferStream();
104
+ const deps = makeDeps({
105
+ request: async () => {
106
+ throw new ApiError("Free plan credit exhausted. Upgrade to Scale to continue.", {
107
+ status: 403,
108
+ code: ERR_BILLING_CREDIT_EXHAUSTED,
109
+ });
110
+ },
111
+ });
112
+ const ctx = { stdout: makeNoop(), stderr: err.stream, jsonMode: false, env: {} };
113
+ const workspaces = { current_workspace_id: WS_ID, items: [] };
114
+ const core = createCoreHandlers(ctx, workspaces, deps);
115
+
116
+ await expect(core.commandSpecsSync([])).rejects.toMatchObject({ code: ERR_BILLING_CREDIT_EXHAUSTED });
117
+ expect(err.read()).toContain("Upgrade path:");
118
+ expect(err.read()).toContain(`--workspace-id ${WS_ID}`);
119
+ });
120
+
121
+ test("JSON mode error path does NOT print upgrade hint on credit exhausted", async () => {
122
+ const err = makeBufferStream();
123
+ const deps = makeDeps({
124
+ request: async () => {
125
+ throw new ApiError("Free plan credit exhausted.", {
126
+ status: 403,
127
+ code: ERR_BILLING_CREDIT_EXHAUSTED,
128
+ });
129
+ },
130
+ });
131
+ const ctx = { stdout: makeNoop(), stderr: err.stream, jsonMode: true, env: {} };
132
+ const workspaces = { current_workspace_id: WS_ID, items: [] };
133
+ const core = createCoreHandlers(ctx, workspaces, deps);
134
+
135
+ await expect(core.commandSpecsSync([])).rejects.toMatchObject({ code: ERR_BILLING_CREDIT_EXHAUSTED });
136
+ expect(err.read()).not.toContain("Upgrade path:");
137
+ });
138
+
139
+ test("negative credit_remaining_cents triggers upgrade hint", async () => {
140
+ const out = makeBufferStream();
141
+ const deps = makeDeps({
142
+ request: async () => ({ synced_count: 1, total_pending: 0, plan_tier: "free", credit_remaining_cents: -50, credit_currency: "USD" }),
143
+ });
144
+ const ctx = { stdout: out.stream, stderr: makeNoop(), jsonMode: false, env: {} };
145
+ const workspaces = { current_workspace_id: WS_ID, items: [] };
146
+ const core = createCoreHandlers(ctx, workspaces, deps);
147
+ const code = await core.commandSpecsSync([]);
148
+ expect(code).toBe(0);
149
+ expect(out.read()).toContain("Upgrade path:");
150
+ });
151
+
152
+ test("missing credit_remaining_cents does NOT trigger upgrade hint", async () => {
153
+ const out = makeBufferStream();
154
+ const deps = makeDeps({
155
+ request: async () => ({ synced_count: 1, total_pending: 0, plan_tier: "free" }),
156
+ });
157
+ const ctx = { stdout: out.stream, stderr: makeNoop(), jsonMode: false, env: {} };
158
+ const workspaces = { current_workspace_id: WS_ID, items: [] };
159
+ const core = createCoreHandlers(ctx, workspaces, deps);
160
+ const code = await core.commandSpecsSync([]);
161
+ expect(code).toBe(0);
162
+ expect(out.read()).not.toContain("Upgrade path:");
163
+ });
164
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { parseSseBuffer } from "../src/lib/sse-parser.js";
3
+
4
+ describe("parseSseBuffer", () => {
5
+ test("parses single tool_use event", () => {
6
+ const buf = 'event: tool_use\ndata: {"id":"tu_01","name":"read_file","input":{"path":"go.mod"}}\n\n';
7
+ const { events, remainder } = parseSseBuffer(buf);
8
+ expect(events).toHaveLength(1);
9
+ expect(events[0].type).toBe("tool_use");
10
+ expect(events[0].data.name).toBe("read_file");
11
+ expect(events[0].data.input.path).toBe("go.mod");
12
+ expect(remainder).toBe("");
13
+ });
14
+
15
+ test("parses multiple events", () => {
16
+ const buf = 'event: text_delta\ndata: {"text":"hello"}\n\nevent: done\ndata: {"usage":{"total_tokens":100}}\n\n';
17
+ const result = parseSseBuffer(buf);
18
+ expect(result.events).toHaveLength(2);
19
+ expect(result.events[0].type).toBe("text_delta");
20
+ expect(result.events[0].data.text).toBe("hello");
21
+ expect(result.events[1].type).toBe("done");
22
+ expect(result.events[1].data.usage.total_tokens).toBe(100);
23
+ expect(result.remainder).toBe("");
24
+ });
25
+
26
+ test("returns remainder for incomplete frame", () => {
27
+ const buf = 'event: text_delta\ndata: {"text":"partial';
28
+ const { events, remainder } = parseSseBuffer(buf);
29
+ expect(events).toHaveLength(0);
30
+ expect(remainder).toBe(buf);
31
+ });
32
+
33
+ test("skips heartbeat comments", () => {
34
+ const buf = ': heartbeat\n\nevent: done\ndata: {"ok":true}\n\n';
35
+ const { events } = parseSseBuffer(buf);
36
+ // heartbeat-only frame produces null (no data line), only done event returned
37
+ expect(events).toHaveLength(1);
38
+ expect(events[0].type).toBe("done");
39
+ });
40
+
41
+ test("handles empty buffer", () => {
42
+ const { events, remainder } = parseSseBuffer("");
43
+ expect(events).toHaveLength(0);
44
+ expect(remainder).toBe("");
45
+ });
46
+
47
+ test("parses error event", () => {
48
+ const buf = 'event: error\ndata: {"message":"provider timeout"}\n\n';
49
+ const { events } = parseSseBuffer(buf);
50
+ expect(events).toHaveLength(1);
51
+ expect(events[0].type).toBe("error");
52
+ expect(events[0].data.message).toBe("provider timeout");
53
+ });
54
+ });
@@ -0,0 +1,34 @@
1
+ import { test } from "bun:test";
2
+ import assert from "node:assert/strict";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { stateInternals } from "../src/lib/state.js";
7
+
8
+ test("resolveStatePaths defaults to XDG-style zombiectl config directory", () => {
9
+ const previous = process.env.ZOMBIE_STATE_DIR;
10
+ delete process.env.ZOMBIE_STATE_DIR;
11
+ try {
12
+ const paths = stateInternals.resolveStatePaths();
13
+ const expectedBase = path.join(os.homedir(), ".config", "zombiectl");
14
+ assert.equal(paths.baseDir, expectedBase);
15
+ assert.equal(paths.credentialsPath, path.join(expectedBase, "credentials.json"));
16
+ assert.equal(paths.workspacesPath, path.join(expectedBase, "workspaces.json"));
17
+ } finally {
18
+ if (previous !== undefined) process.env.ZOMBIE_STATE_DIR = previous;
19
+ }
20
+ });
21
+
22
+ test("resolveStatePaths honors ZOMBIE_STATE_DIR override", () => {
23
+ const previous = process.env.ZOMBIE_STATE_DIR;
24
+ process.env.ZOMBIE_STATE_DIR = "/tmp/zombiectl-state-test";
25
+ try {
26
+ const paths = stateInternals.resolveStatePaths();
27
+ assert.equal(paths.baseDir, "/tmp/zombiectl-state-test");
28
+ assert.equal(paths.credentialsPath, "/tmp/zombiectl-state-test/credentials.json");
29
+ assert.equal(paths.workspacesPath, "/tmp/zombiectl-state-test/workspaces.json");
30
+ } finally {
31
+ if (previous === undefined) delete process.env.ZOMBIE_STATE_DIR;
32
+ else process.env.ZOMBIE_STATE_DIR = previous;
33
+ }
34
+ });
@@ -0,0 +1,211 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { streamFetch, ApiError } from "../src/lib/http.js";
3
+
4
+ // ── Test helpers ─────────────────────────────────────────────────────────────
5
+
6
+ function sseResponseFrom(sseBody, status = 200) {
7
+ const encoder = new TextEncoder();
8
+ return {
9
+ ok: status >= 200 && status < 300,
10
+ status,
11
+ statusText: status === 200 ? "OK" : "Error",
12
+ headers: new Headers(),
13
+ text: async () => sseBody,
14
+ body: {
15
+ getReader() {
16
+ let sent = false;
17
+ return {
18
+ read() {
19
+ if (!sent) { sent = true; return Promise.resolve({ done: false, value: encoder.encode(sseBody) }); }
20
+ return Promise.resolve({ done: true });
21
+ },
22
+ };
23
+ },
24
+ },
25
+ };
26
+ }
27
+
28
+ // ── T1: Happy path ──────────────────────────────────────────────────────────
29
+
30
+ describe("streamFetch — happy path", () => {
31
+ test("parses SSE events and calls onEvent for each", async () => {
32
+ const events = [];
33
+ const fetchImpl = async () => sseResponseFrom(
34
+ 'event: tool_use\ndata: {"id":"tu_01","name":"read_file"}\n\nevent: done\ndata: {"ok":true}\n\n'
35
+ );
36
+ await streamFetch("https://api.test.com/v1/x", {}, {}, (e) => events.push(e), { fetchImpl });
37
+ expect(events).toHaveLength(2);
38
+ expect(events[0].type).toBe("tool_use");
39
+ expect(events[0].data.name).toBe("read_file");
40
+ expect(events[1].type).toBe("done");
41
+ });
42
+
43
+ test("merges custom headers with content-type and accept", async () => {
44
+ let capturedHeaders = null;
45
+ const fetchImpl = async (url, opts) => {
46
+ capturedHeaders = opts.headers;
47
+ return sseResponseFrom('event: done\ndata: {}\n\n');
48
+ };
49
+ await streamFetch("https://api.test.com/v1/x", { msg: 1 },
50
+ { Authorization: "Bearer tok" }, () => {}, { fetchImpl });
51
+ expect(capturedHeaders.Authorization).toBe("Bearer tok");
52
+ expect(capturedHeaders["Content-Type"]).toBe("application/json");
53
+ expect(capturedHeaders.Accept).toBe("text/event-stream");
54
+ });
55
+
56
+ test("sends JSON-stringified payload as body", async () => {
57
+ let capturedBody = null;
58
+ const fetchImpl = async (url, opts) => {
59
+ capturedBody = opts.body;
60
+ return sseResponseFrom('event: done\ndata: {}\n\n');
61
+ };
62
+ await streamFetch("https://api.test.com/v1/x", { messages: ["hello"], tools: [] },
63
+ {}, () => {}, { fetchImpl });
64
+ const parsed = JSON.parse(capturedBody);
65
+ expect(parsed.messages).toEqual(["hello"]);
66
+ });
67
+ });
68
+
69
+ // ── T2: Edge cases ──────────────────────────────────────────────────────────
70
+
71
+ describe("streamFetch — edge cases", () => {
72
+ test("handles empty SSE body (no events)", async () => {
73
+ const events = [];
74
+ const fetchImpl = async () => sseResponseFrom("");
75
+ await streamFetch("https://api.test.com/v1/x", {}, {}, (e) => events.push(e), { fetchImpl });
76
+ expect(events).toHaveLength(0);
77
+ });
78
+
79
+ test("skips heartbeat comments", async () => {
80
+ const events = [];
81
+ const fetchImpl = async () => sseResponseFrom(': heartbeat\n\nevent: done\ndata: {"ok":true}\n\n');
82
+ await streamFetch("https://api.test.com/v1/x", {}, {}, (e) => events.push(e), { fetchImpl });
83
+ expect(events).toHaveLength(1);
84
+ expect(events[0].type).toBe("done");
85
+ });
86
+
87
+ test("handles multi-chunk delivery (split across reads)", async () => {
88
+ const encoder = new TextEncoder();
89
+ const chunk1 = 'event: text_delta\ndata: {"te';
90
+ const chunk2 = 'xt":"hello"}\n\nevent: done\ndata: {}\n\n';
91
+ let readCount = 0;
92
+ const fetchImpl = async () => ({
93
+ ok: true, status: 200, body: {
94
+ getReader() {
95
+ return {
96
+ read() {
97
+ readCount++;
98
+ if (readCount === 1) return Promise.resolve({ done: false, value: encoder.encode(chunk1) });
99
+ if (readCount === 2) return Promise.resolve({ done: false, value: encoder.encode(chunk2) });
100
+ return Promise.resolve({ done: true });
101
+ },
102
+ };
103
+ },
104
+ },
105
+ });
106
+ const events = [];
107
+ await streamFetch("https://api.test.com/v1/x", {}, {}, (e) => events.push(e), { fetchImpl });
108
+ expect(events).toHaveLength(2);
109
+ expect(events[0].data.text).toBe("hello");
110
+ });
111
+ });
112
+
113
+ // ── T3: Error paths ─────────────────────────────────────────────────────────
114
+
115
+ describe("streamFetch — error paths", () => {
116
+ test("non-200 response throws ApiError with parsed error code", async () => {
117
+ const fetchImpl = async () => ({
118
+ ok: false,
119
+ status: 403,
120
+ statusText: "Forbidden",
121
+ text: async () => JSON.stringify({ error: { code: "UZ-AUTH-001", message: "access denied" } }),
122
+ });
123
+ try {
124
+ await streamFetch("https://api.test.com/v1/x", {}, {}, () => {}, { fetchImpl });
125
+ expect(true).toBe(false);
126
+ } catch (err) {
127
+ expect(err).toBeInstanceOf(ApiError);
128
+ expect(err.status).toBe(403);
129
+ expect(err.code).toBe("UZ-AUTH-001");
130
+ }
131
+ });
132
+
133
+ test("non-200 with non-JSON body still throws ApiError", async () => {
134
+ const fetchImpl = async () => ({
135
+ ok: false,
136
+ status: 502,
137
+ statusText: "Bad Gateway",
138
+ text: async () => "upstream error",
139
+ });
140
+ try {
141
+ await streamFetch("https://api.test.com/v1/x", {}, {}, () => {}, { fetchImpl });
142
+ expect(true).toBe(false);
143
+ } catch (err) {
144
+ expect(err).toBeInstanceOf(ApiError);
145
+ expect(err.code).toBe("HTTP_502");
146
+ }
147
+ });
148
+
149
+ test("timeout throws ApiError with TIMEOUT code", async () => {
150
+ const fetchImpl = async (url, opts) => {
151
+ // Simulate slow response — wait longer than timeoutMs
152
+ await new Promise((resolve, reject) => {
153
+ opts.signal.addEventListener("abort", () => {
154
+ reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
155
+ });
156
+ });
157
+ };
158
+ try {
159
+ await streamFetch("https://api.test.com/v1/x", {}, {}, () => {}, {
160
+ fetchImpl,
161
+ timeoutMs: 50,
162
+ });
163
+ expect(true).toBe(false);
164
+ } catch (err) {
165
+ expect(err).toBeInstanceOf(ApiError);
166
+ expect(err.code).toBe("TIMEOUT");
167
+ }
168
+ });
169
+
170
+ test("read error mid-stream propagates", async () => {
171
+ const fetchImpl = async () => ({
172
+ ok: true,
173
+ status: 200,
174
+ body: {
175
+ getReader() {
176
+ return {
177
+ read() { return Promise.reject(new Error("socket hang up")); },
178
+ };
179
+ },
180
+ },
181
+ });
182
+ try {
183
+ await streamFetch("https://api.test.com/v1/x", {}, {}, () => {}, { fetchImpl });
184
+ expect(true).toBe(false);
185
+ } catch (err) {
186
+ expect(err.message).toContain("socket hang up");
187
+ }
188
+ });
189
+ });
190
+
191
+ // ── T4: Output fidelity — SSE parsing correctness ───────────────────────────
192
+
193
+ describe("streamFetch — SSE parsing fidelity", () => {
194
+ test("parses event with JSON data containing nested objects", async () => {
195
+ const events = [];
196
+ const fetchImpl = async () => sseResponseFrom(
197
+ 'event: tool_use\ndata: {"id":"tu_01","name":"read_file","input":{"path":"src/main.go"}}\n\n'
198
+ );
199
+ await streamFetch("https://api.test.com/v1/x", {}, {}, (e) => events.push(e), { fetchImpl });
200
+ expect(events[0].data.input.path).toBe("src/main.go");
201
+ });
202
+
203
+ test("handles data with unicode content", async () => {
204
+ const events = [];
205
+ const fetchImpl = async () => sseResponseFrom(
206
+ 'event: text_delta\ndata: {"text":"中文テスト 👨‍💻"}\n\n'
207
+ );
208
+ await streamFetch("https://api.test.com/v1/x", {}, {}, (e) => events.push(e), { fetchImpl });
209
+ expect(events[0].data.text).toBe("中文テスト 👨‍💻");
210
+ });
211
+ });