diff --git a/skills/ai-tools/scripts/embeddings.test.js b/skills/ai-tools/scripts/embeddings.test.js index cc7abe9..b5133f0 100644 --- a/skills/ai-tools/scripts/embeddings.test.js +++ b/skills/ai-tools/scripts/embeddings.test.js @@ -1,11 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -const scriptPath = path.join(process.cwd(), 'skills/ai-tools/scripts/embeddings.js'); - -describe('embeddings.js', () => { import { EventEmitter } from 'events'; describe('embeddings.js', () => { @@ -14,328 +8,67 @@ describe('embeddings.js', () => { beforeEach(() => { originalEnv = { ...process.env }; - process.env.OPENAI_API_KEY = 'test-api-key'; mockFetch = vi.fn(); global.fetch = mockFetch; }); afterEach(() => { process.env = originalEnv; - }); - - function runScript(args) { - return new Promise((resolve) => { - const proc = spawn('node', [scriptPath, ...args], { - env: process.env, vi.restoreAllMocks(); }); - const runScript = (args, env = {}) => { - return new Promise((resolve, reject) => { - const proc = spawn('node', ['skills/ai-tools/scripts/embeddings.js', ...args], { - env: { ...process.env, ...env }, - }); - - let stdout = ''; - let stderr = ''; - - proc.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - proc.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - proc.on('close', (exitCode) => { - resolve({ exitCode, stdout, stderr }); - }); - }); - } - - describe('parseArgs', () => { - it('displays usage when no text is provided', async () => { - const result = await runScript([]); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('Usage: node embeddings.js '); - }); - - it('displays available options in help text', async () => { - const result = await runScript([]); - - expect(result.stderr).toContain('--model'); - expect(result.stderr).toContain('--dimensions'); - expect(result.stderr).toContain('--output'); - }); - }); - - describe('API key validation', () => { - it('fails when no API key is provided', async () => { - delete process.env.OPENAI_API_KEY; - delete process.env.AI_GATEWAY_API_KEY; - - const result = await runScript(['test text']); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('OPENAI_API_KEY or AI_GATEWAY_API_KEY required'); - }); - - it('accepts AI_GATEWAY_API_KEY as alternative', async () => { - delete process.env.OPENAI_API_KEY; - process.env.AI_GATEWAY_API_KEY = 'gateway-key'; - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - data: [{ embedding: new Array(1536).fill(0.1) }], - usage: { total_tokens: 10 } - }) - }); - - const result = await runScript(['test text']); - - expect(global.fetch).toHaveBeenCalled(); - }); - }); - - describe('generateEmbeddings', () => { - beforeEach(() => { - global.fetch = vi.fn(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('makes API request with correct parameters', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - data: [{ embedding: new Array(1536).fill(0.1) }], - usage: { total_tokens: 10 } - }) - }); - - await runScript(['hello world']); - - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/embeddings'), - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/json', - 'Authorization': 'Bearer test-api-key' - }), - body: expect.stringContaining('"input":"hello world"') - }) - ); - }); - - it('uses custom model when specified', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - data: [{ embedding: new Array(1536).fill(0.1) }], - usage: { total_tokens: 10 } - }) - }); - - await runScript(['test', '--model', 'text-embedding-3-large']); - - const callBody = JSON.parse(global.fetch.mock.calls[0][1].body); - expect(callBody.model).toBe('text-embedding-3-large'); + const runScript = async (args, env = {}) => { + const { main } = require('./embeddings.js'); + const originalArgv = process.argv; + const originalEnv = process.env; + process.argv = ['node', 'embeddings.js', ...args]; + // Create a combined env but don't overwrite the whole process.env + // as it might contain important things for the test runner. + // Instead, we'll temporarily set individual variables. + const tempEnv = { ...env }; + for (const key in tempEnv) { + process.env[key] = tempEnv[key]; + } + + let stdout = ''; + let stderr = ''; + let exitCode = 0; + const spyLog = vi.spyOn(console, 'log').mockImplementation(m => { stdout += m + '\n'; }); + const spyError = vi.spyOn(console, 'error').mockImplementation(m => { stderr += m + '\n'; }); + const spyExit = vi.spyOn(process, 'exit').mockImplementation((code) => { + exitCode = code; + const err = new Error('process.exit'); + err.code = code; + throw err; }); - it('uses custom dimensions when specified', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - data: [{ embedding: new Array(512).fill(0.1) }], - usage: { total_tokens: 10 } - }) - }); - - await runScript(['test', '--dimensions', '512']); - - const callBody = JSON.parse(global.fetch.mock.calls[0][1].body); - expect(callBody.dimensions).toBe(512); - }); - - it('handles API errors gracefully', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 401, - text: () => Promise.resolve('Unauthorized') - }); - - const result = await runScript(['test text']); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('API error'); - expect(result.stderr).toContain('401'); - }); - - it('returns embeddings with correct structure', async () => { - const mockEmbedding = new Array(1536).fill(0.1); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - data: [{ embedding: mockEmbedding }], - usage: { total_tokens: 10 } - }) - }); - - const result = await runScript(['test text']); - - expect(result.exitCode).toBe(0); - const output = JSON.parse(result.stdout); - expect(output).toHaveProperty('model'); - expect(output).toHaveProperty('dimensions'); - expect(output).toHaveProperty('embedding'); - expect(output).toHaveProperty('usage'); - }); - - it('truncates embedding display in console output', async () => { - const mockEmbedding = new Array(1536).fill(0.1); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - data: [{ embedding: mockEmbedding }], - usage: { total_tokens: 10 } - }) - }); - - const result = await runScript(['test text']); - - const output = JSON.parse(result.stdout); - expect(output.embedding).toHaveLength(7); // 5 values + "..." + total count - expect(output.embedding[5]).toBe('...'); - expect(output.embedding[6]).toContain('(1536 total)'); - }); - }); - - describe('file output', () => { - const testOutputFile = '/tmp/embeddings-test-output.json'; - - afterEach(() => { - if (fs.existsSync(testOutputFile)) { - fs.unlinkSync(testOutputFile); + try { + await main(); + } catch (err) { + if (err.message !== 'process.exit') { + stderr += err.message; + exitCode = 1; } - }); - - it('saves embeddings to file when --output is specified', async () => { - const mockEmbedding = new Array(1536).fill(0.1); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - data: [{ embedding: mockEmbedding }], - usage: { total_tokens: 10 } - }) - }); - - const result = await runScript(['test text', '--output', testOutputFile]); - - expect(result.exitCode).toBe(0); - expect(fs.existsSync(testOutputFile)).toBe(true); - - const savedData = JSON.parse(fs.readFileSync(testOutputFile, 'utf-8')); - expect(savedData).toHaveProperty('embedding'); - expect(savedData.embedding).toHaveLength(1536); - }); - - it('outputs success message when saving to file', async () => { - const mockEmbedding = new Array(1536).fill(0.1); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - data: [{ embedding: mockEmbedding }], - usage: { total_tokens: 10 } - }) - }); - - const result = await runScript(['test text', '--output', testOutputFile]); - - const output = JSON.parse(result.stdout); - expect(output.success).toBe(true); - expect(output.saved).toBe(testOutputFile); - expect(output).toHaveProperty('dimensions'); - expect(output).toHaveProperty('usage'); - }); - }); - - describe('edge cases', () => { - it('handles empty string input', async () => { - const result = await runScript(['']); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('Usage'); - }); - - it('handles very long text input', async () => { - const longText = 'word '.repeat(10000); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - data: [{ embedding: new Array(1536).fill(0.1) }], - usage: { total_tokens: 10000 } - }) - }); - - const result = await runScript([longText]); - - expect(result.exitCode).toBe(0); - expect(global.fetch).toHaveBeenCalled(); - }); - - it('handles network timeout gracefully', async () => { - global.fetch = vi.fn().mockRejectedValue(new Error('Network timeout')); - - const result = await runScript(['test text']); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('error'); - }); - - it('handles malformed API response', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ invalid: 'response' }) - }); - - const result = await runScript(['test text']); - - expect(result.exitCode).toBe(1); - }); - }); - - describe('custom base URL', () => { - it('uses AI_GATEWAY_BASE_URL when provided', async () => { - process.env.AI_GATEWAY_BASE_URL = 'https://custom-gateway.com/v1'; - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - data: [{ embedding: new Array(1536).fill(0.1) }], - usage: { total_tokens: 10 } - }) - }); - - await runScript(['test text']); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://custom-gateway.com/v1/embeddings', - expect.any(Object) - ); - }); - proc.on('close', (code) => { - resolve({ code, stdout, stderr }); - }); - - proc.on('error', (err) => { - reject(err); - }); - }); + } finally { + process.argv = originalArgv; + // Restore env + for (const key in tempEnv) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + spyLog.mockRestore(); + spyError.mockRestore(); + spyExit.mockRestore(); + } + + return { + code: exitCode, + stdout, + stderr + }; }; it('shows usage when no text is provided', async () => { @@ -532,4 +265,241 @@ describe('embeddings.js', () => { expect(result.code).toBe(0); }); + + it('saves embeddings to file when --output is specified', async () => { + const fs = require('fs'); + const path = require('path'); + const os = require('os'); + const outputFile = path.join(os.tmpdir(), `test-embeddings-${Date.now()}.json`); + + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2, 0.3, 0.4, 0.5] }], + usage: { total_tokens: 10 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text', '--output', outputFile], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + + // Verify file was created + if (fs.existsSync(outputFile)) { + const savedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8')); + expect(savedData).toHaveProperty('embedding'); + expect(savedData).toHaveProperty('model'); + expect(savedData.embedding).toHaveLength(5); + fs.unlinkSync(outputFile); + } + + const output = JSON.parse(result.stdout); + expect(output).toHaveProperty('success', true); + expect(output).toHaveProperty('saved'); + }); + + it('handles zero dimensions parameter', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.1] }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text', '--dimensions', '0'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.dimensions).toBe(0); + }); + + it('handles rate limit errors', async () => { + const mockResponse = { + ok: false, + status: 429, + text: async () => 'Rate limit exceeded', + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('429'); + }); + + it('handles special characters in text', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test "quoted" text & special '], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles unicode characters in text', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2] }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['Hello 你好 مرحبا'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('verifies correct API endpoint is called', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2] }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/embeddings'), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer test-key', + }), + }) + ); + }); + + it('handles non-numeric dimensions parameter', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2] }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text', '--dimensions', 'not-a-number'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + // NaN is serialized as null in JSON + expect(output.dimensions).toBeNull(); + }); + + it('handles negative dimensions parameter', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [] }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text', '--dimensions', '-100'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.dimensions).toBe(-100); + }); + + it('handles very large dimensions value', async () => { + const largeEmbedding = Array(10000).fill(0.1); + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: largeEmbedding }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text', '--dimensions', '10000'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.embedding).toHaveLength(7); + expect(output.embedding[6]).toContain('10000 total'); + }); + + it('handles 503 service unavailable error', async () => { + const mockResponse = { + ok: false, + status: 503, + text: async () => 'Service temporarily unavailable', + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('503'); + }); + + it('handles malformed JSON response from API', async () => { + const mockResponse = { + ok: true, + json: async () => { + throw new Error('Invalid JSON'); + }, + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('error'); + }); }); \ No newline at end of file diff --git a/skills/ai-tools/scripts/extract.test.js b/skills/ai-tools/scripts/extract.test.js index 280d9c1..3cf1d50 100644 --- a/skills/ai-tools/scripts/extract.test.js +++ b/skills/ai-tools/scripts/extract.test.js @@ -1,15 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { spawn } from 'child_process'; -import path from 'path'; - -const scriptPath = path.join(process.cwd(), 'skills/ai-tools/scripts/extract.js'); describe('extract.js', () => { let originalEnv; beforeEach(() => { originalEnv = { ...process.env }; - process.env.ANTHROPIC_API_KEY = 'test-api-key'; }); afterEach(() => { @@ -17,82 +13,50 @@ describe('extract.js', () => { vi.restoreAllMocks(); }); - function runScript(args) { - return new Promise((resolve) => { - const proc = spawn('node', [scriptPath, ...args], { - env: process.env, - const runScript = (args, env = {}) => { - return new Promise((resolve, reject) => { - const proc = spawn('node', ['skills/ai-tools/scripts/extract.js', ...args], { - env: { ...process.env, ...env }, - }); - - let stdout = ''; - let stderr = ''; - - proc.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - proc.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - proc.on('close', (exitCode) => { - resolve({ exitCode, stdout, stderr }); - }); - }); - } - - describe('parseArgs', () => { - it('displays usage when no text is provided', async () => { - const result = await runScript([]); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('Usage: node extract.js'); - }); - - it('displays usage when no schema is provided', async () => { - const result = await runScript(['some text']); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('--schema'); - }); - - it('shows example in help text', async () => { - const result = await runScript([]); - - expect(result.stderr).toContain('Example:'); - expect(result.stderr).toContain('--schema'); - }); - }); - - describe('API key validation', () => { - it('fails when no API key is provided', async () => { - delete process.env.ANTHROPIC_API_KEY; - delete process.env.AI_GATEWAY_API_KEY; - - const result = await runScript(['test', '--schema', '{"name":"string"}']); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('ANTHROPIC_API_KEY or AI_GATEWAY_API_KEY required'); + const runScript = async (args, env = {}) => { + const { main } = require('./extract.js'); + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + process.argv = ['node', 'extract.js', ...args]; + + for (const key in env) { + process.env[key] = env[key]; + } + + let stdout = ''; + let stderr = ''; + let exitCode = 0; + const spyLog = vi.spyOn(console, 'log').mockImplementation(m => { stdout += m + '\n'; }); + const spyError = vi.spyOn(console, 'error').mockImplementation(m => { stderr += m + '\n'; }); + const spyExit = vi.spyOn(process, 'exit').mockImplementation((code) => { + exitCode = code; + const err = new Error('process.exit'); + err.code = code; + throw err; }); - }); - - describe('edge cases', () => { - it('handles empty text', async () => { - const result = await runScript(['', '--schema', '{}']); - expect(result.exitCode).toBe(1); - }); - proc.on('close', (code) => { - resolve({ code, stdout, stderr }); - }); + try { + await main(); + } catch (err) { + if (err.message !== 'process.exit') { + stderr += err.message; + exitCode = 1; + } + } finally { + process.argv = originalArgv; + for (const key in env) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + spyLog.mockRestore(); + spyError.mockRestore(); + spyExit.mockRestore(); + } - proc.on('error', (err) => { - reject(err); - }); - }); + return { code: exitCode, stdout, stderr }; }; it('shows usage when no text is provided', async () => { @@ -325,4 +289,277 @@ describe('extract.js', () => { expect(output.extracted.person.name).toBe('Alice'); expect(output.extracted.person.address.city).toBe('Boston'); }); + + it('handles empty schema', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{}' }], + usage: { input_tokens: 10, output_tokens: 5 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', '{}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted).toEqual({}); + }); + + it('handles arrays in schema', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [ + { + text: JSON.stringify({ + items: ['item1', 'item2', 'item3'], + }), + }, + ], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['list: item1, item2, item3', '--schema', '{"items":["string"]}'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.items).toHaveLength(3); + }); + + it('handles network timeout', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('Network timeout')); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', '{"data":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('error'); + }); + + it('handles malformed JSON in response without code blocks', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{broken json' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', '{"data":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted).toHaveProperty('parseError', true); + }); + + it('handles very long text input', async () => { + const longText = 'a'.repeat(5000); + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"summary":"long text"}' }], + usage: { input_tokens: 1200, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([longText, '--schema', '{"summary":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles special characters in extracted data', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"name":"O\'Brien","symbol":"$"}' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', '{"name":"string","symbol":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.name).toBe("O'Brien"); + expect(output.extracted.symbol).toBe('$'); + }); + + it('handles multiple word text as positional argument', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"result":"success"}' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['this', 'is', 'multiple', 'words', '--schema', '{"result":"string"}'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + }); + + it('handles rate limiting with 429 error', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 429, + text: async () => 'Too many requests', + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', '{"data":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('429'); + }); + + it('includes usage tokens in output', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"data":"value"}' }], + usage: { input_tokens: 100, output_tokens: 50 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', '{"data":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.usage.input_tokens).toBe(100); + expect(output.usage.output_tokens).toBe(50); + }); + + it('handles schema with special regex characters', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"pattern":".*"}' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['text with pattern', '--schema', '{"pattern":".*+?[]{}()"}'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.pattern).toBe('.*'); + }); + + it('handles boolean values in extracted data', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"active":true,"deleted":false}' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', '{"active":"boolean","deleted":"boolean"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.active).toBe(true); + expect(output.extracted.deleted).toBe(false); + }); + + it('handles null values in extracted data', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"value":null}' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', '{"value":"any"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.value).toBeNull(); + }); + + it('handles deeply nested object schemas', async () => { + const deepSchema = { + level1: { + level2: { + level3: { + level4: { + value: 'string', + }, + }, + }, + }, + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [ + { + text: JSON.stringify({ + level1: { + level2: { + level3: { + level4: { + value: 'deep value', + }, + }, + }, + }, + }), + }, + ], + usage: { input_tokens: 20, output_tokens: 30 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', JSON.stringify(deepSchema)], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.level1.level2.level3.level4.value).toBe('deep value'); + }); }); \ No newline at end of file diff --git a/skills/ai-tools/scripts/sentiment.test.js b/skills/ai-tools/scripts/sentiment.test.js index c3d6841..eb4d6d3 100644 --- a/skills/ai-tools/scripts/sentiment.test.js +++ b/skills/ai-tools/scripts/sentiment.test.js @@ -1,8 +1,3 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { spawn } from 'child_process'; -import path from 'path'; - -const scriptPath = path.join(process.cwd(), 'skills/ai-tools/scripts/sentiment.js'); import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { spawn } from 'child_process'; @@ -11,13 +6,6 @@ describe('sentiment.js', () => { beforeEach(() => { originalEnv = { ...process.env }; - process.env.ANTHROPIC_API_KEY = 'test-api-key'; - }); - - function runScript(args) { - return new Promise((resolve) => { - const proc = spawn('node', [scriptPath, ...args], { - env: process.env, }); afterEach(() => { @@ -25,64 +13,50 @@ describe('sentiment.js', () => { vi.restoreAllMocks(); }); - const runScript = (args, env = {}) => { - return new Promise((resolve, reject) => { - const proc = spawn('node', ['skills/ai-tools/scripts/sentiment.js', ...args], { - env: { ...process.env, ...env }, - }); - - let stdout = ''; - let stderr = ''; - - proc.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - proc.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - proc.on('close', (exitCode) => { - resolve({ exitCode, stdout, stderr }); - }); + const runScript = async (args, env = {}) => { + const { main } = require('./sentiment.js'); + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + process.argv = ['node', 'sentiment.js', ...args]; + + for (const key in env) { + process.env[key] = env[key]; + } + + let stdout = ''; + let stderr = ''; + let exitCode = 0; + const spyLog = vi.spyOn(console, 'log').mockImplementation(m => { stdout += m + '\n'; }); + const spyError = vi.spyOn(console, 'error').mockImplementation(m => { stderr += m + '\n'; }); + const spyExit = vi.spyOn(process, 'exit').mockImplementation((code) => { + exitCode = code; + const err = new Error('process.exit'); + err.code = code; + throw err; }); - } - - describe('parseArgs', () => { - it('displays usage when no text is provided', async () => { - const result = await runScript([]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('Usage: node sentiment.js'); - }); - }); - - describe('API key validation', () => { - it('fails when no API key is provided', async () => { - delete process.env.ANTHROPIC_API_KEY; - delete process.env.AI_GATEWAY_API_KEY; - - const result = await runScript(['test text']); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('ANTHROPIC_API_KEY or AI_GATEWAY_API_KEY required'); - }); - }); - - describe('edge cases', () => { - it('handles single word input', async () => { - const result = await runScript(['test']); - // May fail without real API but should parse args correctly - expect(result.exitCode === 0 || result.exitCode === 1).toBe(true); - }); - proc.on('close', (code) => { - resolve({ code, stdout, stderr }); - }); - - proc.on('error', (err) => { - reject(err); - }); - }); + try { + await main(); + } catch (err) { + if (err.message !== 'process.exit') { + stderr += err.message; + exitCode = 1; + } + } finally { + process.argv = originalArgv; + for (const key in env) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + spyLog.mockRestore(); + spyError.mockRestore(); + spyExit.mockRestore(); + } + + return { code: exitCode, stdout, stderr }; }; it('shows usage when no text is provided', async () => { @@ -211,7 +185,7 @@ describe('sentiment.js', () => { global.fetch = mockFetch; const result = await runScript( - ['Good day', '--model', 'claude-3-sonnet-20240229'], + ['--model', 'claude-3-sonnet-20240229', 'Good day'], { ANTHROPIC_API_KEY: 'test-key' } ); @@ -371,4 +345,391 @@ describe('sentiment.js', () => { expect(result.code).toBe(0); }); + + it('handles very short text', async () => { + const mockAnalysis = { + sentiment: 'neutral', + score: 0.0, + confidence: 0.6, + emotions: [], + tone: 'brief', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 5, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['ok'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles text with multiple emotions', async () => { + const mockAnalysis = { + sentiment: 'mixed', + score: 0.1, + confidence: 0.8, + emotions: [ + { emotion: 'joy', intensity: 0.5 }, + { emotion: 'sadness', intensity: 0.3 }, + { emotion: 'anger', intensity: 0.2 }, + ], + tone: 'complex', + keywords: ['happy', 'sad', 'frustrated'], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 20, output_tokens: 30 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['Complex emotional text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.emotions).toHaveLength(3); + }); + + it('handles sarcastic text', async () => { + const mockAnalysis = { + sentiment: 'mixed', + score: -0.3, + confidence: 0.7, + emotions: [{ emotion: 'sarcasm', intensity: 0.8 }], + tone: 'sarcastic', + keywords: ['great', 'wonderful'], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 10, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['Oh great, another bug!'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.tone).toBe('sarcastic'); + }); + + it('handles network error', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('Network failed')); + global.fetch = mockFetch; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('error'); + }); + + it('handles 500 server error', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: async () => 'Internal server error', + }); + global.fetch = mockFetch; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('500'); + }); + + it('includes model in output', async () => { + const mockAnalysis = { + sentiment: 'positive', + score: 0.7, + confidence: 0.9, + emotions: [], + tone: 'upbeat', + keywords: ['great'], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 10, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.model).toBe('claude-3-5-haiku-20241022'); + }); + + it('handles emoji in text', async () => { + const mockAnalysis = { + sentiment: 'positive', + score: 0.9, + confidence: 0.95, + emotions: [{ emotion: 'joy', intensity: 0.9 }], + tone: 'enthusiastic', + keywords: ['love'], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 10, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['I love this! 😊🎉'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles extremely negative sentiment', async () => { + const mockAnalysis = { + sentiment: 'negative', + score: -0.95, + confidence: 0.98, + emotions: [{ emotion: 'disgust', intensity: 0.9 }], + tone: 'hostile', + keywords: ['terrible', 'awful', 'worst'], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 15, output_tokens: 25 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['This is the worst, most terrible thing ever'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.score).toBeLessThan(-0.9); + }); + + it('uses custom base URL when provided', async () => { + const mockAnalysis = { + sentiment: 'neutral', + score: 0, + confidence: 0.7, + emotions: [], + tone: 'neutral', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 10, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'test-key', + AI_GATEWAY_BASE_URL: 'https://custom.api.com', + }); + + expect(result.code).toBe(0); + }); + + it('handles text exactly 100 characters long', async () => { + const exactText = 'a'.repeat(100); + const mockAnalysis = { + sentiment: 'neutral', + score: 0, + confidence: 0.5, + emotions: [], + tone: 'monotonous', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 25, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([exactText], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.text).toBe(exactText); + expect(output.text).not.toContain('...'); + }); + + it('handles text with only whitespace', async () => { + const mockAnalysis = { + sentiment: 'neutral', + score: 0, + confidence: 0.3, + emotions: [], + tone: 'empty', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 5, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([' \t\n '], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles response with zero confidence', async () => { + const mockAnalysis = { + sentiment: 'unclear', + score: 0, + confidence: 0, + emotions: [], + tone: 'ambiguous', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 10, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['ambiguous text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.confidence).toBe(0); + }); + + it('handles response with maximum values', async () => { + const mockAnalysis = { + sentiment: 'positive', + score: 1.0, + confidence: 1.0, + emotions: [ + { emotion: 'ecstasy', intensity: 1.0 }, + ], + tone: 'euphoric', + keywords: ['perfect', 'amazing', 'incredible'], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 10, output_tokens: 30 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['Perfect! Amazing! Incredible!'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.score).toBe(1.0); + expect(output.analysis.confidence).toBe(1.0); + }); + + it('handles malformed JSON in API response', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{invalid json' }], + usage: { input_tokens: 10, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis).toHaveProperty('parseError', true); + expect(output.analysis.raw).toContain('invalid json'); + }); + + it('handles empty keywords array', async () => { + const mockAnalysis = { + sentiment: 'neutral', + score: 0, + confidence: 0.7, + emotions: [], + tone: 'plain', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 10, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['The data shows results'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.keywords).toEqual([]); + }); }); \ No newline at end of file diff --git a/skills/ai-tools/scripts/summarize.test.js b/skills/ai-tools/scripts/summarize.test.js index 7635e5a..cd4047b 100644 --- a/skills/ai-tools/scripts/summarize.test.js +++ b/skills/ai-tools/scripts/summarize.test.js @@ -1,16 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -const scriptPath = path.join(process.cwd(), 'skills/ai-tools/scripts/summarize.js'); - -describe('summarize.js', () => { - let originalEnv; - - beforeEach(() => { - originalEnv = { ...process.env }; - process.env.ANTHROPIC_API_KEY = 'test-api-key'; import { writeFileSync, unlinkSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -25,19 +14,6 @@ describe('summarize.js', () => { afterEach(() => { process.env = originalEnv; - vi.restoreAllMocks(); - - // Clean up test files - const testFile = '/tmp/test-summarize-input.txt'; - if (fs.existsSync(testFile)) { - fs.unlinkSync(testFile); - } - }); - - function runScript(args) { - return new Promise((resolve) => { - const proc = spawn('node', [scriptPath, ...args], { - env: process.env, tempFiles.forEach((file) => { if (existsSync(file)) { try { @@ -51,291 +27,50 @@ describe('summarize.js', () => { vi.restoreAllMocks(); }); - const runScript = (args, env = {}) => { - return new Promise((resolve, reject) => { - const proc = spawn('node', ['skills/ai-tools/scripts/summarize.js', ...args], { - env: { ...process.env, ...env }, - }); - - let stdout = ''; - let stderr = ''; - - proc.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - proc.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - proc.on('close', (exitCode) => { - resolve({ exitCode, stdout, stderr }); - }); - }); - } - - describe('parseArgs', () => { - it('displays usage when no text is provided', async () => { - const result = await runScript([]); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('Usage: node summarize.js'); - }); - - it('shows available options', async () => { - const result = await runScript([]); - - expect(result.stderr).toContain('--length'); - expect(result.stderr).toContain('--style'); - expect(result.stderr).toContain('--file'); - }); - - it('parses length option', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - content: [{ text: 'Short summary.' }], - usage: { input_tokens: 10, output_tokens: 5 } - }) - }); - - await runScript(['test text', '--length', '50']); - - const callBody = JSON.parse(global.fetch.mock.calls[0][1].body); - expect(callBody.messages[0].content).toContain('50 words'); - }); - - it('parses style option', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - content: [{ text: '• Point 1\n• Point 2' }], - usage: { input_tokens: 10, output_tokens: 5 } - }) - }); - - await runScript(['test', '--style', 'bullets']); - - const callBody = JSON.parse(global.fetch.mock.calls[0][1].body); - expect(callBody.messages[0].content).toContain('bulleted list'); - }); - }); - - describe('API key validation', () => { - it('fails when no API key is provided', async () => { - delete process.env.ANTHROPIC_API_KEY; - delete process.env.AI_GATEWAY_API_KEY; - - const result = await runScript(['test']); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('ANTHROPIC_API_KEY or AI_GATEWAY_API_KEY required'); - }); - }); - - describe('summarize', () => { - beforeEach(() => { - global.fetch = vi.fn(); - }); - - it('makes API request with correct parameters', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - content: [{ text: 'This is a summary.' }], - usage: { input_tokens: 50, output_tokens: 10 } - }) - }); - - await runScript(['This is a long text that needs to be summarized.']); - - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/v1/messages'), - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'x-api-key': 'test-api-key' - }) - }) - ); - }); - - it('includes system prompt', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - content: [{ text: 'Summary.' }], - usage: { input_tokens: 10, output_tokens: 5 } - }) - }); + const runScript = async (args, env = {}) => { + const { main } = require('./summarize.js'); + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + process.argv = ['node', 'summarize.js', ...args]; - await runScript(['test']); - - const callBody = JSON.parse(global.fetch.mock.calls[0][1].body); - expect(callBody.system).toContain('summarizer'); - }); - - it('uses brief style by default', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - content: [{ text: 'Brief summary.' }], - usage: { input_tokens: 10, output_tokens: 5 } - }) - }); - - const result = await runScript(['test text']); - - expect(result.exitCode).toBe(0); - const output = JSON.parse(result.stdout); - expect(output.style).toBe('brief'); - }); - - it('applies detailed style correctly', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - content: [{ text: 'Detailed summary with context.' }], - usage: { input_tokens: 10, output_tokens: 10 } - }) - }); - - await runScript(['test', '--style', 'detailed']); - - const callBody = JSON.parse(global.fetch.mock.calls[0][1].body); - expect(callBody.messages[0].content).toContain('comprehensive'); - }); - - it('returns correct output structure', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - content: [{ text: 'Summary text here.' }], - usage: { input_tokens: 50, output_tokens: 10 } - }) - }); - - const result = await runScript(['Long text to summarize']); - - expect(result.exitCode).toBe(0); - const output = JSON.parse(result.stdout); - expect(output).toHaveProperty('summary'); - expect(output).toHaveProperty('style'); - expect(output).toHaveProperty('targetWords'); - expect(output).toHaveProperty('actualWords'); - expect(output).toHaveProperty('originalLength'); - expect(output).toHaveProperty('usage'); - }); - - it('calculates word counts correctly', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - content: [{ text: 'One two three four five.' }], - usage: { input_tokens: 10, output_tokens: 5 } - }) - }); - - const result = await runScript(['test text']); - - const output = JSON.parse(result.stdout); - expect(output.actualWords).toBe(5); - }); - - it('handles API errors gracefully', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - text: () => Promise.resolve('Internal error') - }); - - const result = await runScript(['test']); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('API error'); - }); - }); - - describe('file input', () => { - it('reads text from file when --file is specified', async () => { - const testFile = '/tmp/test-summarize-input.txt'; - fs.writeFileSync(testFile, 'File content to summarize'); - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - content: [{ text: 'File summary.' }], - usage: { input_tokens: 10, output_tokens: 5 } - }) - }); - - const result = await runScript([testFile, '--file']); - - expect(result.exitCode).toBe(0); - const callBody = JSON.parse(global.fetch.mock.calls[0][1].body); - expect(callBody.messages[0].content).toContain('File content'); - }); - - it('handles missing file gracefully', async () => { - const result = await runScript(['/nonexistent/file.txt', '--file']); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('File not found'); - }); - }); - - describe('edge cases', () => { - it('handles very short text', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - content: [{ text: 'Short.' }], - usage: { input_tokens: 5, output_tokens: 2 } - }) - }); - - const result = await runScript(['Hi']); - - expect(result.exitCode).toBe(0); - }); - - it('handles very long text', async () => { - const longText = 'word '.repeat(5000); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - content: [{ text: 'Summary of long text.' }], - usage: { input_tokens: 5000, output_tokens: 20 } - }) - }); - - const result = await runScript([longText]); - - expect(result.exitCode).toBe(0); - }); - - it('handles custom model', async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ - content: [{ text: 'Summary.' }], - usage: { input_tokens: 10, output_tokens: 5 } - }) - }); - - await runScript(['test', '--model', 'claude-3-opus-20240229']); + for (const key in env) { + process.env[key] = env[key]; + } - const callBody = JSON.parse(global.fetch.mock.calls[0][1].body); - expect(callBody.model).toBe('claude-3-opus-20240229'); - }); - proc.on('close', (code) => { - resolve({ code, stdout, stderr }); - }); + let stdout = ''; + let stderr = ''; + let exitCode = 0; + const spyLog = vi.spyOn(console, 'log').mockImplementation(m => { stdout += m + '\n'; }); + const spyError = vi.spyOn(console, 'error').mockImplementation(m => { stderr += m + '\n'; }); + const spyExit = vi.spyOn(process, 'exit').mockImplementation((code) => { + exitCode = code; + const err = new Error('process.exit'); + err.code = code; + throw err; + }); + + try { + await main(); + } catch (err) { + if (err.message !== 'process.exit') { + stderr += err.message; + exitCode = 1; + } + } finally { + process.argv = originalArgv; + for (const key in env) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + spyLog.mockRestore(); + spyError.mockRestore(); + spyExit.mockRestore(); + } - proc.on('error', (err) => { - reject(err); - }); - }); + return { code: exitCode, stdout, stderr }; }; it('shows usage when no text is provided', async () => { @@ -627,4 +362,331 @@ describe('summarize.js', () => { const output = JSON.parse(result.stdout); expect(output.style).toBe('unknown'); }); + + it('handles very short text that needs no summarization', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Short text.' }], + usage: { input_tokens: 5, output_tokens: 5 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['Short text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles extremely long files', async () => { + const tempFile = join(tmpdir(), `test-long-${Date.now()}.txt`); + tempFiles.push(tempFile); + const longContent = 'This is a very long document. '.repeat(1000); + writeFileSync(tempFile, longContent); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Summary of very long document.' }], + usage: { input_tokens: 5000, output_tokens: 50 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([tempFile, '--file'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.originalLength).toBe(longContent.length); + }); + + it('handles network timeout error', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('Request timeout')); + global.fetch = mockFetch; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('error'); + }); + + it('handles 401 unauthorized error', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + text: async () => 'Invalid API key', + }); + global.fetch = mockFetch; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'bad-key', + }); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('401'); + }); + + it('handles very large target word length', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'A'.repeat(2000) }], + usage: { input_tokens: 100, output_tokens: 500 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--length', '2000'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.targetWords).toBe(2000); + }); + + it('handles file with special characters', async () => { + const tempFile = join(tmpdir(), `test-special-${Date.now()}.txt`); + tempFiles.push(tempFile); + writeFileSync(tempFile, 'Text with "quotes" and & characters'); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Summary of special text.' }], + usage: { input_tokens: 20, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([tempFile, '--file'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('combines multiple command line options', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '- Point 1\n- Point 2\n- Point 3' }], + usage: { input_tokens: 100, output_tokens: 30 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['text', '--length', '50', '--style', 'bullets', '--model', 'claude-3-opus-20240229'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.targetWords).toBe(50); + expect(output.style).toBe('bullets'); + expect(output.model).toBe('claude-3-opus-20240229'); + }); + + it('handles file read permission error', async () => { + const result = await runScript(['/root/nonexistent.txt', '--file'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('File not found'); + }); + + it('verifies summary contains all metadata', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Complete summary text here.' }], + usage: { input_tokens: 100, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['input text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output).toHaveProperty('summary'); + expect(output).toHaveProperty('style'); + expect(output).toHaveProperty('targetWords'); + expect(output).toHaveProperty('actualWords'); + expect(output).toHaveProperty('originalLength'); + expect(output).toHaveProperty('model'); + expect(output).toHaveProperty('usage'); + }); + + it('handles file with unicode content', async () => { + const tempFile = join(tmpdir(), `test-unicode-${Date.now()}.txt`); + tempFiles.push(tempFile); + writeFileSync(tempFile, 'Hello 世界 مرحبا Привет'); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Multilingual greeting summary.' }], + usage: { input_tokens: 20, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([tempFile, '--file'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles zero target word length', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Summary.' }], + usage: { input_tokens: 100, output_tokens: 5 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--length', '0'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.targetWords).toBe(0); + }); + + it('handles negative length parameter', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Brief summary.' }], + usage: { input_tokens: 100, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--length', '-50'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.targetWords).toBe(-50); + }); + + it('handles empty file', async () => { + const tempFile = join(tmpdir(), `test-empty-${Date.now()}.txt`); + tempFiles.push(tempFile); + writeFileSync(tempFile, ''); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'No content to summarize.' }], + usage: { input_tokens: 5, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([tempFile, '--file'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.originalLength).toBe(0); + }); + + it('handles summary longer than original text', async () => { + const shortText = 'Hi'; + const longSummary = 'This is a very detailed and comprehensive summary that is much longer than the original text.'; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: longSummary }], + usage: { input_tokens: 5, output_tokens: 50 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([shortText], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.actualWords).toBeGreaterThan(output.originalLength); + }); + + it('handles summary with multiple newlines', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Line 1\n\nLine 2\n\n\nLine 3' }], + usage: { input_tokens: 100, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.summary).toContain('\n'); + }); + + it('handles API response with missing content', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [], + usage: { input_tokens: 10, output_tokens: 0 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('error'); + }); + + it('handles bullets style with numbered list', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '1. First point\n2. Second point\n3. Third point' }], + usage: { input_tokens: 100, output_tokens: 30 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--style', 'bullets'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.summary).toContain('1.'); + }); }); \ No newline at end of file diff --git a/skills/ai-tools/scripts/vision.test.js b/skills/ai-tools/scripts/vision.test.js index af50a9d..5cce9af 100644 --- a/skills/ai-tools/scripts/vision.test.js +++ b/skills/ai-tools/scripts/vision.test.js @@ -1,20 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -const scriptPath = path.join(process.cwd(), 'skills/ai-tools/scripts/vision.js'); - -describe('vision.js', () => { - let originalEnv; - const testImagePath = '/tmp/test-vision-image.png'; - - beforeEach(() => { - originalEnv = { ...process.env }; - process.env.ANTHROPIC_API_KEY = 'test-api-key'; - fs.writeFileSync(testImagePath, Buffer.from('fake-png-data')); import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { spawn } from 'child_process'; import { writeFileSync, unlinkSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -22,22 +6,17 @@ import { tmpdir } from 'os'; describe('vision.js', () => { let originalEnv; let tempFiles = []; + let mockFetch; beforeEach(() => { + vi.resetModules(); originalEnv = { ...process.env }; + mockFetch = vi.fn(); + global.fetch = mockFetch; }); afterEach(() => { process.env = originalEnv; - if (fs.existsSync(testImagePath)) { - fs.unlinkSync(testImagePath); - } - }); - - function runScript(args) { - return new Promise((resolve) => { - const proc = spawn('node', [scriptPath, ...args], { - env: process.env, tempFiles.forEach((file) => { if (existsSync(file)) { try { @@ -51,65 +30,50 @@ describe('vision.js', () => { vi.restoreAllMocks(); }); - const runScript = (args, env = {}) => { - return new Promise((resolve, reject) => { - const proc = spawn('node', ['skills/ai-tools/scripts/vision.js', ...args], { - env: { ...process.env, ...env }, - }); - - let stdout = ''; - let stderr = ''; - - proc.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - proc.stderr.on('data', (data) => { - stderr += data.toString(); - }); + const runScript = async (args, env = {}) => { + const { main } = require('./vision.js'); + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + process.argv = ['node', 'vision.js', ...args]; - proc.on('close', (exitCode) => { - resolve({ exitCode, stdout, stderr }); - }); - }); - } - - describe('parseArgs', () => { - it('displays usage when no image is provided', async () => { - const result = await runScript([]); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('Usage: node vision.js'); - }); - }); - - describe('API key validation', () => { - it('fails when no API key is provided', async () => { - delete process.env.ANTHROPIC_API_KEY; - delete process.env.AI_GATEWAY_API_KEY; - - const result = await runScript([testImagePath]); + for (const key in env) { + process.env[key] = env[key]; + } - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('ANTHROPIC_API_KEY or AI_GATEWAY_API_KEY required'); + let stdout = ''; + let stderr = ''; + let exitCode = 0; + const spyLog = vi.spyOn(console, 'log').mockImplementation(m => { stdout += m + '\n'; }); + const spyError = vi.spyOn(console, 'error').mockImplementation(m => { stderr += m + '\n'; }); + const spyExit = vi.spyOn(process, 'exit').mockImplementation((code) => { + exitCode = code; + const err = new Error('process.exit'); + err.code = code; + throw err; }); - }); - describe('image handling', () => { - it('handles missing file gracefully', async () => { - const result = await runScript(['/nonexistent/image.png']); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('Image not found'); - }); - proc.on('close', (code) => { - resolve({ code, stdout, stderr }); - }); + try { + await main(); + } catch (err) { + if (err.message !== 'process.exit') { + stderr += err.message; + exitCode = 1; + } + } finally { + process.argv = originalArgv; + for (const key in env) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + spyLog.mockRestore(); + spyError.mockRestore(); + spyExit.mockRestore(); + } - proc.on('error', (err) => { - reject(err); - }); - }); + return { code: exitCode, stdout, stderr }; }; it('shows usage when no image is provided', async () => { @@ -119,7 +83,11 @@ describe('vision.js', () => { }); it('requires API key', async () => { - const result = await runScript(['image.png'], { + const tempFile = join(tmpdir(), 'dummy-image.png'); + writeFileSync(tempFile, 'dummy data'); + tempFiles.push(tempFile); + + const result = await runScript([tempFile], { ANTHROPIC_API_KEY: '', AI_GATEWAY_API_KEY: '', }); @@ -130,14 +98,22 @@ describe('vision.js', () => { }); it('analyzes image from URL', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - content: [{ text: 'This image shows a beautiful landscape.' }], - usage: { input_tokens: 1500, output_tokens: 50 }, - }), + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'This image shows a beautiful landscape.' }], + usage: { input_tokens: 1500, output_tokens: 50 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; }); - global.fetch = mockFetch; const result = await runScript( ['https://example.com/image.jpg', 'What is in this image?'], @@ -148,19 +124,26 @@ describe('vision.js', () => { const output = JSON.parse(result.stdout); expect(output).toHaveProperty('model'); expect(output).toHaveProperty('analysis'); - expect(output).toHaveProperty('usage'); expect(output.analysis).toContain('beautiful landscape'); }); it('uses default prompt when none is provided', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - content: [{ text: 'Detailed description of the image.' }], - usage: { input_tokens: 1500, output_tokens: 50 }, - }), + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Detailed description.' }], + usage: { input_tokens: 1500, output_tokens: 50 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; }); - global.fetch = mockFetch; const result = await runScript(['https://example.com/image.jpg'], { ANTHROPIC_API_KEY: 'test-key', @@ -172,23 +155,15 @@ describe('vision.js', () => { it('analyzes image from local file', async () => { const tempFile = join(tmpdir(), `test-image-${Date.now()}.png`); tempFiles.push(tempFile); + writeFileSync(tempFile, Buffer.from('fake-png-data')); - // Create a minimal PNG file (1x1 transparent pixel) - const pngData = Buffer.from( - '89504e470d0a1a0a0000000d494844520000000100000001080600000' + - '01f15c4890000000a49444154789c63000100000500010d0a2db40000000049454e44ae426082', - 'hex' - ); - writeFileSync(tempFile, pngData); - - const mockFetch = vi.fn().mockResolvedValue({ + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ content: [{ text: 'This is a small image.' }], usage: { input_tokens: 1500, output_tokens: 20 }, }), }); - global.fetch = mockFetch; const result = await runScript([tempFile, 'Describe this'], { ANTHROPIC_API_KEY: 'test-key', @@ -210,17 +185,25 @@ describe('vision.js', () => { }); it('accepts custom model parameter', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - content: [{ text: 'Image analysis.' }], - usage: { input_tokens: 1500, output_tokens: 20 }, - }), + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Image analysis.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; }); - global.fetch = mockFetch; const result = await runScript( - ['https://example.com/image.jpg', '--model', 'claude-3-opus-20240229'], + ['--model', 'claude-3-opus-20240229', 'https://example.com/image.png'], { ANTHROPIC_API_KEY: 'test-key' } ); @@ -230,14 +213,22 @@ describe('vision.js', () => { }); it('accepts detail parameter', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - content: [{ text: 'Detailed analysis.' }], - usage: { input_tokens: 1500, output_tokens: 30 }, - }), + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Detailed analysis.' }], + usage: { input_tokens: 1500, output_tokens: 30 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; }); - global.fetch = mockFetch; const result = await runScript( ['https://example.com/image.jpg', '--detail', 'high'], @@ -248,12 +239,20 @@ describe('vision.js', () => { }); it('handles API errors gracefully', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - text: async () => 'Invalid image format', + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: false, + status: 400, + text: async () => 'Invalid image format', + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; }); - global.fetch = mockFetch; const result = await runScript(['https://example.com/invalid.jpg'], { ANTHROPIC_API_KEY: 'test-key', @@ -261,34 +260,21 @@ describe('vision.js', () => { expect(result.code).toBe(1); const error = JSON.parse(result.stderr); - expect(error).toHaveProperty('error'); expect(error.error).toContain('API error'); }); it('detects JPEG file type correctly', async () => { const tempFile = join(tmpdir(), `test-image-${Date.now()}.jpg`); tempFiles.push(tempFile); + writeFileSync(tempFile, Buffer.from('fake-jpeg-data')); - // Create a minimal JPEG file - const jpegData = Buffer.from( - 'ffd8ffe000104a46494600010101006000600000ffdb004300080606070605080707070' + - '9090809090a0d160d0a0a0c0c0c0c0c191318131a161616161616161616161616161616' + - '16161616161616161616161616161616161616161616161616ffc00011080001000103' + - '012200021101031101ffc4001500010100000000000000000000000000000009ffc400' + - '141001010000000000000000000000000000ffda000c03010002110311003f00bfa000' + - '1ffd9', - 'hex' - ); - writeFileSync(tempFile, jpegData); - - const mockFetch = vi.fn().mockResolvedValue({ + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ content: [{ text: 'JPEG image.' }], usage: { input_tokens: 1500, output_tokens: 20 }, }), }); - global.fetch = mockFetch; const result = await runScript([tempFile], { ANTHROPIC_API_KEY: 'test-key', @@ -298,14 +284,22 @@ describe('vision.js', () => { }); it('handles multi-word prompts', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - content: [{ text: 'The person in this image is smiling.' }], - usage: { input_tokens: 1500, output_tokens: 30 }, - }), + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'The person in this image is smiling.' }], + usage: { input_tokens: 1500, output_tokens: 30 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; }); - global.fetch = mockFetch; const result = await runScript( ['https://example.com/person.jpg', 'Is', 'the', 'person', 'smiling?'], @@ -316,14 +310,22 @@ describe('vision.js', () => { }); it('uses AI_GATEWAY_API_KEY as fallback', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - content: [{ text: 'Image analysis.' }], - usage: { input_tokens: 1500, output_tokens: 20 }, - }), + mockFetch.mockImplementation(async (url) => { + if (url.includes('api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Image analysis.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; }); - global.fetch = mockFetch; const result = await runScript(['https://example.com/image.jpg'], { ANTHROPIC_API_KEY: '', @@ -334,14 +336,22 @@ describe('vision.js', () => { }); it('uses custom base URL when provided', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - content: [{ text: 'Image analysis.' }], - usage: { input_tokens: 1500, output_tokens: 20 }, - }), + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://custom.api.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Image analysis.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; }); - global.fetch = mockFetch; const result = await runScript(['https://example.com/image.jpg'], { ANTHROPIC_API_KEY: 'test-key', @@ -354,19 +364,24 @@ describe('vision.js', () => { it('supports WEBP file format', async () => { const tempFile = join(tmpdir(), `test-image-${Date.now()}.webp`); tempFiles.push(tempFile); - - // Create a minimal WEBP file header - const webpData = Buffer.from('524946461400000057454250', 'hex'); - writeFileSync(tempFile, webpData); - - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - content: [{ text: 'WEBP image.' }], - usage: { input_tokens: 1500, output_tokens: 20 }, - }), + writeFileSync(tempFile, Buffer.from('fake-webp-data')); + + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'WEBP image.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; }); - global.fetch = mockFetch; const result = await runScript([tempFile], { ANTHROPIC_API_KEY: 'test-key', @@ -380,14 +395,22 @@ describe('vision.js', () => { tempFiles.push(tempFile); writeFileSync(tempFile, Buffer.from('fake image data')); - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - content: [{ text: 'Unknown format image.' }], - usage: { input_tokens: 1500, output_tokens: 20 }, - }), + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Unknown format image.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; }); - global.fetch = mockFetch; const result = await runScript([tempFile], { ANTHROPIC_API_KEY: 'test-key', @@ -395,4 +418,475 @@ describe('vision.js', () => { expect(result.code).toBe(0); }); + + it('handles very large image file', async () => { + const tempFile = join(tmpdir(), `test-large-${Date.now()}.png`); + tempFiles.push(tempFile); + writeFileSync(tempFile, Buffer.alloc(5 * 1024 * 1024)); + + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Large image analysis.' }], + usage: { input_tokens: 5000, output_tokens: 50 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; + }); + + const result = await runScript([tempFile], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles network error when fetching remote image', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await runScript(['https://example.com/image.jpg'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('error'); + }); + + it('handles invalid image URL', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://example.com')) { + return { + ok: false, + status: 404, + }; + } + return { + ok: true, + json: async () => ({ + content: [{ text: 'Analysis.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + }); + + const result = await runScript(['https://example.com/notfound.jpg'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + }); + + it('handles API returning error for invalid image format', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: false, + status: 400, + text: async () => 'Invalid image format', + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; + }); + + const result = await runScript(['https://example.com/bad.jpg'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('400'); + }); + + it('handles very long prompt', async () => { + const longPrompt = 'Describe ' + 'very '.repeat(100) + 'detailed'; + + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Detailed analysis response.' }], + usage: { input_tokens: 1600, output_tokens: 100 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; + }); + + const result = await runScript(['https://example.com/image.jpg', ...longPrompt.split(' ')], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('includes usage tokens in output', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Analysis.' }], + usage: { input_tokens: 2000, output_tokens: 50 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; + }); + + const result = await runScript(['https://example.com/image.jpg'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.usage.input_tokens).toBe(2000); + expect(output.usage.output_tokens).toBe(50); + }); + + it('handles GIF format', async () => { + const tempFile = join(tmpdir(), `test-image-${Date.now()}.gif`); + tempFiles.push(tempFile); + writeFileSync(tempFile, Buffer.from('GIF89a')); + + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'GIF image.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/gif']]), + }; + }); + + const result = await runScript([tempFile], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles remote image with different content-type', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Image analysis.' }], + usage: { input_tokens: 1500, output_tokens: 30 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/webp']]), + }; + }); + + const result = await runScript(['https://example.com/image.webp'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles rate limiting error', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: false, + status: 429, + text: async () => 'Rate limit exceeded', + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; + }); + + const result = await runScript(['https://example.com/image.jpg'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('429'); + }); + + it('handles image with base64 special characters', async () => { + const tempFile = join(tmpdir(), `test-special-${Date.now()}.png`); + tempFiles.push(tempFile); + writeFileSync(tempFile, Buffer.from('++//==')); + + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Special chars image.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; + }); + + const result = await runScript([tempFile], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('verifies model is included in output', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Analysis.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; + }); + + const result = await runScript(['https://example.com/image.jpg'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.model).toBe('claude-3-5-sonnet-20241022'); + }); + + it('handles image with no file extension', async () => { + const tempFile = join(tmpdir(), `test-no-ext-${Date.now()}`); + tempFiles.push(tempFile); + writeFileSync(tempFile, Buffer.from('image-data')); + + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'No extension image.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/png']]), + }; + }); + + const result = await runScript([tempFile], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles empty image file', async () => { + const tempFile = join(tmpdir(), `test-empty-${Date.now()}.png`); + tempFiles.push(tempFile); + writeFileSync(tempFile, Buffer.from('')); + + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Empty or corrupted image.' }], + usage: { input_tokens: 1000, output_tokens: 10 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(0), + headers: new Map([['content-type', 'image/png']]), + }; + }); + + const result = await runScript([tempFile], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles prompt with quotes', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Response about "quoted" text.' }], + usage: { input_tokens: 1500, output_tokens: 30 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; + }); + + const result = await runScript( + ['https://example.com/image.jpg', 'What is "this"?'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + }); + + it('handles BMP image format', async () => { + const tempFile = join(tmpdir(), `test-image-${Date.now()}.bmp`); + tempFiles.push(tempFile); + writeFileSync(tempFile, Buffer.from('BM')); + + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'BMP image.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/bmp']]), + }; + }); + + const result = await runScript([tempFile], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles URL with authentication parameters', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Authenticated image analysis.' }], + usage: { input_tokens: 1500, output_tokens: 30 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/png']]), + }; + }); + + const result = await runScript( + ['https://user:pass@example.com/image.png'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + }); + + it('handles very short prompt', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Analysis for short prompt.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; + }); + + const result = await runScript(['https://example.com/image.jpg', 'Hi'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('verifies usage information is complete', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Analysis.' }], + usage: { input_tokens: 2500, output_tokens: 75 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; + }); + + const result = await runScript(['https://example.com/image.jpg'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.usage).toHaveProperty('input_tokens', 2500); + expect(output.usage).toHaveProperty('output_tokens', 75); + }); }); \ No newline at end of file diff --git a/skills/cloudflare-browser/scripts/cdp-client.test.js b/skills/cloudflare-browser/scripts/cdp-client.test.js index 799c634..004c8f4 100644 --- a/skills/cloudflare-browser/scripts/cdp-client.test.js +++ b/skills/cloudflare-browser/scripts/cdp-client.test.js @@ -1,484 +1,549 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -// Mock WebSocket before importing the module -const mockWebSocket = vi.fn(); -vi.mock('ws', () => ({ - default: mockWebSocket -})); - -describe('cdp-client.js', () => { - let createClient; - let mockWs; - - beforeEach(async () => { - // Setup mock WebSocket - mockWs = { - send: vi.fn(), - close: vi.fn(), - on: vi.fn(), - }; - mockWebSocket.mockReturnValue(mockWs); - - // Dynamically import after mock is set up - const module = await import('./cdp-client.js'); - createClient = module.createClient; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('createClient', () => { - it('throws error when CDP_SECRET is not provided', async () => { - await expect(createClient({})).rejects.toThrow('CDP_SECRET environment variable not set'); - }); - - it('constructs correct WebSocket URL', () => { - const options = { - secret: 'test-secret', - workerUrl: 'https://worker.example.com' - }; - - createClient(options).catch(() => {}); // Prevent unhandled rejection - - expect(mockWebSocket).toHaveBeenCalledWith( - expect.stringContaining('wss://worker.example.com/cdp?secret=test-secret') - ); - }); - - it('strips protocol from worker URL', () => { - const options = { - secret: 'secret', - workerUrl: 'http://worker.com' - }; - - createClient(options).catch(() => {}); - - const wsUrl = mockWebSocket.mock.calls[0][0]; - expect(wsUrl).toMatch(/^wss:\/\/worker\.com/); - }); - - it('registers event handlers', () => { - createClient({ secret: 'test', workerUrl: 'https://test.com' }).catch(() => {}); - - expect(mockWs.on).toHaveBeenCalledWith('message', expect.any(Function)); - expect(mockWs.on).toHaveBeenCalledWith('error', expect.any(Function)); - expect(mockWs.on).toHaveBeenCalledWith('open', expect.any(Function)); - }); - }); - - describe('client API', () => { - it('provides navigate method', async () => { - const clientPromise = createClient({ secret: 'test', workerUrl: 'https://test.com' }); - - // Simulate WebSocket open and target created - const openHandler = mockWs.on.mock.calls.find(call => call[0] === 'open')[1]; - const messageHandler = mockWs.on.mock.calls.find(call => call[0] === 'message')[1]; - - // Simulate target creation message - setTimeout(() => { - messageHandler(JSON.stringify({ - method: 'Target.targetCreated', - params: { targetInfo: { type: 'page', targetId: 'target-123' } } - })); - openHandler(); - }, 0); - - const client = await clientPromise; - expect(client).toHaveProperty('navigate'); - expect(typeof client.navigate).toBe('function'); - }); - - it('provides screenshot method', async () => { - const clientPromise = createClient({ secret: 'test', workerUrl: 'https://test.com' }); - - const openHandler = mockWs.on.mock.calls.find(call => call[0] === 'open')[1]; - const messageHandler = mockWs.on.mock.calls.find(call => call[0] === 'message')[1]; - - setTimeout(() => { - messageHandler(JSON.stringify({ - method: 'Target.targetCreated', - params: { targetInfo: { type: 'page', targetId: 'target-123' } } - })); - openHandler(); - }, 0); - - const client = await clientPromise; - expect(client).toHaveProperty('screenshot'); - expect(typeof client.screenshot).toBe('function'); - }); - - it('provides evaluate method', async () => { - const clientPromise = createClient({ secret: 'test', workerUrl: 'https://test.com' }); - - const openHandler = mockWs.on.mock.calls.find(call => call[0] === 'open')[1]; - const messageHandler = mockWs.on.mock.calls.find(call => call[0] === 'message')[1]; - - setTimeout(() => { - messageHandler(JSON.stringify({ - method: 'Target.targetCreated', - params: { targetInfo: { type: 'page', targetId: 'target-123' } } - })); - openHandler(); - }, 0); - - const client = await clientPromise; - expect(client).toHaveProperty('evaluate'); - expect(typeof client.evaluate).toBe('function'); - }); - - it('provides close method', async () => { - const clientPromise = createClient({ secret: 'test', workerUrl: 'https://test.com' }); - - const openHandler = mockWs.on.mock.calls.find(call => call[0] === 'open')[1]; - const messageHandler = mockWs.on.mock.calls.find(call => call[0] === 'message')[1]; - - setTimeout(() => { - messageHandler(JSON.stringify({ - method: 'Target.targetCreated', - params: { targetInfo: { type: 'page', targetId: 'target-123' } } - })); - openHandler(); - }, 0); - - const client = await clientPromise; - client.close(); - expect(mockWs.close).toHaveBeenCalled(); - }); - }); - - describe('error handling', () => { - it('rejects when no target is created within timeout', async () => { - const clientPromise = createClient({ secret: 'test', workerUrl: 'https://test.com' }); - - const openHandler = mockWs.on.mock.calls.find(call => call[0] === 'open')[1]; - setTimeout(() => openHandler(), 0); - - await expect(clientPromise).rejects.toThrow('No target created'); - }); - - it('handles WebSocket errors', async () => { - const clientPromise = createClient({ secret: 'test', workerUrl: 'https://test.com' }); - - const errorHandler = mockWs.on.mock.calls.find(call => call[0] === 'error')[1]; - setTimeout(() => errorHandler(new Error('Connection failed')), 0); - - await expect(clientPromise).rejects.toThrow('Connection failed'); - }); import { EventEmitter } from 'events'; -// Mock WebSocket before requiring the module -class MockWebSocket extends EventEmitter { +class MockWS extends EventEmitter { constructor(url) { super(); this.url = url; - this.readyState = 0; // CONNECTING - this.CONNECTING = 0; - this.OPEN = 1; - this.CLOSING = 2; - this.CLOSED = 3; - - // Simulate connection + this.readyState = 1; // OPEN setTimeout(() => { - this.readyState = 1; // OPEN this.emit('open'); - - // Simulate target creation setTimeout(() => { - const msg = { + this.emit('message', JSON.stringify({ method: 'Target.targetCreated', - params: { - targetInfo: { - type: 'page', - targetId: 'mock-target-id-123', - }, - }, - }; - this.emit('message', JSON.stringify(msg)); + params: { targetInfo: { type: 'page', targetId: 'mock-id' } } + })); }, 10); }, 10); } - send(data) { - if (this.readyState !== 1) { - throw new Error('WebSocket is not open'); - } - - // Parse and auto-respond to CDP commands const msg = JSON.parse(data); setTimeout(() => { - const response = { id: msg.id, result: {} }; - - // Simulate specific responses - if (msg.method === 'Page.captureScreenshot') { - response.result = { data: Buffer.from('fake-image').toString('base64') }; - } else if (msg.method === 'Runtime.evaluate') { - response.result = { result: { value: 'mock-result' } }; - } - - this.emit('message', JSON.stringify(response)); - }, 10); - } - - close() { - this.readyState = 2; // CLOSING - setTimeout(() => { - this.readyState = 3; // CLOSED - this.emit('close'); + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); }, 10); } + close() { this.emit('close'); } } -vi.mock('ws', () => ({ - default: MockWebSocket, -})); - describe('cdp-client.js', () => { let originalEnv; beforeEach(() => { + vi.resetModules(); originalEnv = { ...process.env }; process.env.CDP_SECRET = 'test-secret'; - process.env.WORKER_URL = 'https://test-worker.example.com'; + process.env.WORKER_URL = 'test-worker.example.com'; }); afterEach(() => { process.env = originalEnv; - vi.clearAllMocks(); }); - it('throws error when CDP_SECRET is not set', async () => { + it('throws error when CDP_SECRET is not set', () => { delete process.env.CDP_SECRET; - - const { createClient } = await import('./cdp-client.js'); - - await expect(createClient()).rejects.toThrow('CDP_SECRET'); + const { createClient } = require('./cdp-client.js'); + expect(() => createClient()).toThrow('CDP_SECRET'); }); it('creates client successfully', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); - - expect(client).toHaveProperty('ws'); - expect(client).toHaveProperty('targetId'); - expect(client).toHaveProperty('send'); - expect(client).toHaveProperty('navigate'); - expect(client).toHaveProperty('screenshot'); - expect(client).toHaveProperty('setViewport'); - expect(client).toHaveProperty('evaluate'); - expect(client).toHaveProperty('scroll'); - expect(client).toHaveProperty('click'); - expect(client).toHaveProperty('type'); - expect(client).toHaveProperty('getHTML'); - expect(client).toHaveProperty('getText'); - expect(client).toHaveProperty('close'); - }); - - it('accepts custom options', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient({ - secret: 'custom-secret', - workerUrl: 'https://custom-worker.example.com', - timeout: 30000, - }); - + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); expect(client).toBeDefined(); - }); - - it('creates WebSocket with correct URL', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); - - expect(client.ws.url).toContain('wss://'); - expect(client.ws.url).toContain('test-worker.example.com'); - expect(client.ws.url).toContain('cdp'); - expect(client.ws.url).toContain('secret='); - }); - - it('has valid targetId after connection', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); - - expect(client.targetId).toBe('mock-target-id-123'); + expect(client.targetId).toBe('mock-id'); }); it('navigate method works', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); - - await expect(client.navigate('https://example.com', 100)).resolves.toBeUndefined(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.navigate('https://example.com', 10)).resolves.toBeUndefined(); }); it('screenshot method returns buffer', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); - - const screenshot = await client.screenshot(); - - expect(Buffer.isBuffer(screenshot)).toBe(true); - }); - - it('screenshot accepts format parameter', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); - - const screenshot = await client.screenshot('jpeg'); + class MockWSWithScreenshot extends EventEmitter { + constructor(url) { + super(); + this.url = url; + setTimeout(() => { + this.emit('open'); + setTimeout(() => { + this.emit('message', JSON.stringify({ + method: 'Target.targetCreated', + params: { targetInfo: { type: 'page', targetId: 'mock-id' } } + })); + }, 10); + }, 10); + } + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Page.captureScreenshot') { + this.emit('message', JSON.stringify({ id: msg.id, result: { data: 'dGVzdA==' } })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + close() { this.emit('close'); } + } - expect(Buffer.isBuffer(screenshot)).toBe(true); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSWithScreenshot }); + const buffer = await client.screenshot(); + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.toString()).toBe('test'); }); it('setViewport method works', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); - + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); await expect(client.setViewport(1920, 1080, 2, true)).resolves.toBeUndefined(); }); it('evaluate method works', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); - - const result = await client.evaluate('2 + 2'); - - expect(result).toBeDefined(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.evaluate('1 + 1')).resolves.toBeDefined(); }); it('scroll method works', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); - + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); await expect(client.scroll(500)).resolves.toBeUndefined(); }); it('click method works', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); - - await expect(client.click('#button')).resolves.toBeUndefined(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.click('.button')).resolves.toBeUndefined(); }); it('type method works', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); - + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); await expect(client.type('#input', 'test text')).resolves.toBeUndefined(); }); - it('getHTML method returns string', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); + it('getHTML method returns HTML string', async () => { + class MockWSWithHTML extends EventEmitter { + constructor(url) { + super(); + this.url = url; + setTimeout(() => { + this.emit('open'); + setTimeout(() => { + this.emit('message', JSON.stringify({ + method: 'Target.targetCreated', + params: { targetInfo: { type: 'page', targetId: 'mock-id' } } + })); + }, 10); + }, 10); + } + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate' && msg.params.expression.includes('outerHTML')) { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { result: { value: 'Test' } } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + close() { this.emit('close'); } + } + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSWithHTML }); const html = await client.getHTML(); - - expect(typeof html).toBe('string'); + expect(html).toBe('Test'); }); - it('getText method returns string', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); + it('getText method returns text content', async () => { + class MockWSWithText extends EventEmitter { + constructor(url) { + super(); + this.url = url; + setTimeout(() => { + this.emit('open'); + setTimeout(() => { + this.emit('message', JSON.stringify({ + method: 'Target.targetCreated', + params: { targetInfo: { type: 'page', targetId: 'mock-id' } } + })); + }, 10); + }, 10); + } + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate' && msg.params.expression.includes('innerText')) { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { result: { value: 'Page text content' } } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + close() { this.emit('close'); } + } + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSWithText }); const text = await client.getText(); - - expect(typeof text).toBe('string'); + expect(text).toBe('Page text content'); }); - it('close method works', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); - + it('close method closes WebSocket', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); expect(() => client.close()).not.toThrow(); }); - it('send method with no params', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles send timeout', async () => { + class MockWSTimeout extends EventEmitter { + constructor(url) { + super(); + this.url = url; + setTimeout(() => { + this.emit('open'); + setTimeout(() => { + this.emit('message', JSON.stringify({ + method: 'Target.targetCreated', + params: { targetInfo: { type: 'page', targetId: 'mock-id' } } + })); + }, 10); + }, 10); + } + send(data) { + // Don't respond to simulate timeout + } + close() { this.emit('close'); } + } - const client = await createClient(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSTimeout, timeout: 100 }); + await expect(client.send('Page.navigate', { url: 'https://example.com' })).rejects.toThrow('Timeout'); + }); - const result = await client.send('Page.enable'); + it('handles CDP error response', async () => { + class MockWSError extends EventEmitter { + constructor(url) { + super(); + this.url = url; + setTimeout(() => { + this.emit('open'); + setTimeout(() => { + this.emit('message', JSON.stringify({ + method: 'Target.targetCreated', + params: { targetInfo: { type: 'page', targetId: 'mock-id' } } + })); + }, 10); + }, 10); + } + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + this.emit('message', JSON.stringify({ + id: msg.id, + error: { message: 'CDP command failed' } + })); + }, 10); + } + close() { this.emit('close'); } + } - expect(result).toBeDefined(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSError }); + await expect(client.navigate('https://example.com')).rejects.toThrow('CDP command failed'); }); - it('send method with params', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles WebSocket connection error', async () => { + class MockWSConnError extends EventEmitter { + constructor(url) { + super(); + this.url = url; + setTimeout(() => { + this.emit('error', new Error('Connection failed')); + }, 10); + } + send() {} + close() {} + } + + const { createClient } = require('./cdp-client.js'); + await expect(createClient({ WebSocket: MockWSConnError })).rejects.toThrow('Connection failed'); + }); - const client = await createClient(); + it('handles target creation timeout', async () => { + class MockWSNoTarget extends EventEmitter { + constructor(url) { + super(); + this.url = url; + setTimeout(() => { + this.emit('open'); + // Don't emit target created + }, 10); + } + send() {} + close() {} + } - const result = await client.send('Page.navigate', { url: 'https://example.com' }); + const { createClient } = require('./cdp-client.js'); + await expect(createClient({ WebSocket: MockWSNoTarget })).rejects.toThrow('No target created'); + }, 15000); - expect(result).toBeDefined(); + it('uses custom secret from options', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ + WebSocket: MockWS, + secret: 'custom-secret', + workerUrl: 'custom.worker.dev' + }); + expect(client).toBeDefined(); + expect(client.targetId).toBe('mock-id'); }); - it('handles WORKER_URL with http://', async () => { - process.env.WORKER_URL = 'http://test-worker.example.com'; + it('uses default screenshot format', async () => { + class MockWSScreenshot extends EventEmitter { + constructor(url) { + super(); + this.url = url; + setTimeout(() => { + this.emit('open'); + setTimeout(() => { + this.emit('message', JSON.stringify({ + method: 'Target.targetCreated', + params: { targetInfo: { type: 'page', targetId: 'mock-id' } } + })); + }, 10); + }, 10); + } + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Page.captureScreenshot') { + expect(msg.params.format).toBe('png'); + this.emit('message', JSON.stringify({ id: msg.id, result: { data: 'dGVzdA==' } })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + close() {} + } - const { createClient } = await import('./cdp-client.js'); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSScreenshot }); + await client.screenshot(); + }); - const client = await createClient(); + it('uses custom viewport defaults', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.setViewport()).resolves.toBeUndefined(); + }); - expect(client.ws.url).toContain('wss://'); - expect(client.ws.url).not.toContain('http://'); + it('uses custom scroll amount', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.scroll(1000)).resolves.toBeUndefined(); }); - it('handles WORKER_URL with https://', async () => { - process.env.WORKER_URL = 'https://test-worker.example.com'; + it('handles scroll with zero pixels', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.scroll(0)).resolves.toBeUndefined(); + }); - const { createClient } = await import('./cdp-client.js'); + it('handles scroll with negative pixels', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.scroll(-500)).resolves.toBeUndefined(); + }); - const client = await createClient(); + it('handles type with empty string', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.type('#input', '')).resolves.toBeUndefined(); + }); - expect(client.ws.url).toContain('wss://'); - expect(client.ws.url).not.toContain('https://'); + it('handles type with special characters needing escaping', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.type('#input', "test'quote")).resolves.toBeUndefined(); }); - it('uses default timeout when not specified', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles click on non-existent selector', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.click('.non-existent')).resolves.toBeUndefined(); + }); - const client = await createClient(); + it('handles getHTML returning null', async () => { + class MockWSNullHTML extends EventEmitter { + constructor(url) { + super(); + this.url = url; + setTimeout(() => { + this.emit('open'); + setTimeout(() => { + this.emit('message', JSON.stringify({ + method: 'Target.targetCreated', + params: { targetInfo: { type: 'page', targetId: 'mock-id' } } + })); + }, 10); + }, 10); + } + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate' && msg.params.expression.includes('outerHTML')) { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { result: { value: null } } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + close() { this.emit('close'); } + } - expect(client).toBeDefined(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSNullHTML }); + const html = await client.getHTML(); + expect(html).toBeNull(); }); - it('navigate with custom wait time', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles getText returning empty string', async () => { + class MockWSEmptyText extends EventEmitter { + constructor(url) { + super(); + this.url = url; + setTimeout(() => { + this.emit('open'); + setTimeout(() => { + this.emit('message', JSON.stringify({ + method: 'Target.targetCreated', + params: { targetInfo: { type: 'page', targetId: 'mock-id' } } + })); + }, 10); + }, 10); + } + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate' && msg.params.expression.includes('innerText')) { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { result: { value: '' } } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + close() { this.emit('close'); } + } - const client = await createClient(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSEmptyText }); + const text = await client.getText(); + expect(text).toBe(''); + }); - const start = Date.now(); - await client.navigate('https://example.com', 200); - const duration = Date.now() - start; + it('handles multiple rapid send calls', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); - expect(duration).toBeGreaterThanOrEqual(200); - }); + const promises = [ + client.evaluate('1 + 1'), + client.evaluate('2 + 2'), + client.evaluate('3 + 3'), + ]; - it('scroll with default distance', async () => { - const { createClient } = await import('./cdp-client.js'); + await expect(Promise.all(promises)).resolves.toBeDefined(); + }); - const client = await createClient(); + it('handles WebSocket close event', async () => { + class MockWSWithClose extends EventEmitter { + constructor(url) { + super(); + this.url = url; + setTimeout(() => { + this.emit('open'); + setTimeout(() => { + this.emit('message', JSON.stringify({ + method: 'Target.targetCreated', + params: { targetInfo: { type: 'page', targetId: 'mock-id' } } + })); + }, 10); + }, 10); + } + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + }, 10); + } + close() { + this.emit('close'); + } + } - await expect(client.scroll()).resolves.toBeUndefined(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSWithClose }); + expect(() => client.close()).not.toThrow(); }); - it('setViewport with default values', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles screenshot with jpeg format', async () => { + class MockWSJpegScreenshot extends EventEmitter { + constructor(url) { + super(); + this.url = url; + setTimeout(() => { + this.emit('open'); + setTimeout(() => { + this.emit('message', JSON.stringify({ + method: 'Target.targetCreated', + params: { targetInfo: { type: 'page', targetId: 'mock-id' } } + })); + }, 10); + }, 10); + } + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Page.captureScreenshot') { + this.emit('message', JSON.stringify({ id: msg.id, result: { data: 'dGVzdA==' } })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + close() { this.emit('close'); } + } - const client = await createClient(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSJpegScreenshot }); + const buffer = await client.screenshot('jpeg'); + expect(buffer).toBeInstanceOf(Buffer); + }); - await expect(client.setViewport()).resolves.toBeUndefined(); + it('handles navigate with custom wait time', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.navigate('https://example.com', 1000)).resolves.toBeUndefined(); + }); + + it('handles setViewport with mobile true', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.setViewport(375, 667, 2, true)).resolves.toBeUndefined(); }); }); \ No newline at end of file diff --git a/skills/cloudflare-browser/scripts/screenshot.test.js b/skills/cloudflare-browser/scripts/screenshot.test.js index bc99c6d..8366920 100644 --- a/skills/cloudflare-browser/scripts/screenshot.test.js +++ b/skills/cloudflare-browser/scripts/screenshot.test.js @@ -1,17 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -const scriptPath = path.join(process.cwd(), 'skills/cloudflare-browser/scripts/screenshot.js'); - -describe('screenshot.js', () => { - let originalEnv; - - beforeEach(() => { - originalEnv = { ...process.env }; - process.env.CDP_SECRET = 'test-secret'; - process.env.WORKER_URL = 'https://worker.example.com'; import { existsSync, unlinkSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -26,18 +14,6 @@ describe('screenshot.js', () => { afterEach(() => { process.env = originalEnv; - // Clean up test files - const testFile = 'test-screenshot.png'; - if (fs.existsSync(testFile)) { - fs.unlinkSync(testFile); - } - }); - - function runScript(args) { - return new Promise((resolve) => { - const proc = spawn('node', [scriptPath, ...args], { - env: process.env, - timeout: 5000 tempFiles.forEach((file) => { if (existsSync(file)) { try { @@ -69,64 +45,6 @@ describe('screenshot.js', () => { stderr += data.toString(); }); - proc.on('close', (exitCode) => { - resolve({ exitCode, stdout, stderr }); - }); - }); - } - - describe('environment validation', () => { - it('fails when CDP_SECRET is not set', async () => { - delete process.env.CDP_SECRET; - - const result = await runScript(['https://example.com']); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('CDP_SECRET environment variable not set'); - }); - - it('requires WORKER_URL to be set', async () => { - delete process.env.WORKER_URL; - - const result = await runScript(['https://example.com']); - - expect(result.exitCode).toBe(1); - }); - }); - - describe('argument parsing', () => { - it('displays usage when no URL is provided', async () => { - const result = await runScript([]); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('Usage: node screenshot.js'); - }); - - it('uses default output filename', async () => { - // This test would require mocking WebSocket which is complex - // Just verify the script accepts the argument - const result = await runScript(['https://example.com']); - // Will fail due to WebSocket but at least validates arg parsing - expect(result.stderr).not.toContain('Usage:'); - }); - - it('accepts custom output filename', async () => { - const result = await runScript(['https://example.com', 'custom.png']); - // Will fail due to WebSocket but validates arg parsing - expect(result.stderr).not.toContain('Usage:'); - }); - }); - - describe('URL handling', () => { - it('accepts HTTP URLs', async () => { - const result = await runScript(['http://example.com']); - expect(result.stderr).not.toContain('Usage:'); - }); - - it('accepts HTTPS URLs', async () => { - const result = await runScript(['https://example.com']); - expect(result.stderr).not.toContain('Usage:'); - }); proc.on('close', (code) => { resolve({ code, stdout, stderr }); }); @@ -304,4 +222,76 @@ describe('screenshot.js', () => { expect(result.stderr).not.toContain('Usage'); }); + + it('handles output filename with .jpg extension', async () => { + const outputFile = join(tmpdir(), `test-screenshot-${Date.now()}.jpg`); + tempFiles.push(outputFile); + + const result = await runScript(['https://example.com', outputFile], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles output filename with spaces', async () => { + const outputFile = join(tmpdir(), `test screenshot ${Date.now()}.png`); + tempFiles.push(outputFile); + + const result = await runScript(['https://example.com', outputFile], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles URL with port number', async () => { + const result = await runScript(['https://example.com:8080'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles URL with IPv4 address', async () => { + const result = await runScript(['https://192.168.1.1'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles URL with localhost', async () => { + const result = await runScript(['https://localhost:3000'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles output to current directory', async () => { + const outputFile = 'screenshot.png'; + tempFiles.push(outputFile); + + const result = await runScript(['https://example.com', outputFile], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles CDP_SECRET with special characters', async () => { + const result = await runScript(['https://example.com'], { + CDP_SECRET: 'secret!@#$%^&*()', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); }); \ No newline at end of file diff --git a/skills/cloudflare-browser/scripts/video.test.js b/skills/cloudflare-browser/scripts/video.test.js index 2a66af7..6c26ade 100644 --- a/skills/cloudflare-browser/scripts/video.test.js +++ b/skills/cloudflare-browser/scripts/video.test.js @@ -1,16 +1,3 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { spawn } from 'child_process'; -import path from 'path'; - -const scriptPath = path.join(process.cwd(), 'skills/cloudflare-browser/scripts/video.js'); - -describe('video.js', () => { - let originalEnv; - - beforeEach(() => { - originalEnv = { ...process.env }; - process.env.CDP_SECRET = 'test-secret'; - process.env.WORKER_URL = 'https://worker.example.com'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { spawn } from 'child_process'; import { existsSync, unlinkSync } from 'fs'; @@ -27,13 +14,6 @@ describe('video.js', () => { afterEach(() => { process.env = originalEnv; - }); - - function runScript(args) { - return new Promise((resolve) => { - const proc = spawn('node', [scriptPath, ...args], { - env: process.env, - timeout: 5000 tempFiles.forEach((file) => { if (existsSync(file)) { try { @@ -65,68 +45,6 @@ describe('video.js', () => { stderr += data.toString(); }); - proc.on('close', (exitCode) => { - resolve({ exitCode, stdout, stderr }); - }); - }); - } - - describe('environment validation', () => { - it('fails when CDP_SECRET is not set', async () => { - delete process.env.CDP_SECRET; - - const result = await runScript(['https://example.com']); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('CDP_SECRET environment variable not set'); - }); - }); - - describe('argument parsing', () => { - it('displays usage when no URL is provided', async () => { - const result = await runScript([]); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('Usage: node video.js'); - }); - - it('accepts single URL', async () => { - const result = await runScript(['https://example.com']); - expect(result.stderr).not.toContain('Usage:'); - }); - - it('accepts multiple comma-separated URLs', async () => { - const result = await runScript(['https://example.com,https://example.org']); - expect(result.stderr).not.toContain('Usage:'); - }); - - it('accepts --fps option', async () => { - const result = await runScript(['https://example.com', '--fps', '15']); - expect(result.stderr).not.toContain('Usage:'); - }); - - it('accepts --scroll option', async () => { - const result = await runScript(['https://example.com', '--scroll']); - expect(result.stderr).not.toContain('Usage:'); - }); - - it('accepts custom output filename', async () => { - const result = await runScript(['https://example.com', 'custom.mp4']); - expect(result.stderr).not.toContain('Usage:'); - }); - }); - - describe('URL parsing', () => { - it('splits comma-separated URLs', async () => { - const result = await runScript(['https://a.com,https://b.com,https://c.com']); - // Script will fail on WebSocket but arg parsing should work - expect(result.stderr).not.toContain('Usage:'); - }); - - it('trims whitespace from URLs', async () => { - const result = await runScript(['https://a.com, https://b.com , https://c.com']); - expect(result.stderr).not.toContain('Usage:'); - }); proc.on('close', (code) => { resolve({ code, stdout, stderr }); }); @@ -353,4 +271,101 @@ describe('video.js', () => { expect(result.stderr).not.toContain('Usage'); }); + + it('handles zero FPS value', async () => { + const result = await runScript(['https://example.com', '--fps', '0'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles negative FPS value', async () => { + const result = await runScript(['https://example.com', '--fps', '-10'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles output filename with .avi extension', async () => { + const result = await runScript(['https://example.com', 'output.avi'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles output filename with spaces', async () => { + const outputFile = join(tmpdir(), `test video ${Date.now()}.mp4`); + tempFiles.push(outputFile); + + const result = await runScript(['https://example.com', outputFile], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles URLs with different schemes', async () => { + const result = await runScript(['http://example.com,https://test.com'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles very long URL list', async () => { + const urls = Array(10).fill('https://example.com').join(','); + const result = await runScript([urls], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles --scroll with --fps together', async () => { + const result = await runScript( + ['https://example.com', '--scroll', '--fps', '24'], + { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + } + ); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles URL with port and path', async () => { + const result = await runScript(['https://example.com:8080/path/to/page'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles fractional FPS value', async () => { + const result = await runScript(['https://example.com', '--fps', '23.976'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles non-numeric FPS value', async () => { + const result = await runScript(['https://example.com', '--fps', 'abc'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); }); \ No newline at end of file