@framers/agentos-ext-cloud-flyio 0.1.0 → 0.1.2
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/manifest.json +4 -4
- package/package.json +15 -7
- package/src/FlyService.ts +0 -364
- package/src/index.ts +0 -109
- package/src/tools/createVolume.ts +0 -53
- package/src/tools/deployApp.ts +0 -104
- package/src/tools/listApps.ts +0 -62
- package/src/tools/scaleApp.ts +0 -116
- package/test/FlyService.test.ts +0 -484
- package/test/index.test.ts +0 -49
- package/test/tools.test.ts +0 -407
- package/tsconfig.json +0 -22
package/test/FlyService.test.ts
DELETED
|
@@ -1,484 +0,0 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
-
import { FlyService } from '../src/FlyService';
|
|
4
|
-
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
// Helpers
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
|
|
9
|
-
function mockResponse(data: unknown, status = 200): Response {
|
|
10
|
-
const body = typeof data === 'string' ? data : JSON.stringify(data);
|
|
11
|
-
return {
|
|
12
|
-
ok: status >= 200 && status < 300,
|
|
13
|
-
status,
|
|
14
|
-
statusText: status === 200 ? 'OK' : 'Error',
|
|
15
|
-
headers: new Headers(),
|
|
16
|
-
text: vi.fn().mockResolvedValue(body),
|
|
17
|
-
json: vi.fn().mockResolvedValue(typeof data === 'string' ? {} : data),
|
|
18
|
-
clone: vi.fn(),
|
|
19
|
-
} as unknown as Response;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function gqlResponse(data: unknown): Response {
|
|
23
|
-
return mockResponse({ data });
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function gqlErrorResponse(messages: string[]): Response {
|
|
27
|
-
return mockResponse({
|
|
28
|
-
errors: messages.map((m) => ({ message: m })),
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// Tests
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
describe('FlyService', () => {
|
|
37
|
-
let service: FlyService;
|
|
38
|
-
let mockFetch: ReturnType<typeof vi.fn>;
|
|
39
|
-
|
|
40
|
-
beforeEach(() => {
|
|
41
|
-
mockFetch = vi.fn();
|
|
42
|
-
vi.stubGlobal('fetch', mockFetch);
|
|
43
|
-
service = new FlyService({
|
|
44
|
-
token: 'fly-test-token-xyz',
|
|
45
|
-
baseUrl: 'https://api.machines.dev/v1',
|
|
46
|
-
graphqlUrl: 'https://api.fly.io/graphql',
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
afterEach(() => {
|
|
51
|
-
vi.restoreAllMocks();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
// ── Initialize / Shutdown ───────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
describe('initialize', () => {
|
|
57
|
-
it('should succeed when currentUser query returns an id', async () => {
|
|
58
|
-
mockFetch.mockResolvedValueOnce(gqlResponse({ currentUser: { id: 'user-1', email: 'test@fly.io' } }));
|
|
59
|
-
await service.initialize();
|
|
60
|
-
expect(service.isRunning).toBe(true);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('should throw when currentUser has no id', async () => {
|
|
64
|
-
mockFetch.mockResolvedValueOnce(gqlResponse({ currentUser: { id: null } }));
|
|
65
|
-
await expect(service.initialize()).rejects.toThrow('Fly.io auth failed: could not retrieve user');
|
|
66
|
-
expect(service.isRunning).toBe(false);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('should throw when GraphQL returns HTTP error', async () => {
|
|
70
|
-
mockFetch.mockResolvedValueOnce(mockResponse('Unauthorized', 401));
|
|
71
|
-
await expect(service.initialize()).rejects.toThrow('Fly.io GraphQL 401');
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('should throw on GraphQL errors in response body', async () => {
|
|
75
|
-
mockFetch.mockResolvedValueOnce(gqlErrorResponse(['Token revoked']));
|
|
76
|
-
await expect(service.initialize()).rejects.toThrow('Fly.io GraphQL: Token revoked');
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe('shutdown', () => {
|
|
81
|
-
it('should set isRunning to false', async () => {
|
|
82
|
-
mockFetch.mockResolvedValueOnce(gqlResponse({ currentUser: { id: 'u1' } }));
|
|
83
|
-
await service.initialize();
|
|
84
|
-
await service.shutdown();
|
|
85
|
-
expect(service.isRunning).toBe(false);
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
// ── Apps ────────────────────────────────────────────────────────────────
|
|
90
|
-
|
|
91
|
-
describe('createApp', () => {
|
|
92
|
-
it('should create an app and fetch it via GraphQL', async () => {
|
|
93
|
-
// POST /apps (REST)
|
|
94
|
-
mockFetch.mockResolvedValueOnce(mockResponse({ ok: true }));
|
|
95
|
-
// getApp via GraphQL
|
|
96
|
-
mockFetch.mockResolvedValueOnce(gqlResponse({
|
|
97
|
-
app: {
|
|
98
|
-
id: 'app-abc',
|
|
99
|
-
name: 'my-fly-app',
|
|
100
|
-
organization: { slug: 'personal' },
|
|
101
|
-
status: 'deployed',
|
|
102
|
-
hostname: 'my-fly-app.fly.dev',
|
|
103
|
-
createdAt: '2024-06-01',
|
|
104
|
-
},
|
|
105
|
-
}));
|
|
106
|
-
const app = await service.createApp('my-fly-app');
|
|
107
|
-
expect(app.id).toBe('app-abc');
|
|
108
|
-
expect(app.name).toBe('my-fly-app');
|
|
109
|
-
expect(app.organization).toBe('personal');
|
|
110
|
-
expect(app.hostname).toBe('my-fly-app.fly.dev');
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('should pass org_slug when provided', async () => {
|
|
114
|
-
mockFetch.mockResolvedValueOnce(mockResponse({ ok: true }));
|
|
115
|
-
mockFetch.mockResolvedValueOnce(gqlResponse({
|
|
116
|
-
app: {
|
|
117
|
-
id: 'app-xyz',
|
|
118
|
-
name: 'team-app',
|
|
119
|
-
organization: { slug: 'my-team' },
|
|
120
|
-
status: 'pending',
|
|
121
|
-
hostname: 'team-app.fly.dev',
|
|
122
|
-
},
|
|
123
|
-
}));
|
|
124
|
-
const app = await service.createApp('team-app', 'my-team');
|
|
125
|
-
const restBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
126
|
-
expect(restBody.org_slug).toBe('my-team');
|
|
127
|
-
expect(app.organization).toBe('my-team');
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('should throw when REST create fails', async () => {
|
|
131
|
-
mockFetch.mockResolvedValueOnce(mockResponse('Name already taken', 422));
|
|
132
|
-
await expect(service.createApp('taken-app')).rejects.toThrow('Fly.io API 422');
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
describe('getApp', () => {
|
|
137
|
-
it('should return an app by name via GraphQL', async () => {
|
|
138
|
-
mockFetch.mockResolvedValueOnce(gqlResponse({
|
|
139
|
-
app: {
|
|
140
|
-
id: 'app-1',
|
|
141
|
-
name: 'my-app',
|
|
142
|
-
organization: { slug: 'personal' },
|
|
143
|
-
status: 'deployed',
|
|
144
|
-
hostname: 'my-app.fly.dev',
|
|
145
|
-
createdAt: '2024-01-01',
|
|
146
|
-
},
|
|
147
|
-
}));
|
|
148
|
-
const app = await service.getApp('my-app');
|
|
149
|
-
expect(app.id).toBe('app-1');
|
|
150
|
-
expect(app.status).toBe('deployed');
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('should default hostname to name.fly.dev when missing', async () => {
|
|
154
|
-
mockFetch.mockResolvedValueOnce(gqlResponse({
|
|
155
|
-
app: {
|
|
156
|
-
id: 'app-1',
|
|
157
|
-
name: 'bare-app',
|
|
158
|
-
organization: {},
|
|
159
|
-
status: 'pending',
|
|
160
|
-
},
|
|
161
|
-
}));
|
|
162
|
-
const app = await service.getApp('bare-app');
|
|
163
|
-
expect(app.hostname).toBe('bare-app.fly.dev');
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('should throw on GraphQL errors', async () => {
|
|
167
|
-
mockFetch.mockResolvedValueOnce(gqlErrorResponse(['App not found']));
|
|
168
|
-
await expect(service.getApp('missing-app')).rejects.toThrow('Fly.io GraphQL: App not found');
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
describe('listApps', () => {
|
|
173
|
-
it('should return a list of apps', async () => {
|
|
174
|
-
mockFetch.mockResolvedValueOnce(gqlResponse({
|
|
175
|
-
apps: {
|
|
176
|
-
nodes: [
|
|
177
|
-
{
|
|
178
|
-
id: 'a1', name: 'app-one', organization: { slug: 'personal' },
|
|
179
|
-
status: 'deployed', hostname: 'app-one.fly.dev', createdAt: '2024-01-01',
|
|
180
|
-
},
|
|
181
|
-
{
|
|
182
|
-
id: 'a2', name: 'app-two', organization: { slug: 'team' },
|
|
183
|
-
status: 'suspended', hostname: 'app-two.fly.dev', createdAt: '2024-02-01',
|
|
184
|
-
},
|
|
185
|
-
],
|
|
186
|
-
},
|
|
187
|
-
}));
|
|
188
|
-
const apps = await service.listApps();
|
|
189
|
-
expect(apps).toHaveLength(2);
|
|
190
|
-
expect(apps[0].name).toBe('app-one');
|
|
191
|
-
expect(apps[1].organization).toBe('team');
|
|
192
|
-
expect(apps[1].status).toBe('suspended');
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('should return empty array when no apps exist', async () => {
|
|
196
|
-
mockFetch.mockResolvedValueOnce(gqlResponse({ apps: { nodes: [] } }));
|
|
197
|
-
const apps = await service.listApps();
|
|
198
|
-
expect(apps).toHaveLength(0);
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
// ── Machines ────────────────────────────────────────────────────────────
|
|
203
|
-
|
|
204
|
-
describe('createMachine', () => {
|
|
205
|
-
it('should create a machine with config', async () => {
|
|
206
|
-
mockFetch.mockResolvedValueOnce(mockResponse({
|
|
207
|
-
id: 'mach-1',
|
|
208
|
-
name: 'web-1',
|
|
209
|
-
state: 'started',
|
|
210
|
-
region: 'iad',
|
|
211
|
-
instance_id: 'inst-abc',
|
|
212
|
-
private_ip: 'fdaa::1',
|
|
213
|
-
config: {
|
|
214
|
-
image: 'registry.fly.io/my-app:latest',
|
|
215
|
-
env: { NODE_ENV: 'production' },
|
|
216
|
-
guest: { cpus: 1, cpu_kind: 'shared', memory_mb: 256 },
|
|
217
|
-
},
|
|
218
|
-
image_ref: { repository: 'registry.fly.io/my-app', tag: 'latest', digest: 'sha256:abc' },
|
|
219
|
-
created_at: '2024-06-01',
|
|
220
|
-
updated_at: '2024-06-01',
|
|
221
|
-
}));
|
|
222
|
-
const machine = await service.createMachine('my-app', {
|
|
223
|
-
image: 'registry.fly.io/my-app:latest',
|
|
224
|
-
env: { NODE_ENV: 'production' },
|
|
225
|
-
guest: { cpus: 1, cpu_kind: 'shared', memory_mb: 256 },
|
|
226
|
-
}, { name: 'web-1', region: 'iad' });
|
|
227
|
-
|
|
228
|
-
expect(machine.id).toBe('mach-1');
|
|
229
|
-
expect(machine.name).toBe('web-1');
|
|
230
|
-
expect(machine.state).toBe('started');
|
|
231
|
-
expect(machine.region).toBe('iad');
|
|
232
|
-
expect(machine.config.image).toBe('registry.fly.io/my-app:latest');
|
|
233
|
-
expect(machine.config.env?.NODE_ENV).toBe('production');
|
|
234
|
-
expect(machine.imageRef?.digest).toBe('sha256:abc');
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('should throw on non-OK response', async () => {
|
|
238
|
-
mockFetch.mockResolvedValueOnce(mockResponse('App not found', 404));
|
|
239
|
-
await expect(service.createMachine('missing-app', {
|
|
240
|
-
image: 'nginx:latest',
|
|
241
|
-
})).rejects.toThrow('Fly.io API 404');
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
describe('listMachines', () => {
|
|
246
|
-
it('should return a list of machines', async () => {
|
|
247
|
-
mockFetch.mockResolvedValueOnce(mockResponse([
|
|
248
|
-
{
|
|
249
|
-
id: 'm1', name: 'web', state: 'started', region: 'iad',
|
|
250
|
-
instance_id: 'i1', private_ip: 'fdaa::1',
|
|
251
|
-
config: { image: 'nginx:latest' },
|
|
252
|
-
created_at: '2024-01-01', updated_at: '2024-01-01',
|
|
253
|
-
},
|
|
254
|
-
{
|
|
255
|
-
id: 'm2', name: 'worker', state: 'stopped', region: 'lhr',
|
|
256
|
-
instance_id: 'i2', private_ip: 'fdaa::2',
|
|
257
|
-
config: { image: 'node:20' },
|
|
258
|
-
created_at: '2024-01-02', updated_at: '2024-01-02',
|
|
259
|
-
},
|
|
260
|
-
]));
|
|
261
|
-
const machines = await service.listMachines('my-app');
|
|
262
|
-
expect(machines).toHaveLength(2);
|
|
263
|
-
expect(machines[0].name).toBe('web');
|
|
264
|
-
expect(machines[0].state).toBe('started');
|
|
265
|
-
expect(machines[1].name).toBe('worker');
|
|
266
|
-
expect(machines[1].region).toBe('lhr');
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
it('should return empty array when no machines exist', async () => {
|
|
270
|
-
mockFetch.mockResolvedValueOnce(mockResponse([]));
|
|
271
|
-
const machines = await service.listMachines('empty-app');
|
|
272
|
-
expect(machines).toHaveLength(0);
|
|
273
|
-
});
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
describe('destroyMachine', () => {
|
|
277
|
-
it('should stop then delete the machine', async () => {
|
|
278
|
-
// stop
|
|
279
|
-
mockFetch.mockResolvedValueOnce(mockResponse({ ok: true }));
|
|
280
|
-
// delete
|
|
281
|
-
mockFetch.mockResolvedValueOnce(mockResponse('', 200));
|
|
282
|
-
await expect(service.destroyMachine('my-app', 'mach-1')).resolves.toBeUndefined();
|
|
283
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it('should succeed even if stop fails (machine already stopped)', async () => {
|
|
287
|
-
// stop fails
|
|
288
|
-
mockFetch.mockRejectedValueOnce(new Error('Machine already stopped'));
|
|
289
|
-
// delete succeeds
|
|
290
|
-
mockFetch.mockResolvedValueOnce(mockResponse('', 200));
|
|
291
|
-
await expect(service.destroyMachine('my-app', 'mach-1')).resolves.toBeUndefined();
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
it('should treat 404 on delete as success', async () => {
|
|
295
|
-
// stop
|
|
296
|
-
mockFetch.mockResolvedValueOnce(mockResponse({ ok: true }));
|
|
297
|
-
// delete returns 404
|
|
298
|
-
mockFetch.mockResolvedValueOnce(mockResponse('Not Found', 404));
|
|
299
|
-
await expect(service.destroyMachine('my-app', 'mach-gone')).resolves.toBeUndefined();
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it('should throw on non-404 error during delete', async () => {
|
|
303
|
-
// stop
|
|
304
|
-
mockFetch.mockResolvedValueOnce(mockResponse({ ok: true }));
|
|
305
|
-
// delete returns 500
|
|
306
|
-
mockFetch.mockResolvedValueOnce(mockResponse('Internal Error', 500));
|
|
307
|
-
await expect(service.destroyMachine('my-app', 'mach-1')).rejects.toThrow('Fly.io API 500');
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
it('should pass force=true as query param when force is set', async () => {
|
|
311
|
-
// stop
|
|
312
|
-
mockFetch.mockResolvedValueOnce(mockResponse({ ok: true }));
|
|
313
|
-
// delete
|
|
314
|
-
mockFetch.mockResolvedValueOnce(mockResponse('', 200));
|
|
315
|
-
await service.destroyMachine('my-app', 'mach-1', true);
|
|
316
|
-
const deleteUrl = mockFetch.mock.calls[1][0] as string;
|
|
317
|
-
expect(deleteUrl).toContain('force=true');
|
|
318
|
-
});
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
describe('updateMachine', () => {
|
|
322
|
-
it('should update machine config and return the updated machine', async () => {
|
|
323
|
-
mockFetch.mockResolvedValueOnce(mockResponse({
|
|
324
|
-
id: 'mach-1',
|
|
325
|
-
name: 'web',
|
|
326
|
-
state: 'started',
|
|
327
|
-
region: 'iad',
|
|
328
|
-
instance_id: 'i1',
|
|
329
|
-
private_ip: 'fdaa::1',
|
|
330
|
-
config: {
|
|
331
|
-
image: 'nginx:1.26',
|
|
332
|
-
guest: { cpus: 2, cpu_kind: 'shared', memory_mb: 512 },
|
|
333
|
-
},
|
|
334
|
-
created_at: '2024-01-01',
|
|
335
|
-
updated_at: '2024-06-01',
|
|
336
|
-
}));
|
|
337
|
-
const machine = await service.updateMachine('my-app', 'mach-1', {
|
|
338
|
-
image: 'nginx:1.26',
|
|
339
|
-
guest: { cpus: 2, cpu_kind: 'shared', memory_mb: 512 },
|
|
340
|
-
});
|
|
341
|
-
expect(machine.config.image).toBe('nginx:1.26');
|
|
342
|
-
expect(machine.config.guest?.memory_mb).toBe(512);
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
it('should throw on failure', async () => {
|
|
346
|
-
mockFetch.mockResolvedValueOnce(mockResponse('Bad Request', 400));
|
|
347
|
-
await expect(service.updateMachine('my-app', 'mach-1', {
|
|
348
|
-
image: 'invalid',
|
|
349
|
-
})).rejects.toThrow('Fly.io API 400');
|
|
350
|
-
});
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
// ── Volumes ─────────────────────────────────────────────────────────────
|
|
354
|
-
|
|
355
|
-
describe('createVolume', () => {
|
|
356
|
-
it('should create a volume and return mapped result', async () => {
|
|
357
|
-
mockFetch.mockResolvedValueOnce(mockResponse({
|
|
358
|
-
id: 'vol-abc',
|
|
359
|
-
name: 'data',
|
|
360
|
-
state: 'created',
|
|
361
|
-
size_gb: 10,
|
|
362
|
-
region: 'iad',
|
|
363
|
-
encrypted: true,
|
|
364
|
-
created_at: '2024-06-01',
|
|
365
|
-
}));
|
|
366
|
-
const vol = await service.createVolume('my-app', {
|
|
367
|
-
name: 'data',
|
|
368
|
-
region: 'iad',
|
|
369
|
-
sizeGb: 10,
|
|
370
|
-
});
|
|
371
|
-
expect(vol.id).toBe('vol-abc');
|
|
372
|
-
expect(vol.name).toBe('data');
|
|
373
|
-
expect(vol.sizeGb).toBe(10);
|
|
374
|
-
expect(vol.encrypted).toBe(true);
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
it('should default encrypted to true in the request', async () => {
|
|
378
|
-
mockFetch.mockResolvedValueOnce(mockResponse({
|
|
379
|
-
id: 'vol-xyz', name: 'db', state: 'created', size_gb: 5,
|
|
380
|
-
region: 'lhr', encrypted: true, created_at: '2024-06-01',
|
|
381
|
-
}));
|
|
382
|
-
await service.createVolume('my-app', { name: 'db', region: 'lhr', sizeGb: 5 });
|
|
383
|
-
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
384
|
-
expect(body.encrypted).toBe(true);
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
it('should allow overriding encrypted to false', async () => {
|
|
388
|
-
mockFetch.mockResolvedValueOnce(mockResponse({
|
|
389
|
-
id: 'vol-plain', name: 'tmp', state: 'created', size_gb: 1,
|
|
390
|
-
region: 'iad', encrypted: false, created_at: '2024-06-01',
|
|
391
|
-
}));
|
|
392
|
-
await service.createVolume('my-app', { name: 'tmp', region: 'iad', sizeGb: 1, encrypted: false });
|
|
393
|
-
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
394
|
-
expect(body.encrypted).toBe(false);
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
it('should throw on failure', async () => {
|
|
398
|
-
mockFetch.mockResolvedValueOnce(mockResponse('App not found', 404));
|
|
399
|
-
await expect(service.createVolume('missing', {
|
|
400
|
-
name: 'vol', region: 'iad', sizeGb: 1,
|
|
401
|
-
})).rejects.toThrow('Fly.io API 404');
|
|
402
|
-
});
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
describe('listVolumes', () => {
|
|
406
|
-
it('should return a list of volumes', async () => {
|
|
407
|
-
mockFetch.mockResolvedValueOnce(mockResponse([
|
|
408
|
-
{
|
|
409
|
-
id: 'v1', name: 'data', state: 'created', size_gb: 10, region: 'iad',
|
|
410
|
-
encrypted: true, created_at: '2024-01-01', attached_machine_id: 'mach-1',
|
|
411
|
-
},
|
|
412
|
-
{
|
|
413
|
-
id: 'v2', name: 'logs', state: 'created', size_gb: 5, region: 'iad',
|
|
414
|
-
encrypted: true, created_at: '2024-01-02',
|
|
415
|
-
},
|
|
416
|
-
]));
|
|
417
|
-
const volumes = await service.listVolumes('my-app');
|
|
418
|
-
expect(volumes).toHaveLength(2);
|
|
419
|
-
expect(volumes[0].name).toBe('data');
|
|
420
|
-
expect(volumes[0].attachedMachineId).toBe('mach-1');
|
|
421
|
-
expect(volumes[1].attachedMachineId).toBeUndefined();
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
it('should return empty array when no volumes exist', async () => {
|
|
425
|
-
mockFetch.mockResolvedValueOnce(mockResponse([]));
|
|
426
|
-
const volumes = await service.listVolumes('empty-app');
|
|
427
|
-
expect(volumes).toHaveLength(0);
|
|
428
|
-
});
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
// ── Request Details ─────────────────────────────────────────────────────
|
|
432
|
-
|
|
433
|
-
describe('REST request format', () => {
|
|
434
|
-
it('should send Bearer token to Machines API', async () => {
|
|
435
|
-
mockFetch.mockResolvedValueOnce(mockResponse([]));
|
|
436
|
-
await service.listMachines('my-app');
|
|
437
|
-
const [url, init] = mockFetch.mock.calls[0];
|
|
438
|
-
expect(url).toBe('https://api.machines.dev/v1/apps/my-app/machines');
|
|
439
|
-
expect(init.headers['Authorization']).toBe('Bearer fly-test-token-xyz');
|
|
440
|
-
expect(init.headers['Content-Type']).toBe('application/json');
|
|
441
|
-
});
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
describe('GraphQL request format', () => {
|
|
445
|
-
it('should send Bearer token to GraphQL API', async () => {
|
|
446
|
-
mockFetch.mockResolvedValueOnce(gqlResponse({ apps: { nodes: [] } }));
|
|
447
|
-
await service.listApps();
|
|
448
|
-
const [url, init] = mockFetch.mock.calls[0];
|
|
449
|
-
expect(url).toBe('https://api.fly.io/graphql');
|
|
450
|
-
expect(init.method).toBe('POST');
|
|
451
|
-
expect(init.headers['Authorization']).toBe('Bearer fly-test-token-xyz');
|
|
452
|
-
});
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
// ── Default Config ──────────────────────────────────────────────────────
|
|
456
|
-
|
|
457
|
-
describe('default config', () => {
|
|
458
|
-
it('should use default base URLs when none provided', () => {
|
|
459
|
-
const svc = new FlyService({ token: 'test' });
|
|
460
|
-
// We can verify by making a request and checking the URL
|
|
461
|
-
// Since the defaults are set in the constructor, just verify the service is created
|
|
462
|
-
expect(svc.isRunning).toBe(false);
|
|
463
|
-
});
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
// ── Error Handling ──────────────────────────────────────────────────────
|
|
467
|
-
|
|
468
|
-
describe('error handling', () => {
|
|
469
|
-
it('should join multiple GraphQL errors with semicolons', async () => {
|
|
470
|
-
mockFetch.mockResolvedValueOnce(gqlErrorResponse(['Error one', 'Error two']));
|
|
471
|
-
await expect(service.listApps()).rejects.toThrow('Fly.io GraphQL: Error one; Error two');
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
it('should handle HTTP-level errors from GraphQL endpoint', async () => {
|
|
475
|
-
mockFetch.mockResolvedValueOnce(mockResponse('Bad Gateway', 502));
|
|
476
|
-
await expect(service.listApps()).rejects.toThrow('Fly.io GraphQL 502');
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
it('should handle HTTP-level errors from Machines API', async () => {
|
|
480
|
-
mockFetch.mockResolvedValueOnce(mockResponse('Too Many Requests', 429));
|
|
481
|
-
await expect(service.listMachines('app')).rejects.toThrow('Fly.io API 429');
|
|
482
|
-
});
|
|
483
|
-
});
|
|
484
|
-
});
|
package/test/index.test.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
-
import { createExtensionPack } from '../src/index.js';
|
|
4
|
-
|
|
5
|
-
const mockFetch = vi.fn();
|
|
6
|
-
vi.stubGlobal('fetch', mockFetch);
|
|
7
|
-
|
|
8
|
-
function okResponse(data: any = {}) {
|
|
9
|
-
return { ok: true, status: 200, text: async () => JSON.stringify(data), json: async () => data } as Response;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
describe('createExtensionPack (Fly.io)', () => {
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
vi.clearAllMocks();
|
|
15
|
-
mockFetch.mockResolvedValue(okResponse({ id: 'u1', name: 'test-org' }));
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('should return a valid ExtensionPack', () => {
|
|
19
|
-
const pack = createExtensionPack({ secrets: { 'fly.token': 'tok' } });
|
|
20
|
-
expect(pack.name).toBe('@framers/agentos-ext-cloud-flyio');
|
|
21
|
-
expect(pack.version).toBe('0.1.0');
|
|
22
|
-
expect(pack.descriptors).toHaveLength(4);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('should register correct tool IDs', () => {
|
|
26
|
-
const pack = createExtensionPack({ secrets: { 'fly.token': 'tok' } });
|
|
27
|
-
const ids = pack.descriptors.map((d) => d.id);
|
|
28
|
-
expect(ids).toContain('flyDeployApp');
|
|
29
|
-
expect(ids).toContain('flyListApps');
|
|
30
|
-
expect(ids).toContain('flyScaleApp');
|
|
31
|
-
expect(ids).toContain('flyCreateVolume');
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('onActivate should throw without token', async () => {
|
|
35
|
-
const pack = createExtensionPack({ secrets: {} });
|
|
36
|
-
await expect(pack.onActivate!()).rejects.toThrow();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('onActivate should succeed with valid token', async () => {
|
|
40
|
-
const pack = createExtensionPack({ secrets: { 'fly.token': 'valid' } });
|
|
41
|
-
await expect(pack.onActivate!()).resolves.toBeUndefined();
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('onDeactivate should complete', async () => {
|
|
45
|
-
const pack = createExtensionPack({ secrets: { 'fly.token': 'valid' } });
|
|
46
|
-
await pack.onActivate!();
|
|
47
|
-
await expect(pack.onDeactivate!()).resolves.toBeUndefined();
|
|
48
|
-
});
|
|
49
|
-
});
|