@app-vitals/agent-runtime 0.0.0-stage → 0.1.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.
- package/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +40 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for agent/src/runtime/setup.ts — installPlugins local marketplace seam
|
|
3
|
+
*
|
|
4
|
+
* Pure logic tests: no I/O, no real binaries, injected exec doubles only.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "bun:test";
|
|
8
|
+
import { discoverBakedMarketplaces, installPlugins } from "./setup.ts";
|
|
9
|
+
|
|
10
|
+
// Explicit empty extra-marketplace list — every call in this describe block
|
|
11
|
+
// must pass this (or an intentional array) instead of omitting the arg.
|
|
12
|
+
// Omitting it falls through to discoverBakedMarketplaces(BAKED_MARKETPLACES_ROOT),
|
|
13
|
+
// which reads the REAL /opt/shipwright/marketplaces directory on disk and makes
|
|
14
|
+
// these tests depend on ambient sandbox/image state.
|
|
15
|
+
const NO_EXTRA_MARKETPLACES: string[] = [];
|
|
16
|
+
|
|
17
|
+
describe("installPlugins — local marketplace", () => {
|
|
18
|
+
it("registers the marketplace before installing plugins", async () => {
|
|
19
|
+
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
20
|
+
const mockExec = async (
|
|
21
|
+
cmd: string,
|
|
22
|
+
args: string[],
|
|
23
|
+
_opts: { cwd: string },
|
|
24
|
+
) => {
|
|
25
|
+
calls.push({ cmd, args });
|
|
26
|
+
return { stdout: "", exitCode: 0 };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
await installPlugins(
|
|
30
|
+
mockExec,
|
|
31
|
+
"/tmp/cwd",
|
|
32
|
+
[],
|
|
33
|
+
"/repo/root",
|
|
34
|
+
"/tmp/nonexistent-manifest.json",
|
|
35
|
+
NO_EXTRA_MARKETPLACES,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// First call must be marketplace add
|
|
39
|
+
expect(calls[0].args).toEqual([
|
|
40
|
+
"plugin",
|
|
41
|
+
"marketplace",
|
|
42
|
+
"add",
|
|
43
|
+
"/repo/root",
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("installs and updates shipwright@shipwright using the shipwright marketplace name", async () => {
|
|
48
|
+
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
49
|
+
const mockExec = async (
|
|
50
|
+
cmd: string,
|
|
51
|
+
args: string[],
|
|
52
|
+
_opts: { cwd: string },
|
|
53
|
+
) => {
|
|
54
|
+
calls.push({ cmd, args });
|
|
55
|
+
return { stdout: "", exitCode: 0 };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
await installPlugins(
|
|
59
|
+
mockExec,
|
|
60
|
+
"/tmp/cwd",
|
|
61
|
+
[],
|
|
62
|
+
"/repo/root",
|
|
63
|
+
"/tmp/nonexistent-manifest.json",
|
|
64
|
+
NO_EXTRA_MARKETPLACES,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// marketplace add + install + update = 3 calls
|
|
68
|
+
expect(calls).toHaveLength(3);
|
|
69
|
+
expect(calls[1].args).toEqual([
|
|
70
|
+
"plugin",
|
|
71
|
+
"install",
|
|
72
|
+
"shipwright@shipwright",
|
|
73
|
+
]);
|
|
74
|
+
expect(calls[2].args).toEqual([
|
|
75
|
+
"plugin",
|
|
76
|
+
"update",
|
|
77
|
+
"shipwright@shipwright",
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("agent plugins use their own marketplace, not the local one", async () => {
|
|
82
|
+
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
83
|
+
const mockExec = async (
|
|
84
|
+
cmd: string,
|
|
85
|
+
args: string[],
|
|
86
|
+
_opts: { cwd: string },
|
|
87
|
+
) => {
|
|
88
|
+
calls.push({ cmd, args });
|
|
89
|
+
return { stdout: "", exitCode: 0 };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
await installPlugins(
|
|
93
|
+
mockExec,
|
|
94
|
+
"/tmp/cwd",
|
|
95
|
+
[{ plugin: "my-plugin", marketplace: "org/my-marketplace" }],
|
|
96
|
+
"/repo/root",
|
|
97
|
+
"/tmp/nonexistent-manifest.json",
|
|
98
|
+
NO_EXTRA_MARKETPLACES,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// marketplace add + 2 installs + 2 updates = 5 calls
|
|
102
|
+
expect(calls).toHaveLength(5);
|
|
103
|
+
const specs = calls.slice(1).map((c) => c.args[2]);
|
|
104
|
+
expect(specs).toEqual([
|
|
105
|
+
"shipwright@shipwright",
|
|
106
|
+
"my-plugin@org/my-marketplace",
|
|
107
|
+
"shipwright@shipwright",
|
|
108
|
+
"my-plugin@org/my-marketplace",
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("passes the repoRoot to marketplace add", async () => {
|
|
113
|
+
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
114
|
+
const mockExec = async (
|
|
115
|
+
cmd: string,
|
|
116
|
+
args: string[],
|
|
117
|
+
_opts: { cwd: string },
|
|
118
|
+
) => {
|
|
119
|
+
calls.push({ cmd, args });
|
|
120
|
+
return { stdout: "", exitCode: 0 };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
await installPlugins(
|
|
124
|
+
mockExec,
|
|
125
|
+
"/tmp/cwd",
|
|
126
|
+
[],
|
|
127
|
+
"/custom/repo/root",
|
|
128
|
+
"/tmp/nonexistent-manifest.json",
|
|
129
|
+
NO_EXTRA_MARKETPLACES,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(calls[0].args).toEqual([
|
|
133
|
+
"plugin",
|
|
134
|
+
"marketplace",
|
|
135
|
+
"add",
|
|
136
|
+
"/custom/repo/root",
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("registers extra marketplaces before the /app marketplace", async () => {
|
|
141
|
+
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
142
|
+
const mockExec = async (
|
|
143
|
+
cmd: string,
|
|
144
|
+
args: string[],
|
|
145
|
+
_opts: { cwd: string },
|
|
146
|
+
) => {
|
|
147
|
+
calls.push({ cmd, args });
|
|
148
|
+
return { stdout: "", exitCode: 0 };
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Pass a non-existent root so discoverBakedMarketplaces can't find dirs —
|
|
152
|
+
// we inject via extraMarketplaceDirs directly instead.
|
|
153
|
+
await installPlugins(
|
|
154
|
+
mockExec,
|
|
155
|
+
"/tmp/cwd",
|
|
156
|
+
[],
|
|
157
|
+
"/repo/root",
|
|
158
|
+
"/tmp/nonexistent-manifest.json",
|
|
159
|
+
["/opt/shipwright/marketplaces/my-marketplace"],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Extra marketplace add must come BEFORE the /repo/root marketplace add
|
|
163
|
+
const addCalls = calls.filter((c) => c.args[1] === "marketplace");
|
|
164
|
+
expect(addCalls[0].args[3]).toBe(
|
|
165
|
+
"/opt/shipwright/marketplaces/my-marketplace",
|
|
166
|
+
);
|
|
167
|
+
expect(addCalls[1].args[3]).toBe("/repo/root");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("is a no-op when no extra marketplace dirs exist", async () => {
|
|
171
|
+
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
172
|
+
const mockExec = async (
|
|
173
|
+
cmd: string,
|
|
174
|
+
args: string[],
|
|
175
|
+
_opts: { cwd: string },
|
|
176
|
+
) => {
|
|
177
|
+
calls.push({ cmd, args });
|
|
178
|
+
return { stdout: "", exitCode: 0 };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
await installPlugins(
|
|
182
|
+
mockExec,
|
|
183
|
+
"/tmp/cwd",
|
|
184
|
+
[],
|
|
185
|
+
"/repo/root",
|
|
186
|
+
"/tmp/nonexistent-manifest.json",
|
|
187
|
+
NO_EXTRA_MARKETPLACES,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// Only the /repo/root marketplace add — no extra adds
|
|
191
|
+
const addCalls = calls.filter((c) => c.args[1] === "marketplace");
|
|
192
|
+
expect(addCalls).toHaveLength(1);
|
|
193
|
+
expect(addCalls[0].args[3]).toBe("/repo/root");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("never reads the real BAKED_MARKETPLACES_ROOT when extraMarketplaceDirs is injected (isolation regression)", async () => {
|
|
197
|
+
// Regression test for T-002: previously, tests that omitted the
|
|
198
|
+
// extraMarketplaceDirs arg silently fell through to
|
|
199
|
+
// discoverBakedMarketplaces(BAKED_MARKETPLACES_ROOT), which performs a
|
|
200
|
+
// real existsSync/readdirSync against /opt/shipwright/marketplaces. On any
|
|
201
|
+
// image/sandbox that bakes a marketplace there (this sandbox does), that
|
|
202
|
+
// extra ambient `marketplace add` call would silently appear before the
|
|
203
|
+
// expected calls and break exact-match assertions below.
|
|
204
|
+
//
|
|
205
|
+
// This test asserts the injected empty array wins deterministically,
|
|
206
|
+
// regardless of what (if anything) actually exists on disk at
|
|
207
|
+
// /opt/shipwright/marketplaces in the environment running the suite.
|
|
208
|
+
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
209
|
+
const mockExec = async (
|
|
210
|
+
cmd: string,
|
|
211
|
+
args: string[],
|
|
212
|
+
_opts: { cwd: string },
|
|
213
|
+
) => {
|
|
214
|
+
calls.push({ cmd, args });
|
|
215
|
+
return { stdout: "", exitCode: 0 };
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
await installPlugins(
|
|
219
|
+
mockExec,
|
|
220
|
+
"/tmp/cwd",
|
|
221
|
+
[],
|
|
222
|
+
"/repo/root",
|
|
223
|
+
"/tmp/nonexistent-manifest.json",
|
|
224
|
+
NO_EXTRA_MARKETPLACES,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// Exactly one marketplace add (the built-in shipwright one) — no extra
|
|
228
|
+
// marketplace add sneaks in from real fs discovery, no matter what's
|
|
229
|
+
// actually present on disk at BAKED_MARKETPLACES_ROOT in this environment.
|
|
230
|
+
const addCalls = calls.filter((c) => c.args[1] === "marketplace");
|
|
231
|
+
expect(addCalls).toHaveLength(1);
|
|
232
|
+
expect(addCalls[0].args[3]).toBe("/repo/root");
|
|
233
|
+
// Full call count is deterministic: marketplace add + install + update = 3.
|
|
234
|
+
expect(calls).toHaveLength(3);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("discoverBakedMarketplaces", () => {
|
|
239
|
+
it("returns dirs that have .claude-plugin/marketplace.json", () => {
|
|
240
|
+
// Since discoverBakedMarketplaces uses real fs, we test with a known-absent path
|
|
241
|
+
// The actual fs-based tests live in setup.integration.test.ts.
|
|
242
|
+
// This unit test verifies the function is exported and handles absent roots.
|
|
243
|
+
const result = discoverBakedMarketplaces(
|
|
244
|
+
"/tmp/nonexistent-convention-root-abc123",
|
|
245
|
+
);
|
|
246
|
+
expect(Array.isArray(result)).toBe(true);
|
|
247
|
+
expect(result).toHaveLength(0);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("returns empty array when conventionRoot is absent (empty string)", () => {
|
|
251
|
+
const result = discoverBakedMarketplaces("");
|
|
252
|
+
expect(result).toEqual([]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("returns empty array when conventionRoot does not exist", () => {
|
|
256
|
+
const result = discoverBakedMarketplaces("/opt/does-not-exist-xyz987");
|
|
257
|
+
expect(result).toEqual([]);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("resolveTemplatesDir option resolution (AEP-1.2)", () => {
|
|
262
|
+
it("resolves an absent type to the built-in templates with no env set", async () => {
|
|
263
|
+
const { resolveTemplatesDir } = await import("./setup.ts");
|
|
264
|
+
const dir = resolveTemplatesDir(undefined, "/nonexistent", () => {}, {});
|
|
265
|
+
expect(dir).toMatch(/workspace$/);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("looks up SHIPWRIGHT_DEFAULT_AGENT_TYPE under SHIPWRIGHT_SPECIALTIES_DIR for an absent type", async () => {
|
|
269
|
+
const { resolveTemplatesDir } = await import("./setup.ts");
|
|
270
|
+
const warnings: string[] = [];
|
|
271
|
+
const dir = resolveTemplatesDir(
|
|
272
|
+
undefined,
|
|
273
|
+
"/nonexistent",
|
|
274
|
+
(m) => warnings.push(m),
|
|
275
|
+
{
|
|
276
|
+
SHIPWRIGHT_DEFAULT_AGENT_TYPE: "base",
|
|
277
|
+
SHIPWRIGHT_SPECIALTIES_DIR: "/fixture/types",
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
// Missing fixture manifest → warns naming the type, falls back built-in.
|
|
281
|
+
expect(warnings).toHaveLength(1);
|
|
282
|
+
expect(warnings[0]).toContain('"base"');
|
|
283
|
+
expect(warnings[0]).toContain("/fixture/types/base/manifest.yaml");
|
|
284
|
+
expect(dir).toMatch(/workspace$/);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/shipwright-config-client.ts
|
|
3
|
+
*
|
|
4
|
+
* - ShipwrightConfigClient — interface for DI (used by entrypoint and tests)
|
|
5
|
+
* - RecordedShipwrightConfigClient — cassette-backed double for tests
|
|
6
|
+
*
|
|
7
|
+
* The HTTP implementation was consolidated into HttpShipwrightRuntimeClient
|
|
8
|
+
* (shipwright-runtime-client.ts). Wire that via getAgentConfigBundle() as the
|
|
9
|
+
* configClient adapter in entrypoint-main.ts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AgentConfigResponse } from "@app-vitals/agent-admin";
|
|
13
|
+
|
|
14
|
+
// ─── Interface ────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface ShipwrightConfigClient {
|
|
17
|
+
getConfig(agentId: string): Promise<AgentConfigResponse>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── RecordedShipwrightConfigClient ───────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Cassette-backed client for tests.
|
|
24
|
+
* Returns a fixed AgentConfigResponse on every call — no network required.
|
|
25
|
+
*/
|
|
26
|
+
export class RecordedShipwrightConfigClient implements ShipwrightConfigClient {
|
|
27
|
+
private readonly cassette: AgentConfigResponse;
|
|
28
|
+
|
|
29
|
+
constructor(cassette: AgentConfigResponse) {
|
|
30
|
+
this.cassette = cassette;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async getConfig(_agentId: string): Promise<AgentConfigResponse> {
|
|
34
|
+
return this.cassette;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/shipwright-runtime-client.integration.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Integration tests for HttpShipwrightRuntimeClient.
|
|
5
|
+
* Uses injected fake fetch doubles — no global.fetch override, no mock.module().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "bun:test";
|
|
9
|
+
import type {
|
|
10
|
+
AgentConfigResponse,
|
|
11
|
+
AgentCronJob,
|
|
12
|
+
} from "@app-vitals/agent-admin";
|
|
13
|
+
import {
|
|
14
|
+
HttpShipwrightRuntimeClient,
|
|
15
|
+
ShipwrightClientError,
|
|
16
|
+
} from "./shipwright-runtime-client.ts";
|
|
17
|
+
|
|
18
|
+
// ─── Test fixtures ─────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const API_URL = "https://api.test.shipwright.dev";
|
|
21
|
+
const API_KEY = "test-bearer-key";
|
|
22
|
+
const AGENT_ID = "agent-abc-123";
|
|
23
|
+
|
|
24
|
+
const SAMPLE_CONFIG: AgentConfigResponse = {
|
|
25
|
+
env: { ANTHROPIC_MODEL: "claude-sonnet-4-6" },
|
|
26
|
+
allowedTools: ["Read", "Bash"],
|
|
27
|
+
plugins: [{ marketplace: "shipwright", plugin: "my-plugin" }],
|
|
28
|
+
repos: [],
|
|
29
|
+
reviewAuthorAllowlist: [],
|
|
30
|
+
patchAuthorAllowlist: [],
|
|
31
|
+
restrictSlackToMembers: false,
|
|
32
|
+
memberEmails: [],
|
|
33
|
+
autoPostReviews: true,
|
|
34
|
+
allowSelfReview: false,
|
|
35
|
+
minConfidence: 75,
|
|
36
|
+
maxFindings: 5,
|
|
37
|
+
cleanupMergedWorktrees: true,
|
|
38
|
+
cleanupAfterDays: 14,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const SAMPLE_CRONS: AgentCronJob[] = [
|
|
42
|
+
{
|
|
43
|
+
id: "cron-1",
|
|
44
|
+
agentId: AGENT_ID,
|
|
45
|
+
name: "daily-brief",
|
|
46
|
+
schedule: "0 6 * * *",
|
|
47
|
+
prompt: "Run the morning brief",
|
|
48
|
+
channel: "C012345",
|
|
49
|
+
user: null,
|
|
50
|
+
silent: false,
|
|
51
|
+
enabled: true,
|
|
52
|
+
preCheck: null,
|
|
53
|
+
system: false,
|
|
54
|
+
parentCronId: null,
|
|
55
|
+
lockdownDisabledAt: null,
|
|
56
|
+
createdAt: new Date("2025-01-01T00:00:00Z"),
|
|
57
|
+
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// ─── Fake fetch helpers ────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
type FetchFn = (
|
|
64
|
+
url: RequestInfo | URL,
|
|
65
|
+
init?: RequestInit,
|
|
66
|
+
) => Promise<Response>;
|
|
67
|
+
type CapturedCall = { url: string; method: string; headers: Headers };
|
|
68
|
+
|
|
69
|
+
function fakeFetch(statusCode: number, body: unknown): FetchFn {
|
|
70
|
+
return async (_url, _init) => {
|
|
71
|
+
return new Response(JSON.stringify(body), {
|
|
72
|
+
status: statusCode,
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function capturingFetch(
|
|
79
|
+
statusCode: number,
|
|
80
|
+
body: unknown,
|
|
81
|
+
): { fn: FetchFn; calls: CapturedCall[] } {
|
|
82
|
+
const calls: CapturedCall[] = [];
|
|
83
|
+
return {
|
|
84
|
+
fn: async (urlOrReq, init) => {
|
|
85
|
+
// openapi-fetch v0.13 passes a Request object as the first argument
|
|
86
|
+
const req =
|
|
87
|
+
urlOrReq instanceof Request
|
|
88
|
+
? urlOrReq
|
|
89
|
+
: new Request(urlOrReq as string, init);
|
|
90
|
+
calls.push({ url: req.url, method: req.method, headers: req.headers });
|
|
91
|
+
return new Response(JSON.stringify(body), {
|
|
92
|
+
status: statusCode,
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
calls,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── getAgentConfigBundle ──────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
describe("HttpShipwrightRuntimeClient.getAgentConfigBundle", () => {
|
|
103
|
+
it("returns parsed AgentConfigResponse on 200", async () => {
|
|
104
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
105
|
+
apiUrl: API_URL,
|
|
106
|
+
apiKey: API_KEY,
|
|
107
|
+
fetchFn: fakeFetch(200, SAMPLE_CONFIG),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const result = await client.getAgentConfigBundle(AGENT_ID);
|
|
111
|
+
|
|
112
|
+
expect(result).toEqual(SAMPLE_CONFIG);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("throws ShipwrightClientError with statusCode 404 on 404", async () => {
|
|
116
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
117
|
+
apiUrl: API_URL,
|
|
118
|
+
apiKey: API_KEY,
|
|
119
|
+
fetchFn: fakeFetch(404, { error: "Not found" }),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const err = await client.getAgentConfigBundle(AGENT_ID).catch((e) => e);
|
|
123
|
+
|
|
124
|
+
expect(err).toBeInstanceOf(ShipwrightClientError);
|
|
125
|
+
expect(err.statusCode).toBe(404);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("throws ShipwrightClientError with statusCode 401 on 401", async () => {
|
|
129
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
130
|
+
apiUrl: API_URL,
|
|
131
|
+
apiKey: API_KEY,
|
|
132
|
+
fetchFn: fakeFetch(401, { error: "Unauthorized" }),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const err = await client.getAgentConfigBundle(AGENT_ID).catch((e) => e);
|
|
136
|
+
|
|
137
|
+
expect(err).toBeInstanceOf(ShipwrightClientError);
|
|
138
|
+
expect(err.statusCode).toBe(401);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("sends Authorization: Bearer header", async () => {
|
|
142
|
+
const { fn, calls } = capturingFetch(200, SAMPLE_CONFIG);
|
|
143
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
144
|
+
apiUrl: API_URL,
|
|
145
|
+
apiKey: API_KEY,
|
|
146
|
+
fetchFn: fn,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await client.getAgentConfigBundle(AGENT_ID);
|
|
150
|
+
|
|
151
|
+
expect(calls.length).toBe(1);
|
|
152
|
+
expect(calls[0].headers.get("Authorization")).toBe(`Bearer ${API_KEY}`);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("calls the correct URL", async () => {
|
|
156
|
+
const { fn, calls } = capturingFetch(200, SAMPLE_CONFIG);
|
|
157
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
158
|
+
apiUrl: API_URL,
|
|
159
|
+
apiKey: API_KEY,
|
|
160
|
+
fetchFn: fn,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await client.getAgentConfigBundle(AGENT_ID);
|
|
164
|
+
|
|
165
|
+
expect(calls[0].url).toBe(`${API_URL}/agents/${AGENT_ID}/config`);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ─── listAgentCronJobs ─────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
describe("HttpShipwrightRuntimeClient.listAgentCronJobs", () => {
|
|
172
|
+
it("returns parsed AgentCronJob[] on 200", async () => {
|
|
173
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
174
|
+
apiUrl: API_URL,
|
|
175
|
+
apiKey: API_KEY,
|
|
176
|
+
fetchFn: fakeFetch(200, SAMPLE_CRONS),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const result = await client.listAgentCronJobs(AGENT_ID);
|
|
180
|
+
|
|
181
|
+
expect(result.length).toBe(1);
|
|
182
|
+
expect(result[0].id).toBe("cron-1");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("throws ShipwrightClientError with statusCode 404 on 404", async () => {
|
|
186
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
187
|
+
apiUrl: API_URL,
|
|
188
|
+
apiKey: API_KEY,
|
|
189
|
+
fetchFn: fakeFetch(404, { error: "Not found" }),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const err = await client.listAgentCronJobs(AGENT_ID).catch((e) => e);
|
|
193
|
+
|
|
194
|
+
expect(err).toBeInstanceOf(ShipwrightClientError);
|
|
195
|
+
expect(err.statusCode).toBe(404);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("throws ShipwrightClientError with statusCode 401 on 401", async () => {
|
|
199
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
200
|
+
apiUrl: API_URL,
|
|
201
|
+
apiKey: API_KEY,
|
|
202
|
+
fetchFn: fakeFetch(401, { error: "Unauthorized" }),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const err = await client.listAgentCronJobs(AGENT_ID).catch((e) => e);
|
|
206
|
+
|
|
207
|
+
expect(err).toBeInstanceOf(ShipwrightClientError);
|
|
208
|
+
expect(err.statusCode).toBe(401);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("sends Authorization: Bearer header", async () => {
|
|
212
|
+
const { fn, calls } = capturingFetch(200, SAMPLE_CRONS);
|
|
213
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
214
|
+
apiUrl: API_URL,
|
|
215
|
+
apiKey: API_KEY,
|
|
216
|
+
fetchFn: fn,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await client.listAgentCronJobs(AGENT_ID);
|
|
220
|
+
|
|
221
|
+
expect(calls.length).toBe(1);
|
|
222
|
+
expect(calls[0].headers.get("Authorization")).toBe(`Bearer ${API_KEY}`);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("calls the correct URL", async () => {
|
|
226
|
+
const { fn, calls } = capturingFetch(200, SAMPLE_CRONS);
|
|
227
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
228
|
+
apiUrl: API_URL,
|
|
229
|
+
apiKey: API_KEY,
|
|
230
|
+
fetchFn: fn,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await client.listAgentCronJobs(AGENT_ID);
|
|
234
|
+
|
|
235
|
+
expect(calls[0].url).toBe(`${API_URL}/agents/${AGENT_ID}/crons`);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ─── reconcileSystemCrons ──────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
describe("HttpShipwrightRuntimeClient.reconcileSystemCrons", () => {
|
|
242
|
+
it("resolves without error on 200", async () => {
|
|
243
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
244
|
+
apiUrl: API_URL,
|
|
245
|
+
apiKey: API_KEY,
|
|
246
|
+
fetchFn: fakeFetch(200, { created: 1, updated: 0, deleted: 0 }),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await expect(
|
|
250
|
+
client.reconcileSystemCrons(AGENT_ID),
|
|
251
|
+
).resolves.toBeUndefined();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("throws ShipwrightClientError with statusCode 404 on 404", async () => {
|
|
255
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
256
|
+
apiUrl: API_URL,
|
|
257
|
+
apiKey: API_KEY,
|
|
258
|
+
fetchFn: fakeFetch(404, { error: "Not found" }),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const err = await client.reconcileSystemCrons(AGENT_ID).catch((e) => e);
|
|
262
|
+
|
|
263
|
+
expect(err).toBeInstanceOf(ShipwrightClientError);
|
|
264
|
+
expect(err.statusCode).toBe(404);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("throws ShipwrightClientError with statusCode 401 on 401", async () => {
|
|
268
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
269
|
+
apiUrl: API_URL,
|
|
270
|
+
apiKey: API_KEY,
|
|
271
|
+
fetchFn: fakeFetch(401, { error: "Unauthorized" }),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const err = await client.reconcileSystemCrons(AGENT_ID).catch((e) => e);
|
|
275
|
+
|
|
276
|
+
expect(err).toBeInstanceOf(ShipwrightClientError);
|
|
277
|
+
expect(err.statusCode).toBe(401);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("sends Authorization: Bearer header", async () => {
|
|
281
|
+
const { fn, calls } = capturingFetch(200, {
|
|
282
|
+
created: 0,
|
|
283
|
+
updated: 0,
|
|
284
|
+
deleted: 0,
|
|
285
|
+
});
|
|
286
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
287
|
+
apiUrl: API_URL,
|
|
288
|
+
apiKey: API_KEY,
|
|
289
|
+
fetchFn: fn,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await client.reconcileSystemCrons(AGENT_ID);
|
|
293
|
+
|
|
294
|
+
expect(calls.length).toBe(1);
|
|
295
|
+
expect(calls[0].headers.get("Authorization")).toBe(`Bearer ${API_KEY}`);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("calls the correct URL with POST method", async () => {
|
|
299
|
+
const { fn, calls } = capturingFetch(200, {
|
|
300
|
+
created: 0,
|
|
301
|
+
updated: 0,
|
|
302
|
+
deleted: 0,
|
|
303
|
+
});
|
|
304
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
305
|
+
apiUrl: API_URL,
|
|
306
|
+
apiKey: API_KEY,
|
|
307
|
+
fetchFn: fn,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await client.reconcileSystemCrons(AGENT_ID);
|
|
311
|
+
|
|
312
|
+
expect(calls[0].url).toBe(`${API_URL}/agents/${AGENT_ID}/crons/reconcile`);
|
|
313
|
+
expect(calls[0].method).toBe("POST");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("uses adminApiUrl for reconcile when provided separately", async () => {
|
|
317
|
+
const ADMIN_URL = "https://admin.test.shipwright.dev";
|
|
318
|
+
const { fn, calls } = capturingFetch(200, {
|
|
319
|
+
created: 0,
|
|
320
|
+
updated: 0,
|
|
321
|
+
deleted: 0,
|
|
322
|
+
});
|
|
323
|
+
const client = new HttpShipwrightRuntimeClient({
|
|
324
|
+
apiUrl: API_URL,
|
|
325
|
+
adminApiUrl: ADMIN_URL,
|
|
326
|
+
apiKey: API_KEY,
|
|
327
|
+
fetchFn: fn,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await client.reconcileSystemCrons(AGENT_ID);
|
|
331
|
+
|
|
332
|
+
expect(calls[0].url).toBe(
|
|
333
|
+
`${ADMIN_URL}/agents/${AGENT_ID}/crons/reconcile`,
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
});
|