agentproc 0.2.1 → 0.4.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.
@@ -0,0 +1,345 @@
1
+ 'use strict';
2
+ /**
3
+ * Tests for hub.js — mock-based, no real network access.
4
+ *
5
+ * Run with: `node --test src/hub.test.js`
6
+ *
7
+ * Strategy: redirect HOME to a tmp dir for cache isolation; intercept
8
+ * global.fetch with fixtures. Concurrency is disabled because HOME is
9
+ * process-global state.
10
+ */
11
+
12
+ const { test, describe, before, after, beforeEach, afterEach } = require('node:test');
13
+ const assert = require('node:assert');
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+
18
+ const hub = require('./hub.js');
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Test fixtures
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const FAKE_TREE = [
25
+ { path: 'hub', type: 'tree' },
26
+ { path: 'hub/_shared', type: 'tree' },
27
+ { path: 'hub/_shared/stream_utils.py', type: 'blob' },
28
+ { path: 'hub/_shared/stream_utils.js', type: 'blob' },
29
+ { path: 'hub/_shared/README.md', type: 'blob' },
30
+ { path: 'hub/echo-agent', type: 'tree' },
31
+ { path: 'hub/echo-agent/profile.yaml', type: 'blob' },
32
+ { path: 'hub/echo-agent/bridge.py', type: 'blob' },
33
+ { path: 'hub/echo-agent/bridge.js', type: 'blob' },
34
+ { path: 'hub/echo-agent/bridge.sh', type: 'blob' },
35
+ { path: 'hub/echo-agent/README.md', type: 'blob' },
36
+ { path: 'hub/claude-code', type: 'tree' },
37
+ { path: 'hub/claude-code/profile.yaml', type: 'blob' },
38
+ { path: 'hub/claude-code/bridge.py', type: 'blob' },
39
+ { path: 'hub/claude-code/bridge.js', type: 'blob' },
40
+ { path: 'hub/claude-code/README.md', type: 'blob' },
41
+ ];
42
+
43
+ const FAKE_FILE_CONTENTS = {
44
+ 'hub/echo-agent/profile.yaml':
45
+ 'name: echo-agent\n' +
46
+ 'description: Minimal hello-world agent\n' +
47
+ 'cli: none\n' +
48
+ 'agentproc:\n' +
49
+ ' command: python3 ./bridge.py\n' +
50
+ ' cwd: .\n' +
51
+ 'tested: official\n' +
52
+ 'maintainer: jeffkit\n',
53
+ 'hub/echo-agent/bridge.py': '#!/usr/bin/env python3\nprint("echo")\n',
54
+ 'hub/echo-agent/bridge.js': "'use strict';\nconsole.log('echo');\n",
55
+ 'hub/echo-agent/bridge.sh': '#!/usr/bin/env bash\necho echo\n',
56
+ 'hub/echo-agent/README.md': '# echo-agent\n\nHello world.\n',
57
+ 'hub/claude-code/profile.yaml':
58
+ 'name: claude-code\n' +
59
+ 'description: Claude Code wrapper\n' +
60
+ 'cli: claude\n' +
61
+ 'agentproc:\n' +
62
+ ' command: python3 ./bridge.py\n' +
63
+ 'tested: official\n' +
64
+ 'maintainer: jeffkit\n',
65
+ 'hub/claude-code/bridge.py': '#!/usr/bin/env python3\nprint("claude")\n',
66
+ 'hub/claude-code/bridge.js': "'use strict';\nconsole.log('claude');\n",
67
+ 'hub/claude-code/README.md': '# claude-code\n\nReal wrapper.\n',
68
+ };
69
+
70
+ let _origHome = null;
71
+ let _tmpHome = null;
72
+
73
+ function setupHome() {
74
+ _origHome = process.env.HOME;
75
+ _tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-hub-home-'));
76
+ process.env.HOME = _tmpHome;
77
+ }
78
+
79
+ function teardownHome() {
80
+ if (_origHome !== null) process.env.HOME = _origHome;
81
+ if (_tmpHome && fs.existsSync(_tmpHome)) {
82
+ fs.rmSync(_tmpHome, { recursive: true, force: true });
83
+ }
84
+ _tmpHome = null;
85
+ }
86
+
87
+ /**
88
+ * Install a fake global.fetch that serves our fixtures.
89
+ */
90
+ function installFakeFetch(tree = FAKE_TREE, contents = FAKE_FILE_CONTENTS) {
91
+ const counter = { json: 0, text: 0 };
92
+ const orig = global.fetch;
93
+ global.fetch = async (url, opts) => {
94
+ const acceptJson = opts && opts.headers && opts.headers.Accept && opts.headers.Accept.includes('json');
95
+ if (acceptJson && url.includes('git/trees')) {
96
+ counter.json++;
97
+ return {
98
+ ok: true,
99
+ json: async () => ({ tree }),
100
+ text: async () => JSON.stringify({ tree }),
101
+ };
102
+ }
103
+ if (acceptJson) {
104
+ throw new Error(`unexpected JSON URL: ${url}`);
105
+ }
106
+ for (const [p, content] of Object.entries(contents)) {
107
+ if (url.endsWith(p)) {
108
+ counter.text++;
109
+ return { ok: true, text: async () => content };
110
+ }
111
+ }
112
+ throw new Error(`unexpected text URL: ${url}`);
113
+ };
114
+ counter.restore = () => { global.fetch = orig; };
115
+ return counter;
116
+ }
117
+
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // All tests live inside this suite so we can disable concurrency.
121
+ // (Mutating process.env.HOME is global state; parallel tests would clash.)
122
+ // ---------------------------------------------------------------------------
123
+
124
+ describe('hub', { concurrency: false }, () => {
125
+ beforeEach(setupHome);
126
+ afterEach(teardownHome);
127
+
128
+ // ----- cacheAgeSecs -----
129
+
130
+ describe('cacheAgeSecs', () => {
131
+ test('null when not cached', () => {
132
+ assert.strictEqual(hub.cacheAgeSecs('never'), null);
133
+ });
134
+
135
+ test('small age right after write', () => {
136
+ fs.mkdirSync(hub.cacheDir('fresh'), { recursive: true });
137
+ fs.writeFileSync(
138
+ path.join(hub.cacheDir('fresh'), '.cache-meta.json'),
139
+ JSON.stringify({ fetched_at: Date.now() / 1000, ref: 'main' })
140
+ );
141
+ const age = hub.cacheAgeSecs('fresh');
142
+ assert.ok(age !== null);
143
+ assert.ok(age < 5);
144
+ });
145
+
146
+ test('large age for stale cache', () => {
147
+ fs.mkdirSync(hub.cacheDir('stale'), { recursive: true });
148
+ fs.writeFileSync(
149
+ path.join(hub.cacheDir('stale'), '.cache-meta.json'),
150
+ JSON.stringify({ fetched_at: Date.now() / 1000 - 100000, ref: 'main' })
151
+ );
152
+ const age = hub.cacheAgeSecs('stale');
153
+ assert.ok(age !== null);
154
+ assert.ok(age >= 99999, `age=${age}`);
155
+ });
156
+
157
+ test('null for invalid meta', () => {
158
+ fs.mkdirSync(hub.cacheDir('bad'), { recursive: true });
159
+ fs.writeFileSync(
160
+ path.join(hub.cacheDir('bad'), '.cache-meta.json'),
161
+ 'not json at all'
162
+ );
163
+ assert.strictEqual(hub.cacheAgeSecs('bad'), null);
164
+ });
165
+ });
166
+
167
+ // ----- fetchProfile -----
168
+
169
+ describe('fetchProfile', () => {
170
+ test('downloads all files', async () => {
171
+ const counter = installFakeFetch();
172
+ try {
173
+ const dir = await hub.fetchProfile('echo-agent');
174
+ assert.ok(fs.existsSync(dir));
175
+ const names = fs.readdirSync(dir).sort();
176
+ assert.ok(names.includes('profile.yaml'));
177
+ assert.ok(names.includes('bridge.py'));
178
+ assert.ok(names.includes('bridge.js'));
179
+ assert.ok(names.includes('README.md'));
180
+ assert.ok(names.includes('.cache-meta.json'));
181
+ } finally {
182
+ counter.restore();
183
+ }
184
+ });
185
+
186
+ test('unknown profile raises', async () => {
187
+ const counter = installFakeFetch([{ path: 'hub', type: 'tree' }]);
188
+ try {
189
+ await assert.rejects(hub.fetchProfile('nope'), /not found in hub/);
190
+ } finally {
191
+ counter.restore();
192
+ }
193
+ });
194
+
195
+ test('uses cache on second call (no new fetches)', async () => {
196
+ const counter = installFakeFetch();
197
+ try {
198
+ await hub.fetchProfile('echo-agent');
199
+ const afterFirst = { json: counter.json, text: counter.text };
200
+ await hub.fetchProfile('echo-agent');
201
+ assert.strictEqual(counter.json, afterFirst.json);
202
+ assert.strictEqual(counter.text, afterFirst.text);
203
+ } finally {
204
+ counter.restore();
205
+ }
206
+ });
207
+
208
+ test('refresh forces refetch', async () => {
209
+ const counter = installFakeFetch();
210
+ try {
211
+ await hub.fetchProfile('echo-agent');
212
+ const first = counter.json;
213
+ await hub.fetchProfile('echo-agent', { refresh: true });
214
+ assert.ok(counter.json > first);
215
+ } finally {
216
+ counter.restore();
217
+ }
218
+ });
219
+
220
+ test('overwrites tampered cache on refresh', async () => {
221
+ const counter = installFakeFetch();
222
+ try {
223
+ await hub.fetchProfile('echo-agent');
224
+ const f = path.join(hub.cacheDir('echo-agent'), 'bridge.py');
225
+ const original = fs.readFileSync(f, 'utf8');
226
+ fs.writeFileSync(f, '# tampered\n');
227
+ await hub.fetchProfile('echo-agent', { refresh: true });
228
+ assert.strictEqual(fs.readFileSync(f, 'utf8'), original);
229
+ } finally {
230
+ counter.restore();
231
+ }
232
+ });
233
+ });
234
+
235
+ // ----- listProfiles -----
236
+
237
+ describe('listProfiles', () => {
238
+ test('returns all profile dirs with metadata', async () => {
239
+ const counter = installFakeFetch();
240
+ try {
241
+ const profiles = await hub.listProfiles({ refresh: true });
242
+ const names = profiles.map(p => p.name).sort();
243
+ assert.deepStrictEqual(names, ['claude-code', 'echo-agent']);
244
+ const ec = profiles.find(p => p.name === 'echo-agent');
245
+ assert.strictEqual(ec.tested, 'official');
246
+ assert.strictEqual(ec.description, 'Minimal hello-world agent');
247
+ assert.strictEqual(ec.cli, 'none');
248
+ } finally {
249
+ counter.restore();
250
+ }
251
+ });
252
+
253
+ test('skips underscore-prefixed utility dirs like _shared', async () => {
254
+ // _shared/ has no profile.yaml; it must not appear in the listing and
255
+ // must not trigger a "could not read metadata" warning/fetch.
256
+ const counter = installFakeFetch();
257
+ try {
258
+ const profiles = await hub.listProfiles({ refresh: true });
259
+ const names = profiles.map(p => p.name);
260
+ assert.ok(!names.some(n => n.startsWith('_')),
261
+ `utility dir leaked into listing: ${names}`);
262
+ } finally {
263
+ counter.restore();
264
+ }
265
+ });
266
+ });
267
+
268
+ // ----- showReadme -----
269
+
270
+ describe('showReadme', () => {
271
+ test('returns README content', async () => {
272
+ const counter = installFakeFetch();
273
+ try {
274
+ const text = await hub.showReadme('echo-agent', { refresh: true });
275
+ assert.ok(text.includes('echo-agent'));
276
+ assert.ok(text.includes('Hello world'));
277
+ } finally {
278
+ counter.restore();
279
+ }
280
+ });
281
+
282
+ test('missing README returns placeholder', async () => {
283
+ const tree = [
284
+ { path: 'hub', type: 'tree' },
285
+ { path: 'hub/noreadme', type: 'tree' },
286
+ { path: 'hub/noreadme/profile.yaml', type: 'blob' },
287
+ ];
288
+ const contents = {
289
+ 'hub/noreadme/profile.yaml': 'name: noreadme\ndescription: x\ntested: unverified\n',
290
+ };
291
+ const counter = installFakeFetch(tree, contents);
292
+ try {
293
+ const text = await hub.showReadme('noreadme', { refresh: true });
294
+ assert.ok(text.includes('no README.md'));
295
+ } finally {
296
+ counter.restore();
297
+ }
298
+ });
299
+ });
300
+
301
+ // ----- installProfile -----
302
+
303
+ describe('installProfile', () => {
304
+ test('copies to target dir', async () => {
305
+ const counter = installFakeFetch();
306
+ const tmpTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-install-'));
307
+ try {
308
+ const dest = await hub.installProfile('echo-agent', tmpTarget, { refresh: true });
309
+ assert.ok(fs.existsSync(dest));
310
+ assert.ok(fs.existsSync(path.join(dest, 'profile.yaml')));
311
+ assert.ok(fs.existsSync(path.join(dest, 'bridge.py')));
312
+ assert.ok(!fs.existsSync(path.join(dest, '.cache-meta.json')));
313
+ } finally {
314
+ counter.restore();
315
+ fs.rmSync(tmpTarget, { recursive: true, force: true });
316
+ }
317
+ });
318
+
319
+ test('refuses existing target', async () => {
320
+ const counter = installFakeFetch();
321
+ const tmpTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-install-'));
322
+ try {
323
+ await hub.installProfile('echo-agent', tmpTarget, { refresh: true });
324
+ await assert.rejects(
325
+ hub.installProfile('echo-agent', tmpTarget, { refresh: true }),
326
+ /target already exists/
327
+ );
328
+ } finally {
329
+ counter.restore();
330
+ fs.rmSync(tmpTarget, { recursive: true, force: true });
331
+ }
332
+ });
333
+ });
334
+
335
+ // ----- Constants (no HOME mutation needed) -----
336
+
337
+ test('HUB_CACHE_TTL_SECS is 24h', () => {
338
+ assert.strictEqual(hub.HUB_CACHE_TTL_SECS, 24 * 60 * 60);
339
+ });
340
+
341
+ test('hub repo constants', () => {
342
+ assert.strictEqual(hub.HUB_REPO, 'jeffkit/agentproc');
343
+ assert.strictEqual(hub.HUB_REF, 'main');
344
+ });
345
+ });