@framers/agentos-ext-cloud-cloudflare-pages 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.
@@ -0,0 +1,409 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import { CloudflareService } from '../src/CloudflareService.js';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function mockResponse(data: unknown, status = 200): Response {
10
+ return {
11
+ ok: status >= 200 && status < 300,
12
+ status,
13
+ text: () => Promise.resolve(typeof data === 'string' ? data : JSON.stringify(data)),
14
+ json: () => Promise.resolve(data),
15
+ headers: new Headers(),
16
+ } as unknown as Response;
17
+ }
18
+
19
+ const API_TOKEN = 'test-cf-token';
20
+ const ACCOUNT_ID = 'acct_abc123';
21
+
22
+ function createService() {
23
+ return new CloudflareService({
24
+ apiToken: API_TOKEN,
25
+ accountId: ACCOUNT_ID,
26
+ baseUrl: 'https://api.cloudflare.com/client/v4',
27
+ });
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Tests
32
+ // ---------------------------------------------------------------------------
33
+
34
+ describe('CloudflareService', () => {
35
+ let mockFetch: ReturnType<typeof vi.fn>;
36
+ let service: CloudflareService;
37
+
38
+ beforeEach(() => {
39
+ mockFetch = vi.fn();
40
+ vi.stubGlobal('fetch', mockFetch);
41
+ service = createService();
42
+ });
43
+
44
+ afterEach(() => {
45
+ vi.restoreAllMocks();
46
+ });
47
+
48
+ // ── initialize / shutdown ─────────────────────────────────────────────
49
+
50
+ describe('initialize', () => {
51
+ it('should set isRunning to true on valid token', async () => {
52
+ mockFetch.mockResolvedValueOnce(mockResponse({ result: { status: 'active' } }));
53
+
54
+ await service.initialize();
55
+
56
+ expect(service.isRunning).toBe(true);
57
+ expect(mockFetch).toHaveBeenCalledWith(
58
+ expect.stringContaining('/user/tokens/verify'),
59
+ expect.objectContaining({
60
+ headers: expect.objectContaining({
61
+ Authorization: `Bearer ${API_TOKEN}`,
62
+ }),
63
+ }),
64
+ );
65
+ });
66
+
67
+ it('should throw on auth failure', async () => {
68
+ mockFetch.mockResolvedValueOnce(mockResponse('Bad token', 403));
69
+
70
+ await expect(service.initialize()).rejects.toThrow('Cloudflare auth failed: 403');
71
+ expect(service.isRunning).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe('shutdown', () => {
76
+ it('should set isRunning to false', async () => {
77
+ mockFetch.mockResolvedValueOnce(mockResponse({ result: {} }));
78
+ await service.initialize();
79
+ await service.shutdown();
80
+ expect(service.isRunning).toBe(false);
81
+ });
82
+ });
83
+
84
+ // ── Pages Projects ────────────────────────────────────────────────────
85
+
86
+ describe('listProjects', () => {
87
+ it('should return mapped projects', async () => {
88
+ mockFetch.mockResolvedValueOnce(mockResponse({
89
+ result: [
90
+ { id: 'p1', name: 'site-a', subdomain: 'site-a.pages.dev', production_branch: 'main', created_on: '2024-01-01', domains: ['site-a.com'] },
91
+ ],
92
+ }));
93
+
94
+ const projects = await service.listProjects();
95
+
96
+ expect(projects).toHaveLength(1);
97
+ expect(projects[0].name).toBe('site-a');
98
+ expect(projects[0].subdomain).toBe('site-a.pages.dev');
99
+ expect(projects[0].productionBranch).toBe('main');
100
+ });
101
+
102
+ it('should pass per_page limit', async () => {
103
+ mockFetch.mockResolvedValueOnce(mockResponse({ result: [] }));
104
+
105
+ await service.listProjects(10);
106
+
107
+ const url = mockFetch.mock.calls[0][0] as string;
108
+ expect(url).toContain('per_page=10');
109
+ });
110
+
111
+ it('should handle missing result gracefully', async () => {
112
+ mockFetch.mockResolvedValueOnce(mockResponse({}));
113
+
114
+ const projects = await service.listProjects();
115
+ expect(projects).toEqual([]);
116
+ });
117
+ });
118
+
119
+ describe('getProject', () => {
120
+ it('should return a single mapped project', async () => {
121
+ mockFetch.mockResolvedValueOnce(mockResponse({
122
+ result: {
123
+ id: 'p1', name: 'my-site', production_branch: 'main',
124
+ created_on: '2024-01-01', domains: [],
125
+ source: { type: 'github', config: { owner: 'acme', repo_name: 'my-site', production_branch: 'main' } },
126
+ },
127
+ }));
128
+
129
+ const project = await service.getProject('my-site');
130
+
131
+ expect(project.id).toBe('p1');
132
+ expect(project.source?.config?.owner).toBe('acme');
133
+ });
134
+
135
+ it('should throw on 404', async () => {
136
+ mockFetch.mockResolvedValueOnce(mockResponse('Not Found', 404));
137
+
138
+ await expect(service.getProject('nope')).rejects.toThrow('Cloudflare API 404');
139
+ });
140
+ });
141
+
142
+ describe('createProject', () => {
143
+ it('should create a project with build config', async () => {
144
+ mockFetch.mockResolvedValueOnce(mockResponse({
145
+ result: { id: 'p-new', name: 'new-site', production_branch: 'main', created_on: '2024-02-01', domains: [] },
146
+ }));
147
+
148
+ const project = await service.createProject('new-site', {
149
+ buildCommand: 'npm run build',
150
+ buildOutputDirectory: 'dist',
151
+ });
152
+
153
+ expect(project.name).toBe('new-site');
154
+
155
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
156
+ expect(body.build_config.build_command).toBe('npm run build');
157
+ expect(body.build_config.destination_dir).toBe('dist');
158
+ });
159
+
160
+ it('should attach git source when gitUrl is provided', async () => {
161
+ mockFetch.mockResolvedValueOnce(mockResponse({
162
+ result: { id: 'p-git', name: 'from-git', production_branch: 'main', created_on: '2024-02-01', domains: [] },
163
+ }));
164
+
165
+ await service.createProject('from-git', {
166
+ gitUrl: 'https://github.com/acme/from-git',
167
+ productionBranch: 'develop',
168
+ });
169
+
170
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
171
+ expect(body.source.type).toBe('github');
172
+ expect(body.source.config.owner).toBe('acme');
173
+ expect(body.source.config.repo_name).toBe('from-git');
174
+ expect(body.source.config.production_branch).toBe('develop');
175
+ });
176
+
177
+ it('should throw for unparseable git URL', async () => {
178
+ await expect(
179
+ service.createProject('bad', { gitUrl: 'ftp://bad-url/repo' }),
180
+ ).rejects.toThrow('Cannot parse git URL');
181
+ });
182
+ });
183
+
184
+ // ── Deployments ───────────────────────────────────────────────────────
185
+
186
+ describe('createDeployment', () => {
187
+ it('should trigger a deployment and return result', async () => {
188
+ mockFetch.mockResolvedValueOnce(mockResponse({
189
+ result: {
190
+ id: 'dep-1', url: 'abc123.my-site.pages.dev',
191
+ environment: 'production', project_name: 'my-site',
192
+ latest_stage: { name: 'build', status: 'active' },
193
+ },
194
+ }));
195
+
196
+ const deploy = await service.createDeployment('my-site');
197
+
198
+ expect(deploy.id).toBe('dep-1');
199
+ expect(deploy.url).toBe('https://abc123.my-site.pages.dev');
200
+ expect(deploy.environment).toBe('production');
201
+ });
202
+
203
+ it('should include branch in FormData when provided', async () => {
204
+ mockFetch.mockResolvedValueOnce(mockResponse({
205
+ result: {
206
+ id: 'dep-2', url: 'preview.pages.dev', environment: 'preview',
207
+ latest_stage: { name: 'queued', status: 'idle' },
208
+ },
209
+ }));
210
+
211
+ await service.createDeployment('my-site', { branch: 'feature/test' });
212
+
213
+ const callArgs = mockFetch.mock.calls[0][1];
214
+ expect(callArgs.body).toBeInstanceOf(FormData);
215
+ });
216
+ });
217
+
218
+ describe('deployFromGit', () => {
219
+ it('should create project if it does not exist and deploy', async () => {
220
+ // getProject -> 404
221
+ mockFetch.mockResolvedValueOnce(mockResponse('Not Found', 404));
222
+ // createProject
223
+ mockFetch.mockResolvedValueOnce(mockResponse({
224
+ result: { id: 'p-auto', name: 'my-repo', production_branch: 'main', created_on: '2024-01-01', domains: [] },
225
+ }));
226
+ // setProjectEnvVars (PATCH)
227
+ mockFetch.mockResolvedValueOnce(mockResponse({ result: {} }));
228
+ // createDeployment
229
+ mockFetch.mockResolvedValueOnce(mockResponse({
230
+ result: {
231
+ id: 'dep-3', url: 'my-repo.pages.dev', environment: 'production',
232
+ latest_stage: { name: 'build', status: 'active' },
233
+ },
234
+ }));
235
+
236
+ const result = await service.deployFromGit({
237
+ gitUrl: 'https://github.com/acme/my-repo',
238
+ envVars: { SECRET: 'val' },
239
+ });
240
+
241
+ expect(result.id).toBe('dep-3');
242
+ expect(mockFetch).toHaveBeenCalledTimes(4);
243
+ });
244
+ });
245
+
246
+ describe('getDeployment', () => {
247
+ it('should return mapped deployment', async () => {
248
+ mockFetch.mockResolvedValueOnce(mockResponse({
249
+ result: {
250
+ id: 'dep-1', project_id: 'p1', project_name: 'my-site',
251
+ url: 'https://my-site.pages.dev', environment: 'production',
252
+ created_on: '2024-01-01', modified_on: '2024-01-01',
253
+ latest_stage: { name: 'deploy', status: 'success' },
254
+ stages: [{ name: 'build', status: 'success', started_on: '2024-01-01', ended_on: '2024-01-01' }],
255
+ },
256
+ }));
257
+
258
+ const dep = await service.getDeployment('my-site', 'dep-1');
259
+
260
+ expect(dep.id).toBe('dep-1');
261
+ expect(dep.latestStage.status).toBe('success');
262
+ expect(dep.stages).toHaveLength(1);
263
+ });
264
+ });
265
+
266
+ describe('listDeployments', () => {
267
+ it('should return list of mapped deployments', async () => {
268
+ mockFetch.mockResolvedValueOnce(mockResponse({
269
+ result: [
270
+ { id: 'd1', project_id: 'p1', project_name: 's', url: 'a.pages.dev', environment: 'production', created_on: '', modified_on: '', latest_stage: { name: 'deploy', status: 'success' }, stages: [] },
271
+ ],
272
+ }));
273
+
274
+ const deps = await service.listDeployments('my-site', 5);
275
+
276
+ expect(deps).toHaveLength(1);
277
+ const url = mockFetch.mock.calls[0][0] as string;
278
+ expect(url).toContain('per_page=5');
279
+ });
280
+ });
281
+
282
+ // ── DNS ───────────────────────────────────────────────────────────────
283
+
284
+ describe('getZoneByDomain', () => {
285
+ it('should return the matching zone', async () => {
286
+ mockFetch.mockResolvedValueOnce(mockResponse({
287
+ result: [{ id: 'z1', name: 'example.com', status: 'active', name_servers: ['ns1.cf.com'] }],
288
+ }));
289
+
290
+ const zone = await service.getZoneByDomain('example.com');
291
+
292
+ expect(zone.id).toBe('z1');
293
+ expect(zone.nameServers).toContain('ns1.cf.com');
294
+ });
295
+
296
+ it('should throw when no zone found', async () => {
297
+ mockFetch.mockResolvedValueOnce(mockResponse({ result: [] }));
298
+
299
+ await expect(service.getZoneByDomain('unknown.io')).rejects.toThrow(
300
+ 'No Cloudflare zone found for domain: unknown.io',
301
+ );
302
+ });
303
+ });
304
+
305
+ describe('createDnsRecord', () => {
306
+ it('should create a DNS record and return mapped result', async () => {
307
+ mockFetch.mockResolvedValueOnce(mockResponse({
308
+ result: { id: 'r1', type: 'A', name: 'api.example.com', content: '1.2.3.4', ttl: 300, proxied: true },
309
+ }));
310
+
311
+ const record = await service.createDnsRecord('z1', {
312
+ type: 'A', name: 'api.example.com', content: '1.2.3.4', ttl: 300, proxied: true,
313
+ });
314
+
315
+ expect(record.id).toBe('r1');
316
+ expect(record.proxied).toBe(true);
317
+ });
318
+ });
319
+
320
+ describe('updateDnsRecord', () => {
321
+ it('should update a DNS record', async () => {
322
+ mockFetch.mockResolvedValueOnce(mockResponse({
323
+ result: { id: 'r1', type: 'A', name: 'api.example.com', content: '5.6.7.8', ttl: 600, proxied: false },
324
+ }));
325
+
326
+ const record = await service.updateDnsRecord('z1', 'r1', { content: '5.6.7.8', ttl: 600 });
327
+
328
+ expect(record.content).toBe('5.6.7.8');
329
+ expect(mockFetch.mock.calls[0][1].method).toBe('PUT');
330
+ });
331
+ });
332
+
333
+ describe('deleteDnsRecord', () => {
334
+ it('should send DELETE request', async () => {
335
+ mockFetch.mockResolvedValueOnce(mockResponse({ result: null }));
336
+
337
+ await service.deleteDnsRecord('z1', 'r1');
338
+
339
+ expect(mockFetch.mock.calls[0][1].method).toBe('DELETE');
340
+ });
341
+ });
342
+
343
+ // ── Workers ───────────────────────────────────────────────────────────
344
+
345
+ describe('deployWorker', () => {
346
+ it('should upload a worker script and return metadata', async () => {
347
+ mockFetch.mockResolvedValueOnce(mockResponse({
348
+ result: { id: 'w1', tag: 'v1', etag: 'abc', size: 1024, created_on: '2024-01-01', modified_on: '2024-01-01' },
349
+ }));
350
+
351
+ const worker = await service.deployWorker({
352
+ name: 'my-worker',
353
+ script: 'export default { fetch() { return new Response("ok"); } }',
354
+ });
355
+
356
+ expect(worker.id).toBe('w1');
357
+ expect(worker.size).toBe(1024);
358
+
359
+ const callArgs = mockFetch.mock.calls[0][1];
360
+ expect(callArgs.body).toBeInstanceOf(FormData);
361
+ expect(callArgs.method).toBe('PUT');
362
+ });
363
+ });
364
+
365
+ describe('listWorkers', () => {
366
+ it('should return mapped worker list', async () => {
367
+ mockFetch.mockResolvedValueOnce(mockResponse({
368
+ result: [
369
+ { id: 'w1', tag: 't1', etag: 'e1', size: 512, created_on: '2024-01-01', modified_on: '2024-01-01' },
370
+ { id: 'w2', tag: 't2', etag: 'e2', size: 256, created_on: '2024-01-02', modified_on: '2024-01-02' },
371
+ ],
372
+ }));
373
+
374
+ const workers = await service.listWorkers();
375
+
376
+ expect(workers).toHaveLength(2);
377
+ expect(workers[0].id).toBe('w1');
378
+ });
379
+ });
380
+
381
+ describe('deleteWorker', () => {
382
+ it('should send DELETE request for the named worker', async () => {
383
+ mockFetch.mockResolvedValueOnce(mockResponse({ result: null }));
384
+
385
+ await service.deleteWorker('my-worker');
386
+
387
+ const url = mockFetch.mock.calls[0][0] as string;
388
+ expect(url).toContain(`/accounts/${ACCOUNT_ID}/workers/scripts/my-worker`);
389
+ expect(mockFetch.mock.calls[0][1].method).toBe('DELETE');
390
+ });
391
+ });
392
+
393
+ // ── setProjectEnvVars ────────────────────────────────────────────────
394
+
395
+ describe('setProjectEnvVars', () => {
396
+ it('should PATCH project with environment variable map', async () => {
397
+ mockFetch.mockResolvedValueOnce(mockResponse({ result: {} }));
398
+
399
+ await service.setProjectEnvVars('my-site', { API_KEY: 'secret' }, 'production');
400
+
401
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
402
+ expect(body.deployment_configs.production.env_vars.API_KEY).toEqual({
403
+ value: 'secret',
404
+ type: 'plain_text',
405
+ });
406
+ expect(mockFetch.mock.calls[0][1].method).toBe('PATCH');
407
+ });
408
+ });
409
+ });
@@ -0,0 +1,56 @@
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 () => ({ success: true, result: data }) } as Response;
10
+ }
11
+
12
+ describe('createExtensionPack (Cloudflare Pages)', () => {
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ mockFetch.mockResolvedValue(okResponse({ status: 'active' }));
16
+ });
17
+
18
+ it('should return a valid ExtensionPack', () => {
19
+ const pack = createExtensionPack({ secrets: { 'cloudflare.apiToken': 'tok', 'cloudflare.accountId': 'acc' } });
20
+ expect(pack.name).toBe('@framers/agentos-ext-cloud-cloudflare-pages');
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: { 'cloudflare.apiToken': 'tok', 'cloudflare.accountId': 'acc' } });
27
+ const ids = pack.descriptors.map((d) => d.id);
28
+ expect(ids).toContain('cfDeployPages');
29
+ expect(ids).toContain('cfListProjects');
30
+ expect(ids).toContain('cfConfigureDns');
31
+ expect(ids).toContain('cfCreateWorker');
32
+ });
33
+
34
+ it('should set all descriptors to kind "tool"', () => {
35
+ const pack = createExtensionPack({ secrets: { 'cloudflare.apiToken': 'tok', 'cloudflare.accountId': 'acc' } });
36
+ for (const d of pack.descriptors) {
37
+ expect(d.kind).toBe('tool');
38
+ }
39
+ });
40
+
41
+ it('onActivate should throw without credentials', async () => {
42
+ const pack = createExtensionPack({ secrets: {} });
43
+ await expect(pack.onActivate!()).rejects.toThrow();
44
+ });
45
+
46
+ it('onActivate should succeed with valid credentials', async () => {
47
+ const pack = createExtensionPack({ secrets: { 'cloudflare.apiToken': 'tok', 'cloudflare.accountId': 'acc' } });
48
+ await expect(pack.onActivate!()).resolves.toBeUndefined();
49
+ });
50
+
51
+ it('onDeactivate should complete without error', async () => {
52
+ const pack = createExtensionPack({ secrets: { 'cloudflare.apiToken': 'tok', 'cloudflare.accountId': 'acc' } });
53
+ await pack.onActivate!();
54
+ await expect(pack.onDeactivate!()).resolves.toBeUndefined();
55
+ });
56
+ });