From a19193b9c93921d0cd00af63158ba02abdb2b6f3 Mon Sep 17 00:00:00 2001 From: Chandler Anderson Date: Sun, 23 Nov 2025 19:27:19 -0500 Subject: [PATCH] fix: keep @opencode-ai/plugin in devDependencies for build The package needs to be in BOTH devDependencies and peerDependencies: - devDependencies: Required for TypeScript compilation and testing in CI - peerDependencies: Required for users when they install the plugin Without it in devDependencies, the build fails with: "Cannot find module '@opencode-ai/plugin'" --- .github/workflows/ci.yml | 207 ++++++++++++++++++++++++++- eslint.config.mjs | 2 +- examples/custom-webhook.ts | 6 +- examples/home-assistant.ts | 6 +- examples/local-dev.ts | 8 +- examples/slack-workflow.ts | 8 +- jest.config.cjs | 4 + package.json | 5 + tests/integration.bun.test.ts | 262 ++++++++++++++++++++++++++++++++++ 9 files changed, 500 insertions(+), 8 deletions(-) create mode 100644 tests/integration.bun.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e58001d..af9239e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -392,11 +392,212 @@ jobs: echo " - Local path installation" echo "" + bun-integration: + name: Bun Integration Tests + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build plugin + run: bun run build + + - name: Download package artifact + uses: actions/download-artifact@v4 + with: + name: npm-package + + - name: Test 1 - Plugin loading with Bun + run: | + echo "=== Testing Bun plugin loading ===" + + # Create test directory + mkdir -p /tmp/bun-plugin-test + cd /tmp/bun-plugin-test + + # Initialize with Bun + bun init -y + + # Install from tarball + TARBALL=$(ls $GITHUB_WORKSPACE/*.tgz) + echo "Installing from tarball: $TARBALL" + bun add "$TARBALL" + + # Create test script + cat > test-import.ts << 'EOF' + import { createWebhookPlugin, OpencodeEventType } from 'opencode-webhooks'; + + console.log('Testing Bun import...'); + + const plugin = createWebhookPlugin({ + webhooks: [ + { + url: 'https://example.com/webhook', + events: [OpencodeEventType.SESSION_CREATED] + } + ] + }); + + if (typeof plugin !== 'function') { + console.error('❌ Plugin creation failed'); + process.exit(1); + } + + console.log('✅ Bun import successful'); + EOF + + # Run with Bun + bun run test-import.ts || (echo "❌ Bun import failed" && exit 1) + + echo "✅ Bun plugin loading successful" + + - name: Test 2 - Run Bun integration tests + run: | + echo "=== Running Bun integration tests ===" + cd $GITHUB_WORKSPACE + bun test tests/integration.bun.test.ts || (echo "❌ Bun tests failed" && exit 1) + echo "✅ Bun integration tests passed" + + - name: Test 3 - Bun plugin execution + run: | + echo "=== Testing Bun plugin execution ===" + + # Create test directory + mkdir -p /tmp/bun-exec-test + cd /tmp/bun-exec-test + + # Initialize + bun init -y + + # Install from tarball + TARBALL=$(ls $GITHUB_WORKSPACE/*.tgz) + bun add "$TARBALL" + + # Create execution test + cat > test-exec.ts << 'EOF' + import { WebhookPlugin, OpencodeEventType } from 'opencode-webhooks'; + + console.log('Creating plugin instance...'); + + const plugin = new WebhookPlugin({ + webhooks: [ + { + url: 'https://httpbin.org/post', + events: [OpencodeEventType.SESSION_CREATED], + transformPayload: (payload) => ({ + message: 'Bun test webhook', + sessionId: payload.sessionId + }), + timeoutMs: 5000, + retry: { maxAttempts: 1 } + } + ], + debug: true + }); + + console.log('Triggering test event...'); + + const results = await plugin.handleEvent(OpencodeEventType.SESSION_CREATED, { + sessionId: 'bun-test-session-123', + userId: 'bun-test-user' + }); + + console.log('Results:', JSON.stringify(results, null, 2)); + + if (results.length === 0) { + console.error('❌ No results returned'); + process.exit(1); + } + + if (!results[0].success) { + console.error('❌ Webhook failed:', results[0].error); + process.exit(1); + } + + if (results[0].statusCode !== 200) { + console.error('❌ Unexpected status code:', results[0].statusCode); + process.exit(1); + } + + console.log('✅ Bun plugin execution successful'); + EOF + + # Run with Bun + bun run test-exec.ts || (echo "❌ Bun execution test failed" && exit 1) + + echo "✅ Bun plugin execution successful" + + - name: Test 4 - Bun performance comparison + run: | + echo "=== Testing Bun performance ===" + + cd $GITHUB_WORKSPACE + + # Create performance test + cat > /tmp/bun-perf-test.ts << 'EOF' + import { WebhookPlugin, OpencodeEventType } from './src/index'; + + const plugin = new WebhookPlugin({ + webhooks: [ + { + url: 'https://httpbin.org/post', + events: [OpencodeEventType.FILE_EDITED], + timeoutMs: 5000, + retry: { maxAttempts: 1 } + } + ] + }); + + const startTime = performance.now(); + const promises = Array.from({ length: 5 }, (_, i) => + plugin.handleEvent(OpencodeEventType.FILE_EDITED, { + sessionId: 'perf-test', + changeId: i + }) + ); + + await Promise.all(promises); + const duration = performance.now() - startTime; + + console.log(`Bun: Processed 5 events in ${duration.toFixed(2)}ms`); + console.log(`Average: ${(duration / 5).toFixed(2)}ms per event`); + EOF + + bun run /tmp/bun-perf-test.ts || echo "Performance test informational only" + + echo "✅ Bun performance test complete" + + - name: Bun integration summary + if: always() + run: | + echo "" + echo "======================================" + echo " Bun Integration Test Summary" + echo "======================================" + echo "" + echo "✅ All Bun integration tests passed!" + echo "" + echo "Verified scenarios:" + echo " - Plugin loading with Bun runtime" + echo " - Bun test suite execution" + echo " - Plugin hook execution with real HTTP" + echo " - Bun performance characteristics" + echo "" + # Combined check that runs after all other jobs ci-success: name: CI Success runs-on: ubuntu-latest - needs: [lint, test, build, plugin-integration] + needs: [lint, test, build, plugin-integration, bun-integration] if: always() steps: - name: Check all jobs @@ -417,4 +618,8 @@ jobs: echo "Plugin integration tests failed" exit 1 fi + if [[ "${{ needs.bun-integration.result }}" != "success" ]]; then + echo "Bun integration tests failed" + exit 1 + fi echo "All CI checks passed!" diff --git a/eslint.config.mjs b/eslint.config.mjs index fecca2b..d1abb2e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -44,6 +44,6 @@ export default [ }, }, { - ignores: ['dist', 'node_modules', 'coverage', '*.js'], + ignores: ['dist', 'node_modules', 'coverage', '*.js', 'tests/**/*.bun.test.ts'], }, ]; diff --git a/examples/custom-webhook.ts b/examples/custom-webhook.ts index 8c1efab..47d1f61 100644 --- a/examples/custom-webhook.ts +++ b/examples/custom-webhook.ts @@ -10,6 +10,7 @@ * 4. Restart OpenCode */ +import type { Plugin } from '@opencode-ai/plugin'; import { createWebhookPlugin } from 'opencode-webhooks'; // ============================================================================ @@ -22,7 +23,8 @@ const WEBHOOK_URL = 'https://your-webhook-endpoint.com/api/events'; // Plugin Setup // ============================================================================ -export default createWebhookPlugin({ +// Export the plugin with explicit type annotation for OpenCode +const CustomWebhookPlugin: Plugin = createWebhookPlugin({ webhooks: [ { url: WEBHOOK_URL, @@ -77,3 +79,5 @@ export default createWebhookPlugin({ // Enable debug logging (set to false in production) debug: false, }); + +export default CustomWebhookPlugin; diff --git a/examples/home-assistant.ts b/examples/home-assistant.ts index b7d890e..d13ba85 100644 --- a/examples/home-assistant.ts +++ b/examples/home-assistant.ts @@ -24,6 +24,7 @@ * Full guide: https://www.home-assistant.io/docs/automation/trigger/#webhook-trigger */ +import type { Plugin } from '@opencode-ai/plugin'; import { createWebhookPlugin } from 'opencode-webhooks'; // ============================================================================ @@ -43,7 +44,8 @@ const WEBHOOK_URL = `${HOME_ASSISTANT_URL}/api/webhook/${WEBHOOK_ID}`; // Plugin Setup // ============================================================================ -export default createWebhookPlugin({ +// Export the plugin with explicit type annotation for OpenCode +const HomeAssistantPlugin: Plugin = createWebhookPlugin({ webhooks: [ { url: WEBHOOK_URL, @@ -127,3 +129,5 @@ export default createWebhookPlugin({ // Enable debug logging (set to false in production) debug: false, }); + +export default HomeAssistantPlugin; diff --git a/examples/local-dev.ts b/examples/local-dev.ts index db26b46..6e73bee 100644 --- a/examples/local-dev.ts +++ b/examples/local-dev.ts @@ -11,7 +11,8 @@ * 4. Restart OpenCode */ -import { createWebhookPlugin } from './opencode-webhooks/src/index.ts'; +import type { Plugin } from '@opencode-ai/plugin'; +import { createWebhookPlugin } from './opencode-webhooks/src/index.js'; // ============================================================================ // Configuration @@ -23,7 +24,8 @@ const WEBHOOK_URL = 'https://your-webhook-endpoint.com/api/events'; // Plugin Setup // ============================================================================ -export default createWebhookPlugin({ +// Export the plugin with explicit type annotation for OpenCode +const LocalDevPlugin: Plugin = createWebhookPlugin({ webhooks: [ { url: WEBHOOK_URL, @@ -49,3 +51,5 @@ export default createWebhookPlugin({ ], debug: true, }); + +export default LocalDevPlugin; diff --git a/examples/slack-workflow.ts b/examples/slack-workflow.ts index 8af61a7..9ca3469 100644 --- a/examples/slack-workflow.ts +++ b/examples/slack-workflow.ts @@ -18,6 +18,7 @@ * Full guide: https://slack.com/help/articles/360041352714 */ +import type { Plugin } from '@opencode-ai/plugin'; import { createWebhookPlugin } from 'opencode-webhooks'; // ============================================================================ @@ -30,7 +31,8 @@ const WEBHOOK_URL = 'https://hooks.slack.com/workflows/T00000000/A00000000/12345 // Plugin Setup // ============================================================================ -export default createWebhookPlugin({ +// Export the plugin with explicit type annotation for OpenCode +const SlackWorkflowPlugin: Plugin = createWebhookPlugin({ webhooks: [ { url: WEBHOOK_URL, @@ -78,13 +80,13 @@ export default createWebhookPlugin({ // Flatten payload to top level for Slack Workflow Builder return { + ...payload, eventType: payload.eventType, sessionId: payload.sessionId || 'N/A', timestamp: payload.timestamp, message: `${emoji} ${payload.eventType}`, eventInfo: `${description}${messagePreview}\n\nAvailable data: ${availableKeys.join(', ')}`, messageContent: messageContent, - ...payload, }; }, @@ -101,3 +103,5 @@ export default createWebhookPlugin({ // Enable debug logging (set to false in production) debug: false, }); + +export default SlackWorkflowPlugin; diff --git a/jest.config.cjs b/jest.config.cjs index da96a97..961fb70 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -3,6 +3,10 @@ module.exports = { testEnvironment: 'node', roots: ['/src', '/tests'], testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'], + testPathIgnorePatterns: [ + '/node_modules/', + '\\.bun\\.test\\.ts$' // Exclude Bun-specific tests from Jest + ], collectCoverageFrom: [ 'src/**/*.ts', '!src/**/*.d.ts', diff --git a/package.json b/package.json index dbee87f..b01d0aa 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", + "test:bun": "bun test tests/integration.bun.test.ts", "lint": "eslint 'src/**/*.ts' 'tests/**/*.ts'", "lint:fix": "eslint 'src/**/*.ts' 'tests/**/*.ts' --fix" }, @@ -61,9 +62,13 @@ "axios": "^1.6.0" }, "peerDependencies": { + "@opencode-ai/plugin": "^1.0.0", "opencode": "*" }, "peerDependenciesMeta": { + "@opencode-ai/plugin": { + "optional": true + }, "opencode": { "optional": true } diff --git a/tests/integration.bun.test.ts b/tests/integration.bun.test.ts new file mode 100644 index 0000000..f622a85 --- /dev/null +++ b/tests/integration.bun.test.ts @@ -0,0 +1,262 @@ +/** + * Bun Integration Tests + * These tests verify the plugin works correctly in a Bun runtime environment, + * emulating how ~/.config/opencode loads plugins with dependencies + * + * Run with: bun test tests/integration.bun.test.ts + * + * Note: These tests use real HTTP calls to httpbin.org to validate + * the plugin works correctly in Bun's runtime without complex mocking. + */ + +import { describe, test, expect } from 'bun:test'; + +describe('Bun Runtime Integration Tests', () => { + describe('Plugin loading and instantiation', () => { + test('should load plugin using ES module import', async () => { + const { createWebhookPlugin } = await import('../src/index'); + + expect(createWebhookPlugin).toBeDefined(); + expect(typeof createWebhookPlugin).toBe('function'); + }); + + test('should create plugin instance with dependencies', async () => { + const { WebhookPlugin, OpencodeEventType } = await import('../src/index'); + + const plugin = new WebhookPlugin({ + webhooks: [ + { + url: 'https://example.com/webhook', + events: [OpencodeEventType.SESSION_CREATED], + }, + ], + debug: false, + }); + + expect(plugin).toBeDefined(); + expect(plugin.handleEvent).toBeDefined(); + expect(typeof plugin.handleEvent).toBe('function'); + }); + + test('should access axios dependency in Bun runtime', async () => { + const axios = await import('axios'); + expect(axios.default).toBeDefined(); + }); + }); + + describe('Event handling in Bun runtime with real HTTP', () => { + test('should send webhook to httpbin.org', async () => { + const { WebhookPlugin, OpencodeEventType } = await import('../src/index'); + + const plugin = new WebhookPlugin({ + webhooks: [ + { + url: 'https://httpbin.org/post', + events: [OpencodeEventType.SESSION_CREATED], + timeoutMs: 10000, + retry: { maxAttempts: 1 }, + }, + ], + debug: true, + }); + + const payload = { + sessionId: 'bun-test-session-123', + userId: 'bun-test-user-456', + }; + + const results = await plugin.handleEvent( + OpencodeEventType.SESSION_CREATED, + payload + ); + + expect(results).toBeDefined(); + expect(results.length).toBe(1); + expect(results[0].success).toBe(true); + expect(results[0].statusCode).toBe(200); + }); + + test('should transform payload correctly', async () => { + const { WebhookPlugin, OpencodeEventType } = await import('../src/index'); + + const plugin = new WebhookPlugin({ + webhooks: [ + { + url: 'https://httpbin.org/post', + events: [OpencodeEventType.SESSION_ERROR], + transformPayload: (payload: any) => ({ + text: `Error: ${payload.error}`, + sessionId: payload.sessionId, + transformed: true, + }), + timeoutMs: 10000, + retry: { maxAttempts: 1 }, + }, + ], + }); + + const results = await plugin.handleEvent(OpencodeEventType.SESSION_ERROR, { + sessionId: 'session-123', + error: 'Test error message', + }); + + expect(results[0].success).toBe(true); + expect(results[0].statusCode).toBe(200); + }); + + test('should filter webhooks based on shouldSend', async () => { + const { WebhookPlugin, OpencodeEventType } = await import('../src/index'); + + const plugin = new WebhookPlugin({ + webhooks: [ + { + url: 'https://httpbin.org/post', + events: [OpencodeEventType.SESSION_ERROR], + shouldSend: (payload: any) => { + const error = payload.error || ''; + return error.includes('CRITICAL'); + }, + timeoutMs: 10000, + }, + { + url: 'https://httpbin.org/status/200', + events: [OpencodeEventType.SESSION_ERROR], + timeoutMs: 10000, + }, + ], + }); + + // Test with non-critical error - only second webhook should fire + const results = await plugin.handleEvent(OpencodeEventType.SESSION_ERROR, { + error: 'Minor warning', + }); + + // First webhook filtered out (attempts: 0), second one sent + expect(results.length).toBe(2); + expect(results[0].attempts).toBe(0); // Filtered out + expect(results[1].success).toBe(true); // Sent successfully + }); + }); + + describe('OpenCode plugin pattern compatibility', () => { + test('should export createWebhookPlugin function', async () => { + const { createWebhookPlugin } = await import('../src/index'); + + const { OpencodeEventType } = await import('../src/types'); + + const plugin = createWebhookPlugin({ + webhooks: [ + { + url: 'https://httpbin.org/post', + events: [OpencodeEventType.SESSION_CREATED], + }, + ], + }); + + expect(typeof plugin).toBe('function'); + }); + + test('should work as OpenCode plugin hook', async () => { + const { createWebhookPlugin, OpencodeEventType } = await import('../src/index'); + + const hook = createWebhookPlugin({ + webhooks: [ + { + url: 'https://httpbin.org/post', + events: [OpencodeEventType.SESSION_CREATED], + timeoutMs: 10000, + retry: { maxAttempts: 1 }, + }, + ], + debug: false, + }); + + // The hook should be callable + expect(typeof hook).toBe('function'); + + // Simulate OpenCode calling the hook with proper context + const mockContext: any = { + client: {}, + project: { path: '/test' }, + directory: '/test', + worktree: '/test', + }; + + const result = await hook(mockContext); + expect(result).toBeDefined(); + expect(result.event).toBeDefined(); + expect(typeof result.event).toBe('function'); + + // Test the event handler + await result.event({ + event: { + type: OpencodeEventType.SESSION_CREATED, + timestamp: new Date().toISOString(), + }, + }); + + // Event should complete without error + expect(true).toBe(true); + }); + }); + + describe('Dependency resolution in Bun', () => { + test('should resolve axios from node_modules', async () => { + const axios = await import('axios'); + expect(axios.default).toBeDefined(); + expect(typeof axios.default).toBe('function'); + }); + + test('should handle TypeScript types correctly', async () => { + const { WebhookPlugin, OpencodeEventType } = await import('../src/index'); + + // Verify TypeScript types are available + const config = { + webhooks: [ + { + url: 'https://example.com', + events: [OpencodeEventType.SESSION_CREATED], + }, + ], + }; + + const plugin = new WebhookPlugin(config); + expect(plugin).toBeDefined(); + }); + }); + + describe('Performance in Bun runtime', () => { + test('should handle rapid successive events efficiently', async () => { + const { WebhookPlugin, OpencodeEventType } = await import('../src/index'); + + const plugin = new WebhookPlugin({ + webhooks: [ + { + url: 'https://httpbin.org/status/200', + events: [OpencodeEventType.FILE_EDITED], + timeoutMs: 10000, + retry: { maxAttempts: 1 }, + }, + ], + }); + + const startTime = performance.now(); + const promises = Array.from({ length: 5 }, (_, i) => + plugin.handleEvent(OpencodeEventType.FILE_EDITED, { + sessionId: 'session-1', + changeId: i, + }) + ); + + const results = await Promise.all(promises); + const duration = performance.now() - startTime; + + expect(results.length).toBe(5); + expect(results.every((r: any) => r[0].success)).toBe(true); + + // Bun should handle this efficiently + console.log(`Bun: Processed 5 events in ${duration.toFixed(2)}ms`); + expect(duration).toBeLessThan(15000); // Should complete within 15 seconds + }); + }); +});