ScuttleBot

feat: E2E test suite — API tests for all v1.3.0 features (#103) Add tests/e2e/tests/api.spec.ts with 22 API-level Playwright tests covering all shipped v1.3.0 features: - Status and metrics endpoints - Agent CRUD: register, list, bulk-delete, skills, skill filter - Channel operations: list, send message, users with modes - Settings: policies, bot_commands - Topology: types, static channels - Config: server config read - API keys: create, list, revoke - Auth scoping: read-only key blocked from write operations - Instructions: PUT/GET/DELETE round-trip - Blocker escalation endpoint Run: SB_TOKEN=<token> npx playwright test --project chromium

lmata 2026-04-05 19:07 trunk
Commit 9a76ee36b6f592df730f38db72a8279822332ad00304bd317feada2e0870662e
--- a/tests/e2e/tests/api.spec.ts
+++ b/tests/e2e/tests/api.spec.ts
@@ -0,0 +1,235 @@
1
+import { test, expect } from '@playwright/test';
2
+
3
+const BASE = process.env.SB_BASE_URL || 'http://localhost:8080';
4
+const TOKEN = process.env.SB_TOKEN || '';
5
+
6
+function headers() {
7
+ return { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' };
8
+}
9
+
10
+test.beforeEach(() => {
11
+ test.skip(!TOKEN, 'SB_TOKEN not set');
12
+});
13
+
14
+// --- Status ---
15
+
16
+test('GET /v1/status returns ok', async ({ request }) => {
17
+ const res = await request.get(`${BASE}/v1/status`, { headers: headers() });
18
+ expect(res.ok()).toBe(true);
19
+ const data = await res.json();
20
+ expect(data.status).toBe('ok');
21
+ expect(data.uptime).toBeTruthy();
22
+});
23
+
24
+test('GET /v1/metrics returns runtime stats', async ({ request }) => {
25
+ const res = await request.get(`${BASE}/v1/metrics`, { headers: headers() });
26
+ expect(res.ok()).toBe(true);
27
+ const data = await res.json();
28
+ expect(data.runtime).toBeTruthy();
29
+ expect(data.runtime.goroutines).toBeGreaterThan(0);
30
+});
31
+
32
+// --- Agents ---
33
+
34
+test('GET /v1/agents returns agent list', async ({ request }) => {
35
+ const res = await request.get(`${BASE}/v1/agents`, { headers: headers() });
36
+ expect(res.ok()).toBe(true);
37
+ const data = await res.json();
38
+ expect(Array.isArray(data.agents)).toBe(true);
39
+});
40
+
41
+test('POST /v1/agents/register creates agent and returns credentials', async ({ request }) => {
42
+ const nick = `e2e-api-${Date.now()}`;
43
+ const res = await request.post(`${BASE}/v1/agents/register`, {
44
+ headers: headers(),
45
+ data: { nick, type: 'worker', channels: ['#e2e-test'] },
46
+ });
47
+ expect(res.status()).toBe(201);
48
+ const data = await res.json();
49
+ expect(data.credentials.nick).toBe(nick);
50
+ expect(data.credentials.passphrase).toBeTruthy();
51
+
52
+ // Clean up.
53
+ await request.delete(`${BASE}/v1/agents/${nick}`, { headers: headers() });
54
+});
55
+
56
+test('POST /v1/agents/register with skills stores them', async ({ request }) => {
57
+ const nick = `e2e-skills-${Date.now()}`;
58
+ const res = await request.post(`${BASE}/v1/agents/register`, {
59
+ headers: headers(),
60
+ data: { nick, type: 'worker', channels: ['#e2e-test'], skills: ['go', 'python'] },
61
+ });
62
+ expect(res.status()).toBe(201);
63
+
64
+ const agent = await (await request.get(`${BASE}/v1/agents/${nick}`, { headers: headers() })).json();
65
+ expect(agent.skills).toContain('go');
66
+ expect(agent.skills).toContain('python');
67
+
68
+ await request.delete(`${BASE}/v1/agents/${nick}`, { headers: headers() });
69
+});
70
+
71
+test('GET /v1/agents?skill= filters by capability', async ({ request }) => {
72
+ const nick = `e2e-skill-${Date.now()}`;
73
+ await request.post(`${BASE}/v1/agents/register`, {
74
+ headers: headers(),
75
+ data: { nick, type: 'worker', channels: ['#e2e-test'], skills: ['rare-skill-xyz'] },
76
+ });
77
+
78
+ const res = await request.get(`${BASE}/v1/agents?skill=rare-skill-xyz`, { headers: headers() });
79
+ const data = await res.json();
80
+ expect(data.agents.some((a: any) => a.nick === nick)).toBe(true);
81
+
82
+ await request.delete(`${BASE}/v1/agents/${nick}`, { headers: headers() });
83
+});
84
+
85
+test('POST /v1/agents/bulk-delete removes multiple agents', async ({ request }) => {
86
+ const nicks = [`e2e-bulk-${Date.now()}-a`, `e2e-bulk-${Date.now()}-b`];
87
+ for (const nick of nicks) {
88
+ await request.post(`${BASE}/v1/agents/register`, {
89
+ headers: headers(),
90
+ data: { nick, type: 'worker', channels: ['#e2e-test'] },
91
+ });
92
+ }
93
+
94
+ const res = await request.post(`${BASE}/v1/agents/bulk-delete`, {
95
+ headers: headers(),
96
+ data: { nicks },
97
+ });
98
+ expect(res.ok()).toBe(true);
99
+ const data = await res.json();
100
+ expect(data.deleted).toBe(2);
101
+});
102
+
103
+// --- Channels ---
104
+
105
+test('GET /v1/channels returns channel list', async ({ request }) => {
106
+ const res = await request.get(`${BASE}/v1/channels`, { headers: headers() });
107
+ expect(res.ok()).toBe(true);
108
+ const data = await res.json();
109
+ expect(Array.isArray(data.channels)).toBe(true);
110
+});
111
+
112
+test('POST /v1/channels/{ch}/messages sends message', async ({ request }) => {
113
+ const res = await request.post(`${BASE}/v1/channels/general/messages`, {
114
+ headers: headers(),
115
+ data: { text: `e2e test message ${Date.now()}`, nick: 'e2e-test' },
116
+ });
117
+ expect(res.status()).toBeLessThan(300);
118
+});
119
+
120
+test('GET /v1/channels/{ch}/users returns user info with modes', async ({ request }) => {
121
+ const res = await request.get(`${BASE}/v1/channels/general/users`, { headers: headers() });
122
+ expect(res.ok()).toBe(true);
123
+ const data = await res.json();
124
+ expect(Array.isArray(data.users)).toBe(true);
125
+ expect(typeof data.channel_modes).toBe('string');
126
+});
127
+
128
+// --- Settings ---
129
+
130
+test('GET /v1/settings returns policies and bot_commands', async ({ request }) => {
131
+ const res = await request.get(`${BASE}/v1/settings`, { headers: headers() });
132
+ expect(res.ok()).toBe(true);
133
+ const data = await res.json();
134
+ expect(data.policies).toBeTruthy();
135
+ expect(data.bot_commands).toBeTruthy();
136
+ expect(data.bot_commands.oracle).toBeTruthy();
137
+ expect(data.bot_commands.shepherd).toBeTruthy();
138
+});
139
+
140
+// --- Topology ---
141
+
142
+test('GET /v1/topology returns types and static channels', async ({ request }) => {
143
+ const res = await request.get(`${BASE}/v1/topology`, { headers: headers() });
144
+ expect(res.ok()).toBe(true);
145
+ const data = await res.json();
146
+ expect(Array.isArray(data.static_channels)).toBe(true);
147
+ expect(Array.isArray(data.types)).toBe(true);
148
+});
149
+
150
+// --- Config ---
151
+
152
+test('GET /v1/config returns server config', async ({ request }) => {
153
+ const res = await request.get(`${BASE}/v1/config`, { headers: headers() });
154
+ expect(res.ok()).toBe(true);
155
+ const data = await res.json();
156
+ expect(data.ergo).toBeTruthy();
157
+ expect(data.bridge).toBeTruthy();
158
+});
159
+
160
+// --- API keys ---
161
+
162
+test('GET /v1/api-keys returns key list', async ({ request }) => {
163
+ const res = await request.get(`${BASE}/v1/api-keys`, { headers: headers() });
164
+ expect(res.ok()).toBe(true);
165
+ const data = await res.json();
166
+ expect(Array.isArray(data)).toBe(true);
167
+ expect(data.length).toBeGreaterThan(0); // at least the server key
168
+});
169
+
170
+test('POST /v1/api-keys creates key and DELETE revokes it', async ({ request }) => {
171
+ const create = await request.post(`${BASE}/v1/api-keys`, {
172
+ headers: headers(),
173
+ data: { name: `e2e-key-${Date.now()}`, scopes: ['read'] },
174
+ });
175
+ expect(create.status()).toBe(201);
176
+ const key = await create.json();
177
+ expect(key.token).toBeTruthy();
178
+ expect(key.id).toBeTruthy();
179
+
180
+ // Revoke.
181
+ const revoke = await request.delete(`${BASE}/v1/api-keys/${key.id}`, { headers: headers() });
182
+ expect(revoke.status()).toBe(204);
183
+});
184
+
185
+// --- Instructions ---
186
+
187
+test('PUT/GET/DELETE /v1/channels/{ch}/instructions round-trip', async ({ request }) => {
188
+ const ch = 'e2e-instr';
189
+ // Set.
190
+ const put = await request.put(`${BASE}/v1/channels/${ch}/instructions`, {
191
+ headers: headers(),
192
+ data: { instructions: 'Welcome {nick} to {channel}!' },
193
+ });
194
+ expect(put.status()).toBe(204);
195
+
196
+ // Get.
197
+ const get = await request.get(`${BASE}/v1/channels/${ch}/instructions`, { headers: headers() });
198
+ const data = await get.json();
199
+ expect(data.instructions).toContain('Welcome {nick}');
200
+
201
+ // Delete.
202
+ const del = await request.delete(`${BASE}/v1/channels/${ch}/instructions`, { headers: headers() });
203
+ expect(del.status()).toBe(204);
204
+});
205
+
206
+// --- Auth scoping ---
207
+
208
+test('read-only key cannot create agents', async ({ request }) => {
209
+ // Create a read-only key.
210
+ const create = await request.post(`${BASE}/v1/api-keys`, {
211
+ headers: headers(),
212
+ data: { name: `e2e-readonly-${Date.now()}`, scopes: ['read'] },
213
+ });
214
+ const key = await create.json();
215
+
216
+ // Try to register an agent with it.
217
+ const res = await request.post(`${BASE}/v1/agents/register`, {
218
+ headers: { Authorization: `Bearer ${key.token}`, 'Content-Type': 'application/json' },
219
+ data: { nick: 'should-fail', type: 'worker' },
220
+ });
221
+ expect(res.status()).toBe(403);
222
+
223
+ // Clean up.
224
+ await request.delete(`${BASE}/v1/api-keys/${key.id}`, { headers: headers() });
225
+});
226
+
227
+// --- Blocker escalation ---
228
+
229
+test('POST /v1/agents/{nick}/blocker accepts alert', async ({ request }) => {
230
+ const res = await request.post(`${BASE}/v1/agents/test-agent/blocker`, {
231
+ headers: headers(),
232
+ data: { message: 'stuck on database migration', channel: '#test' },
233
+ });
234
+ expect(res.status()).toBe(204);
235
+});
--- a/tests/e2e/tests/api.spec.ts
+++ b/tests/e2e/tests/api.spec.ts
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/e2e/tests/api.spec.ts
+++ b/tests/e2e/tests/api.spec.ts
@@ -0,0 +1,235 @@
1 import { test, expect } from '@playwright/test';
2
3 const BASE = process.env.SB_BASE_URL || 'http://localhost:8080';
4 const TOKEN = process.env.SB_TOKEN || '';
5
6 function headers() {
7 return { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' };
8 }
9
10 test.beforeEach(() => {
11 test.skip(!TOKEN, 'SB_TOKEN not set');
12 });
13
14 // --- Status ---
15
16 test('GET /v1/status returns ok', async ({ request }) => {
17 const res = await request.get(`${BASE}/v1/status`, { headers: headers() });
18 expect(res.ok()).toBe(true);
19 const data = await res.json();
20 expect(data.status).toBe('ok');
21 expect(data.uptime).toBeTruthy();
22 });
23
24 test('GET /v1/metrics returns runtime stats', async ({ request }) => {
25 const res = await request.get(`${BASE}/v1/metrics`, { headers: headers() });
26 expect(res.ok()).toBe(true);
27 const data = await res.json();
28 expect(data.runtime).toBeTruthy();
29 expect(data.runtime.goroutines).toBeGreaterThan(0);
30 });
31
32 // --- Agents ---
33
34 test('GET /v1/agents returns agent list', async ({ request }) => {
35 const res = await request.get(`${BASE}/v1/agents`, { headers: headers() });
36 expect(res.ok()).toBe(true);
37 const data = await res.json();
38 expect(Array.isArray(data.agents)).toBe(true);
39 });
40
41 test('POST /v1/agents/register creates agent and returns credentials', async ({ request }) => {
42 const nick = `e2e-api-${Date.now()}`;
43 const res = await request.post(`${BASE}/v1/agents/register`, {
44 headers: headers(),
45 data: { nick, type: 'worker', channels: ['#e2e-test'] },
46 });
47 expect(res.status()).toBe(201);
48 const data = await res.json();
49 expect(data.credentials.nick).toBe(nick);
50 expect(data.credentials.passphrase).toBeTruthy();
51
52 // Clean up.
53 await request.delete(`${BASE}/v1/agents/${nick}`, { headers: headers() });
54 });
55
56 test('POST /v1/agents/register with skills stores them', async ({ request }) => {
57 const nick = `e2e-skills-${Date.now()}`;
58 const res = await request.post(`${BASE}/v1/agents/register`, {
59 headers: headers(),
60 data: { nick, type: 'worker', channels: ['#e2e-test'], skills: ['go', 'python'] },
61 });
62 expect(res.status()).toBe(201);
63
64 const agent = await (await request.get(`${BASE}/v1/agents/${nick}`, { headers: headers() })).json();
65 expect(agent.skills).toContain('go');
66 expect(agent.skills).toContain('python');
67
68 await request.delete(`${BASE}/v1/agents/${nick}`, { headers: headers() });
69 });
70
71 test('GET /v1/agents?skill= filters by capability', async ({ request }) => {
72 const nick = `e2e-skill-${Date.now()}`;
73 await request.post(`${BASE}/v1/agents/register`, {
74 headers: headers(),
75 data: { nick, type: 'worker', channels: ['#e2e-test'], skills: ['rare-skill-xyz'] },
76 });
77
78 const res = await request.get(`${BASE}/v1/agents?skill=rare-skill-xyz`, { headers: headers() });
79 const data = await res.json();
80 expect(data.agents.some((a: any) => a.nick === nick)).toBe(true);
81
82 await request.delete(`${BASE}/v1/agents/${nick}`, { headers: headers() });
83 });
84
85 test('POST /v1/agents/bulk-delete removes multiple agents', async ({ request }) => {
86 const nicks = [`e2e-bulk-${Date.now()}-a`, `e2e-bulk-${Date.now()}-b`];
87 for (const nick of nicks) {
88 await request.post(`${BASE}/v1/agents/register`, {
89 headers: headers(),
90 data: { nick, type: 'worker', channels: ['#e2e-test'] },
91 });
92 }
93
94 const res = await request.post(`${BASE}/v1/agents/bulk-delete`, {
95 headers: headers(),
96 data: { nicks },
97 });
98 expect(res.ok()).toBe(true);
99 const data = await res.json();
100 expect(data.deleted).toBe(2);
101 });
102
103 // --- Channels ---
104
105 test('GET /v1/channels returns channel list', async ({ request }) => {
106 const res = await request.get(`${BASE}/v1/channels`, { headers: headers() });
107 expect(res.ok()).toBe(true);
108 const data = await res.json();
109 expect(Array.isArray(data.channels)).toBe(true);
110 });
111
112 test('POST /v1/channels/{ch}/messages sends message', async ({ request }) => {
113 const res = await request.post(`${BASE}/v1/channels/general/messages`, {
114 headers: headers(),
115 data: { text: `e2e test message ${Date.now()}`, nick: 'e2e-test' },
116 });
117 expect(res.status()).toBeLessThan(300);
118 });
119
120 test('GET /v1/channels/{ch}/users returns user info with modes', async ({ request }) => {
121 const res = await request.get(`${BASE}/v1/channels/general/users`, { headers: headers() });
122 expect(res.ok()).toBe(true);
123 const data = await res.json();
124 expect(Array.isArray(data.users)).toBe(true);
125 expect(typeof data.channel_modes).toBe('string');
126 });
127
128 // --- Settings ---
129
130 test('GET /v1/settings returns policies and bot_commands', async ({ request }) => {
131 const res = await request.get(`${BASE}/v1/settings`, { headers: headers() });
132 expect(res.ok()).toBe(true);
133 const data = await res.json();
134 expect(data.policies).toBeTruthy();
135 expect(data.bot_commands).toBeTruthy();
136 expect(data.bot_commands.oracle).toBeTruthy();
137 expect(data.bot_commands.shepherd).toBeTruthy();
138 });
139
140 // --- Topology ---
141
142 test('GET /v1/topology returns types and static channels', async ({ request }) => {
143 const res = await request.get(`${BASE}/v1/topology`, { headers: headers() });
144 expect(res.ok()).toBe(true);
145 const data = await res.json();
146 expect(Array.isArray(data.static_channels)).toBe(true);
147 expect(Array.isArray(data.types)).toBe(true);
148 });
149
150 // --- Config ---
151
152 test('GET /v1/config returns server config', async ({ request }) => {
153 const res = await request.get(`${BASE}/v1/config`, { headers: headers() });
154 expect(res.ok()).toBe(true);
155 const data = await res.json();
156 expect(data.ergo).toBeTruthy();
157 expect(data.bridge).toBeTruthy();
158 });
159
160 // --- API keys ---
161
162 test('GET /v1/api-keys returns key list', async ({ request }) => {
163 const res = await request.get(`${BASE}/v1/api-keys`, { headers: headers() });
164 expect(res.ok()).toBe(true);
165 const data = await res.json();
166 expect(Array.isArray(data)).toBe(true);
167 expect(data.length).toBeGreaterThan(0); // at least the server key
168 });
169
170 test('POST /v1/api-keys creates key and DELETE revokes it', async ({ request }) => {
171 const create = await request.post(`${BASE}/v1/api-keys`, {
172 headers: headers(),
173 data: { name: `e2e-key-${Date.now()}`, scopes: ['read'] },
174 });
175 expect(create.status()).toBe(201);
176 const key = await create.json();
177 expect(key.token).toBeTruthy();
178 expect(key.id).toBeTruthy();
179
180 // Revoke.
181 const revoke = await request.delete(`${BASE}/v1/api-keys/${key.id}`, { headers: headers() });
182 expect(revoke.status()).toBe(204);
183 });
184
185 // --- Instructions ---
186
187 test('PUT/GET/DELETE /v1/channels/{ch}/instructions round-trip', async ({ request }) => {
188 const ch = 'e2e-instr';
189 // Set.
190 const put = await request.put(`${BASE}/v1/channels/${ch}/instructions`, {
191 headers: headers(),
192 data: { instructions: 'Welcome {nick} to {channel}!' },
193 });
194 expect(put.status()).toBe(204);
195
196 // Get.
197 const get = await request.get(`${BASE}/v1/channels/${ch}/instructions`, { headers: headers() });
198 const data = await get.json();
199 expect(data.instructions).toContain('Welcome {nick}');
200
201 // Delete.
202 const del = await request.delete(`${BASE}/v1/channels/${ch}/instructions`, { headers: headers() });
203 expect(del.status()).toBe(204);
204 });
205
206 // --- Auth scoping ---
207
208 test('read-only key cannot create agents', async ({ request }) => {
209 // Create a read-only key.
210 const create = await request.post(`${BASE}/v1/api-keys`, {
211 headers: headers(),
212 data: { name: `e2e-readonly-${Date.now()}`, scopes: ['read'] },
213 });
214 const key = await create.json();
215
216 // Try to register an agent with it.
217 const res = await request.post(`${BASE}/v1/agents/register`, {
218 headers: { Authorization: `Bearer ${key.token}`, 'Content-Type': 'application/json' },
219 data: { nick: 'should-fail', type: 'worker' },
220 });
221 expect(res.status()).toBe(403);
222
223 // Clean up.
224 await request.delete(`${BASE}/v1/api-keys/${key.id}`, { headers: headers() });
225 });
226
227 // --- Blocker escalation ---
228
229 test('POST /v1/agents/{nick}/blocker accepts alert', async ({ request }) => {
230 const res = await request.post(`${BASE}/v1/agents/test-agent/blocker`, {
231 headers: headers(),
232 data: { message: 'stuck on database migration', channel: '#test' },
233 });
234 expect(res.status()).toBe(204);
235 });

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button