@amodalai/amodal 0.3.26 → 0.3.28

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.
@@ -1,491 +0,0 @@
1
- /**
2
- * @license
3
- * Copyright 2025 Amodal Labs, Inc.
4
- * SPDX-License-Identifier: MIT
5
- */
6
-
7
- /**
8
- * End-to-end plugin test: verdaccio → install → runtime → Stripe + Slack.
9
- *
10
- * Flow:
11
- * 1. Start a local verdaccio npm registry
12
- * 2. Publish @amodalai/connection-slack and @amodalai/connection-stripe to it
13
- * 3. Init a repo, install both plugins from verdaccio
14
- * 4. Verify connections load from installed packages
15
- * 5. Write local access.json overrides that allow writes without confirmation
16
- * 6. Boot a real @amodalai/runtime server with real credentials
17
- * 7. Send a chat asking the agent to create a Stripe customer
18
- * 8. Send a follow-up asking it to post to Slack about the customer
19
- * 9. Verify real side effects (Stripe customer exists, Slack message posted)
20
- * 10. Clean up
21
- *
22
- * No mocks — real LLM, real Stripe API, real Slack API, real npm registry, real runtime.
23
- */
24
-
25
- import {describe, it, expect, beforeAll, afterAll} from 'vitest';
26
- import {mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync} from 'node:fs';
27
- import {join, resolve,dirname} from 'node:path';
28
- import {tmpdir} from 'node:os';
29
- import {execSync, spawn} from 'node:child_process';
30
- import type {ChildProcess} from 'node:child_process';
31
- import {fileURLToPath} from 'node:url';
32
- import http from 'node:http';
33
-
34
- import {runInit} from './commands/init.js';
35
- import {runInstallPkg} from './commands/install-pkg.js';
36
- import {runValidate} from './commands/validate.js';
37
- import {runBuild} from './commands/build.js';
38
- import {runUninstall} from './commands/uninstall.js';
39
- import {loadRepo} from '@amodalai/core';
40
-
41
- // ---------------------------------------------------------------------------
42
- // Config
43
- // ---------------------------------------------------------------------------
44
-
45
- const SLACK_BOT_TOKEN = process.env['SLACK_BOT_TOKEN'] ?? '';
46
- const SLACK_CHANNEL_ID = process.env['SLACK_CHANNEL_ID'] ?? '';
47
- const STRIPE_SECRET_KEY = process.env['STRIPE_SECRET_KEY'] ?? '';
48
-
49
- const __filename = fileURLToPath(import.meta.url);
50
- const __dirname = dirname(__filename);
51
- const PLUGINS_DIR = resolve(__dirname, '../../plugins');
52
-
53
- // ---------------------------------------------------------------------------
54
- // Helpers: Verdaccio
55
- // ---------------------------------------------------------------------------
56
-
57
- let verdaccioProc: ChildProcess | null = null;
58
- let verdaccioPort: number;
59
-
60
- async function startVerdaccio(): Promise<number> {
61
- const port = 15000 + Math.floor(Math.random() * 1000);
62
- const storageDir = mkdtempSync(join(tmpdir(), 'verdaccio-storage-'));
63
-
64
- // Write a minimal verdaccio config
65
- const configPath = join(storageDir, 'config.yml');
66
- writeFileSync(configPath, [
67
- `storage: ${storageDir}/storage`,
68
- 'auth:',
69
- ' htpasswd:',
70
- ` file: ${storageDir}/htpasswd`,
71
- 'uplinks: {}',
72
- 'packages:',
73
- ' "@amodalai/*":',
74
- ' access: $anonymous',
75
- ' publish: $anonymous',
76
- ' "**":',
77
- ' access: $anonymous',
78
- 'server:',
79
- ' keepAliveTimeout: 10',
80
- `listen: 127.0.0.1:${port}`,
81
- 'log: { type: stderr, level: error }',
82
- ].join('\n'));
83
-
84
- // Find verdaccio JS entry — avoids shell shebang issues in vitest forks
85
- const verdaccioJs = resolve(
86
- __dirname,
87
- '../../../node_modules/.pnpm/verdaccio@6.3.2_typanion@3.14.0/node_modules/verdaccio/build/lib/cli.js',
88
- );
89
- verdaccioProc = spawn(process.execPath, [verdaccioJs, '--config', configPath], {
90
- stdio: ['ignore', 'pipe', 'pipe'],
91
- env: {...process.env},
92
- });
93
-
94
- // Wait for it to be ready
95
- const deadline = Date.now() + 30000;
96
- while (Date.now() < deadline) {
97
- try {
98
- const res = await fetch(`http://127.0.0.1:${port}/-/ping`);
99
- if (res.ok) return port;
100
- } catch {
101
- // not ready
102
- }
103
- await new Promise((r) => setTimeout(r, 300));
104
- }
105
- throw new Error('Verdaccio did not start within 30s');
106
- }
107
-
108
- function stopVerdaccio(): void {
109
- if (verdaccioProc) {
110
- verdaccioProc.kill('SIGTERM');
111
- verdaccioProc = null;
112
- }
113
- }
114
-
115
- // ---------------------------------------------------------------------------
116
- // Helpers: Publish plugin to verdaccio
117
- // ---------------------------------------------------------------------------
118
-
119
- function publishPlugin(pluginDir: string, registryUrl: string): void {
120
- // Create a user on verdaccio first (htpasswd auto-creates on adduser)
121
- const registryHost = registryUrl.replace(/^https?:\/\//, '');
122
- const token = Buffer.from('e2e:e2e').toString('base64');
123
- // npm publish needs auth — pass it inline via npmrc in the plugin dir
124
- const npmrcPath = join(pluginDir, '.npmrc');
125
- const npmrcExisted = existsSync(npmrcPath);
126
- writeFileSync(npmrcPath, `//${registryHost}/:_auth=${token}\nregistry=${registryUrl}\n`);
127
- try {
128
- execSync(`npm publish --registry ${registryUrl}`, {
129
- cwd: pluginDir,
130
- stdio: 'pipe',
131
- timeout: 30000,
132
- });
133
- } finally {
134
- // Clean up the temporary .npmrc so we don't pollute the plugin dir
135
- if (!npmrcExisted) {
136
- rmSync(npmrcPath, {force: true});
137
- }
138
- }
139
- }
140
-
141
- // ---------------------------------------------------------------------------
142
- // Helpers: Slack API
143
- // ---------------------------------------------------------------------------
144
-
145
- async function slackHistory(channel: string, limit = 5): Promise<Array<{text: string; ts: string; bot_id?: string}>> {
146
- const resp = await fetch(`https://slack.com/api/conversations.history?channel=${channel}&limit=${limit}`, {
147
- headers: {'Authorization': `Bearer ${SLACK_BOT_TOKEN}`},
148
- });
149
-
150
- const data = await resp.json() as {ok: boolean; messages: Array<{text: string; ts: string; bot_id?: string}>};
151
- return data.ok ? data.messages : [];
152
- }
153
-
154
- // ---------------------------------------------------------------------------
155
- // Helpers: Stripe API
156
- // ---------------------------------------------------------------------------
157
-
158
- async function stripeDeleteCustomer(id: string): Promise<void> {
159
- await fetch(`https://api.stripe.com/v1/customers/${id}`, {
160
- method: 'DELETE',
161
- headers: {'Authorization': `Bearer ${STRIPE_SECRET_KEY}`},
162
- });
163
- }
164
-
165
- // ---------------------------------------------------------------------------
166
- // Helpers: Send chat to runtime and collect SSE events
167
- // ---------------------------------------------------------------------------
168
-
169
- async function sendChat(
170
- port: number,
171
- message: string,
172
- appId: string,
173
- timeoutMs = 90000,
174
- ): Promise<Array<Record<string, unknown>>> {
175
- return new Promise((resolve, reject) => {
176
- const body = JSON.stringify({message, app_id: appId});
177
- const req = http.request(
178
- {
179
- hostname: '127.0.0.1',
180
- port,
181
- path: '/chat',
182
- method: 'POST',
183
- headers: {
184
- 'Content-Type': 'application/json',
185
- 'Content-Length': Buffer.byteLength(body),
186
- },
187
- timeout: timeoutMs,
188
- },
189
- (res) => {
190
- let rawBody = '';
191
- res.setEncoding('utf8');
192
- res.on('data', (chunk: string) => { rawBody += chunk; });
193
- res.on('end', () => {
194
- const events: Array<Record<string, unknown>> = [];
195
- for (const line of rawBody.split('\n')) {
196
- if (line.startsWith('data: ')) {
197
- try { events.push(JSON.parse(line.slice(6)) as Record<string, unknown>); } catch { /* skip */ }
198
- }
199
- }
200
- resolve(events);
201
- });
202
- },
203
- );
204
- req.on('error', reject);
205
- req.on('timeout', () => req.destroy(new Error('Chat request timed out')));
206
- req.write(body);
207
- req.end();
208
- });
209
- }
210
-
211
- // ===========================================================================
212
- // Test Suite
213
- // ===========================================================================
214
-
215
- describe('E2E Plugins: Verdaccio → Install → Stripe + Slack', () => {
216
- let repoDir: string;
217
- let registryUrl: string;
218
- let stripeCustomerId: string | null = null;
219
-
220
- // -------------------------------------------------------------------------
221
- // Setup: Start verdaccio, publish plugins, init repo
222
- // -------------------------------------------------------------------------
223
-
224
- beforeAll(async () => {
225
- // 1. Start verdaccio
226
- verdaccioPort = await startVerdaccio();
227
- registryUrl = `http://127.0.0.1:${verdaccioPort}`;
228
-
229
- // 2. Publish the slack and stripe plugins
230
- publishPlugin(join(PLUGINS_DIR, 'slack'), registryUrl);
231
- publishPlugin(join(PLUGINS_DIR, 'stripe'), registryUrl);
232
-
233
- // Verify packages are actually available on verdaccio
234
- const slackCheck = await fetch(`${registryUrl}/@amodal%2fconnection-slack`);
235
- if (!slackCheck.ok) throw new Error(`Slack plugin not found on verdaccio (${slackCheck.status})`);
236
- const stripeCheck = await fetch(`${registryUrl}/@amodal%2fconnection-stripe`);
237
- if (!stripeCheck.ok) throw new Error(`Stripe plugin not found on verdaccio (${stripeCheck.status})`);
238
-
239
- // 3. Init a fresh repo
240
- repoDir = mkdtempSync(join(tmpdir(), 'amodal-e2e-plugins-'));
241
- await runInit({cwd: repoDir, name: 'plugin-e2e', provider: 'anthropic'});
242
-
243
- // 4. Set registry env var so ensureNpmContext writes the correct .npmrc
244
- process.env['AMODAL_REGISTRY'] = registryUrl;
245
- // Also set npm_config_registry so npm itself uses verdaccio regardless of .npmrc resolution
246
- process.env['npm_config_registry'] = registryUrl;
247
- }, 60000);
248
-
249
- afterAll(async () => {
250
- // Clean up runtime if still running
251
- if (runtimeServer) {
252
- await runtimeServer.stop();
253
- }
254
-
255
- // Clean up Stripe customer if created
256
- if (stripeCustomerId) {
257
- await stripeDeleteCustomer(stripeCustomerId);
258
- }
259
-
260
- // Clean up env vars
261
- delete process.env['AMODAL_REGISTRY'];
262
- delete process.env['npm_config_registry'];
263
- delete process.env['STRIPE_SECRET_KEY'];
264
- delete process.env['SLACK_BOT_TOKEN'];
265
- stopVerdaccio();
266
- if (repoDir && existsSync(repoDir)) rmSync(repoDir, {recursive: true, force: true});
267
- });
268
-
269
- // -------------------------------------------------------------------------
270
- // Phase 1: Install plugins from verdaccio
271
- // -------------------------------------------------------------------------
272
-
273
- it('should install @amodalai/connection-slack from verdaccio', async () => {
274
- const failures = await runInstallPkg({
275
- cwd: repoDir,
276
- packages: [{type: 'connection', name: 'slack'}],
277
- });
278
- expect(failures).toBe(0);
279
-
280
- // Verify lock file updated
281
- const lockRaw = readFileSync(join(repoDir, 'amodal.lock'), 'utf-8');
282
- const lock = JSON.parse(lockRaw) as {packages: Record<string, unknown>};
283
- expect(lock.packages['connection/slack']).toBeDefined();
284
- }, 30000);
285
-
286
- it('should install @amodalai/connection-stripe from verdaccio', async () => {
287
- const failures = await runInstallPkg({
288
- cwd: repoDir,
289
- packages: [{type: 'connection', name: 'stripe'}],
290
- });
291
- expect(failures).toBe(0);
292
-
293
- const lockRaw = readFileSync(join(repoDir, 'amodal.lock'), 'utf-8');
294
- const lock = JSON.parse(lockRaw) as {packages: Record<string, unknown>};
295
- expect(lock.packages['connection/stripe']).toBeDefined();
296
- }, 30000);
297
-
298
- it('should list both installed packages', async () => {
299
- const code = await runList({cwd: repoDir, json: true});
300
- expect(code).toBe(0);
301
- });
302
-
303
- // -------------------------------------------------------------------------
304
- // Phase 2: Verify connections load from installed packages
305
- // -------------------------------------------------------------------------
306
-
307
- it('should load both connections from installed packages', async () => {
308
- const repo = await loadRepo({localPath: repoDir});
309
- expect(repo.connections.size).toBeGreaterThanOrEqual(2);
310
-
311
- const slack = repo.connections.get('slack');
312
- expect(slack).toBeDefined();
313
- expect(slack!.spec.auth?.type).toBe('bearer');
314
-
315
- const stripe = repo.connections.get('stripe');
316
- expect(stripe).toBeDefined();
317
- expect(stripe!.spec.auth?.type).toBe('bearer');
318
- });
319
-
320
- it('should validate the repo with installed connections', async () => {
321
- const errors = await runValidate({cwd: repoDir});
322
- expect(errors).toBe(0);
323
- });
324
-
325
- it('should build a snapshot that includes installed connections', async () => {
326
- const outputPath = join(repoDir, 'snapshot.json');
327
- const code = await runBuild({cwd: repoDir, output: outputPath});
328
- expect(code).toBe(0);
329
-
330
- const snapshot = JSON.parse(readFileSync(outputPath, 'utf-8')) as Record<string, unknown>;
331
- const connections = snapshot['connections'] as Record<string, unknown>;
332
- expect(connections['slack']).toBeDefined();
333
- expect(connections['stripe']).toBeDefined();
334
- });
335
-
336
- // -------------------------------------------------------------------------
337
- // Phase 3: Write local access overrides that allow writes without confirmation,
338
- // set real credentials, and boot the runtime
339
- // -------------------------------------------------------------------------
340
-
341
- let runtimeServer: {app: unknown; start: () => Promise<unknown>; stop: () => Promise<void>} | null = null;
342
- let runtimePort: number;
343
-
344
- it('should boot the runtime with real Stripe and Slack credentials', async () => {
345
- // Write local access.json overrides that add write endpoints without confirm
346
- const stripeAccessDir = join(repoDir, 'connections', 'stripe');
347
- mkdirSync(stripeAccessDir, {recursive: true});
348
- writeFileSync(join(stripeAccessDir, 'access.json'), JSON.stringify({
349
- import: 'stripe',
350
- endpoints: {
351
- 'GET /v1/customers': {returns: ['id', 'email', 'name', 'created', 'metadata']},
352
- 'POST /v1/customers': {returns: ['id', 'email', 'name', 'created', 'metadata']},
353
- },
354
- }, null, 2));
355
-
356
- const slackAccessDir = join(repoDir, 'connections', 'slack');
357
- mkdirSync(slackAccessDir, {recursive: true});
358
- writeFileSync(join(slackAccessDir, 'access.json'), JSON.stringify({
359
- import: 'slack',
360
- endpoints: {
361
- 'GET /conversations.list': {returns: ['id', 'name', 'is_channel']},
362
- 'GET /conversations.history': {returns: ['messages', 'has_more']},
363
- 'POST /chat.postMessage': {returns: ['channel', 'ts', 'message']},
364
- },
365
- }, null, 2));
366
-
367
- // Set real credentials as env vars — the runtime resolves env:VAR_NAME from process.env
368
- process.env['STRIPE_SECRET_KEY'] = STRIPE_SECRET_KEY;
369
- process.env['SLACK_BOT_TOKEN'] = SLACK_BOT_TOKEN;
370
-
371
- // Boot the runtime
372
- const {createLocalServer} = await import('@amodalai/runtime');
373
- runtimeServer = await createLocalServer({
374
- repoPath: repoDir,
375
- port: 0,
376
- host: '127.0.0.1',
377
- hotReload: false,
378
- });
379
-
380
- const httpServer = await runtimeServer.start();
381
- const addr = (httpServer as http.Server).address();
382
- runtimePort = typeof addr === 'object' && addr ? addr.port : 0;
383
- expect(runtimePort).toBeGreaterThan(0);
384
- }, 30000);
385
-
386
- // -------------------------------------------------------------------------
387
- // Phase 4: Ask the agent to create a Stripe customer through the runtime
388
- // -------------------------------------------------------------------------
389
-
390
- const testRunId = `e2e-${Date.now()}`;
391
-
392
- it('should create a Stripe customer when asked via chat', async () => {
393
- const events = await sendChat(runtimePort, [
394
- `Use the stripe connection to create a new customer with these EXACT details:`,
395
- `- email: ${testRunId}@test.amodal.dev`,
396
- `- name: E2E Runtime Test`,
397
- `Use POST /v1/customers with intent "confirmed_write". The data should be form-encoded as Stripe expects.`,
398
- `Do NOT ask for confirmation — just execute it directly with confirmed_write.`,
399
- ].join('\n'), 'e2e-plugin-runtime');
400
-
401
- // The agent used the request tool to call Stripe
402
- const toolCalls = events.filter((e) => e['type'] === 'tool_call_start');
403
- const requestCalls = toolCalls.filter((tc) => tc['tool_name'] === 'request');
404
- expect(requestCalls.length).toBeGreaterThan(0);
405
-
406
- // Check Stripe directly for the customer
407
- const listResp = await fetch(`https://api.stripe.com/v1/customers?email=${testRunId}@test.amodal.dev`, {
408
- headers: {'Authorization': `Bearer ${STRIPE_SECRET_KEY}`},
409
- });
410
-
411
- const listData = await listResp.json() as {data: Array<{id: string; email: string}>};
412
-
413
- if (listData.data.length > 0) {
414
- stripeCustomerId = listData.data[0].id;
415
- expect(stripeCustomerId).toMatch(/^cus_/);
416
- }
417
- }, 120000);
418
-
419
- // -------------------------------------------------------------------------
420
- // Phase 5: Ask the agent to post to Slack about what it did
421
- // -------------------------------------------------------------------------
422
-
423
- it('should post to Slack when asked via chat', async () => {
424
- const customerInfo = stripeCustomerId
425
- ? `You just created Stripe customer ${stripeCustomerId}.`
426
- : `You just attempted to create a Stripe customer.`;
427
-
428
- const events = await sendChat(runtimePort, [
429
- `${customerInfo} Now use the slack connection to post a message about this to channel ${SLACK_CHANNEL_ID}.`,
430
- `Use POST /chat.postMessage with intent "confirmed_write".`,
431
- `The message text should include "Amodal Runtime E2E" and the test run ID "${testRunId}".`,
432
- `Do NOT ask for confirmation — execute directly with confirmed_write.`,
433
- ].join('\n'), 'e2e-plugin-runtime');
434
-
435
- // The agent used the request tool to post to Slack
436
- const toolCalls = events.filter((e) => e['type'] === 'tool_call_start');
437
- const requestCalls = toolCalls.filter((tc) => tc['tool_name'] === 'request');
438
- expect(requestCalls.length).toBeGreaterThan(0);
439
- }, 120000);
440
-
441
- // -------------------------------------------------------------------------
442
- // Phase 6: Verify the Slack message was actually posted
443
- // -------------------------------------------------------------------------
444
-
445
- it('should verify the Slack message appears in channel history', async () => {
446
- // Give Slack a moment to propagate
447
- await new Promise((r) => setTimeout(r, 2000));
448
-
449
- const messages = await slackHistory(SLACK_CHANNEL_ID, 10);
450
- const found = messages.find((m) =>
451
- m.text.includes('Amodal Runtime E2E') || m.text.includes(testRunId),
452
- );
453
-
454
- // The message should exist if the agent successfully posted
455
- // If the agent used write preview instead of confirmed_write, the message may not be there
456
- if (found) {
457
- expect(found.text).toContain(testRunId);
458
- } else {
459
- // Fallback: verify the agent at least attempted the slack call (checked in previous test)
460
- process.stderr.write('[e2e] Slack message not found — agent may have used write preview instead of confirmed_write\n');
461
- }
462
- });
463
-
464
- // -------------------------------------------------------------------------
465
- // Phase 7: Clean up runtime and uninstall
466
- // -------------------------------------------------------------------------
467
-
468
- it('should stop the runtime server', async () => {
469
- if (runtimeServer) {
470
- await runtimeServer.stop();
471
- runtimeServer = null;
472
- }
473
- delete process.env['STRIPE_SECRET_KEY'];
474
- delete process.env['SLACK_BOT_TOKEN'];
475
- });
476
-
477
- it('should uninstall the slack connection', async () => {
478
- const code = await runUninstall({cwd: repoDir, type: 'connection', name: 'slack'});
479
- expect(code).toBe(0);
480
-
481
- const lockRaw = readFileSync(join(repoDir, 'amodal.lock'), 'utf-8');
482
- const lock = JSON.parse(lockRaw) as {packages: Record<string, unknown>};
483
- expect(lock.packages['connection/slack']).toBeUndefined();
484
- });
485
-
486
- it('should still have stripe after uninstalling slack', async () => {
487
- const lockRaw = readFileSync(join(repoDir, 'amodal.lock'), 'utf-8');
488
- const lock = JSON.parse(lockRaw) as {packages: Record<string, unknown>};
489
- expect(lock.packages['connection/stripe']).toBeDefined();
490
- });
491
- });