diff --git a/skills/ai-tools/scripts/embeddings.test.js b/skills/ai-tools/scripts/embeddings.test.js index cc7abe9..d1b25bc 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,245 @@ describe('embeddings.js', () => { expect(result.code).toBe(0); }); + + it('handles very long input text', async () => { + const longText = 'word '.repeat(10000); + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: Array(1536).fill(0.1) }], + usage: { total_tokens: 10000 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript([longText], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles special characters in text', async () => { + const specialText = 'Hello! @#$%^&*() émojis 🎉🎊 ñáéíóú'; + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { total_tokens: 15 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript([specialText], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles invalid 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(['text', '--dimensions', 'invalid'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + // NaN becomes null when serialized to JSON + expect(output.dimensions).toBeNull(); + }); + + it('handles zero dimensions parameter', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [] }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['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 negative dimensions parameter', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [] }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['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 malformed JSON response from API', async () => { + const mockResponse = { + ok: true, + json: async () => { + throw new Error('Invalid JSON'); + }, + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['text'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('error'); + }); + + it('handles missing embedding data in response', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['text'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('error'); + }); + + it('handles multiple flag options in different orders', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2] }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript( + ['--dimensions', '256', '--model', 'text-embedding-ada-002', 'test text'], + { OPENAI_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.model).toBe('text-embedding-ada-002'); + expect(output.dimensions).toBe(256); + }); + + it('handles text with newlines', async () => { + const textWithNewlines = 'Line 1\nLine 2\nLine 3'; + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2] }], + usage: { total_tokens: 10 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript([textWithNewlines], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles API rate limit error (429)', async () => { + const mockResponse = { + ok: false, + status: 429, + text: async () => 'Rate limit exceeded', + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['text'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('429'); + }); + + it('handles missing model parameter gracefully', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.1] }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['text', '--model'], { + OPENAI_API_KEY: 'test-key', + }); + + // Should still work because 'text' is the first positional arg + expect(result.code).toBe(0); + }); + + it('validates that fetch is called with correct parameters', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + await runScript(['test text', '--model', 'custom-model', '--dimensions', '768'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.openai.com/v1/embeddings', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer test-key', + }), + }) + ); + }); }); \ 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..a0da860 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,234 @@ describe('extract.js', () => { expect(output.extracted.person.name).toBe('Alice'); expect(output.extracted.person.address.city).toBe('Boston'); }); + + it('handles very long text input', async () => { + const longText = 'Lorem ipsum '.repeat(1000); + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"summary": "Long text"}' }], + usage: { input_tokens: 5000, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([longText, '--schema', '{"summary":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles JSON with code blocks without language specifier', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '```\n{"result": "success"}\n```' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', '{"result":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.result).toBe('success'); + }); + + it('handles schema with array types', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"items": ["a", "b", "c"]}' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', '{"items":"array"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(Array.isArray(output.extracted.items)).toBe(true); + }); + + it('handles network timeout errors', 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 empty schema string', async () => { + const result = await runScript(['text', '--schema', ''], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('Usage'); + }); + + it('handles malformed JSON in API response', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => { + throw new Error('Malformed JSON'); + }, + }); + 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 very long schema definitions', async () => { + const longSchema = JSON.stringify({ + field1: 'string', + field2: 'number', + field3: { nested1: 'string', nested2: 'number' }, + field4: 'array', + field5: 'boolean', + }); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"field1": "value"}' }], + usage: { input_tokens: 100, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', longSchema], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles multiple words in text input', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"result": "parsed"}' }], + usage: { input_tokens: 20, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['word1', 'word2', 'word3', '--schema', '{"result":"string"}'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.result).toBe('parsed'); + }); + + it('handles API 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', '--schema', '{"data":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('500'); + }); + + it('handles response with null values', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"name": null, "age": null}' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['text', '--schema', '{"name":"string","age":"number"}'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.name).toBeNull(); + }); + + it('handles empty text input', async () => { + const result = await runScript(['--schema', '{"data":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('Usage'); + }); + + it('handles special characters in schema', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"field_name": "value"}' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['text', '--schema', '{"field_name":"string"}'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + }); + + it('validates fetch is called with correct anthropic headers', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"data": "test"}' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + await runScript(['text', '--schema', '{"data":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.anthropic.com/v1/messages', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'x-api-key': 'test-key', + 'anthropic-version': '2023-06-01', + }), + }) + ); + }); }); \ 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..7ae20cf 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 }); - }); - }); - } - - 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); + 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; }); - 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,350 @@ describe('sentiment.js', () => { expect(result.code).toBe(0); }); + + it('handles sarcastic text', async () => { + const mockAnalysis = { + sentiment: 'mixed', + score: -0.3, + confidence: 0.6, + emotions: [{ emotion: 'sarcasm', intensity: 0.8 }], + tone: 'sarcastic', + keywords: ['great', 'just'], + }; + + 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, just what I needed'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.sentiment).toBe('mixed'); + }); + + it('handles very short text', async () => { + const mockAnalysis = { + sentiment: 'neutral', + score: 0.0, + confidence: 0.5, + emotions: [], + tone: 'brief', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 5, output_tokens: 15 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['Ok'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles extreme negative sentiment', async () => { + const mockAnalysis = { + sentiment: 'negative', + score: -1.0, + confidence: 0.95, + emotions: [{ emotion: 'anger', intensity: 0.9 }, { emotion: 'disgust', intensity: 0.8 }], + tone: 'hostile', + keywords: ['hate', 'worst', 'horrible'], + }; + + 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(['I hate this. Worst experience ever. Horrible!'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.score).toBe(-1.0); + }); + + it('handles extreme positive sentiment', async () => { + const mockAnalysis = { + sentiment: 'positive', + score: 1.0, + confidence: 0.95, + emotions: [{ emotion: 'joy', intensity: 0.95 }, { emotion: 'excitement', intensity: 0.9 }], + tone: 'enthusiastic', + keywords: ['amazing', 'best', 'love'], + }; + + 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 amazing! Best experience ever! I love it!'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.score).toBe(1.0); + }); + + it('handles multiple sentences with varying sentiments', async () => { + const mockAnalysis = { + sentiment: 'mixed', + score: 0.1, + confidence: 0.75, + emotions: [ + { emotion: 'joy', intensity: 0.5 }, + { emotion: 'sadness', intensity: 0.4 }, + ], + tone: 'conflicted', + keywords: ['good', 'bad'], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 20, output_tokens: 25 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['The product is good. But the service was bad. Overall okay.'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.sentiment).toBe('mixed'); + }); + + it('handles questions in text', async () => { + const mockAnalysis = { + sentiment: 'neutral', + score: 0.0, + confidence: 0.6, + emotions: [], + tone: 'inquisitive', + keywords: ['what', 'why'], + }; + + 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(['What is this? Why does it work?'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles emojis in text', async () => { + const mockAnalysis = { + sentiment: 'positive', + score: 0.8, + confidence: 0.9, + emotions: [{ emotion: 'joy', intensity: 0.8 }], + tone: 'cheerful', + 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 text with only punctuation', async () => { + const mockAnalysis = { + sentiment: 'neutral', + score: 0.0, + confidence: 0.3, + emotions: [], + tone: 'unclear', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 5, output_tokens: 15 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['!!!???...'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles malformed JSON in API response', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => { + throw new Error('Invalid JSON'); + }, + }); + 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 API 503 service unavailable error', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + text: async () => 'Service unavailable', + }); + 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('503'); + }); + + it('validates text is truncated at 100 characters', async () => { + const longText = 'a'.repeat(150); + const mockAnalysis = { + sentiment: 'neutral', + score: 0.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: 50, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([longText], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.text.length).toBeLessThanOrEqual(103); // 100 + '...' + }); + + it('validates fetch is called with correct parameters', async () => { + const mockAnalysis = { + sentiment: 'positive', + score: 0.5, + confidence: 0.8, + emotions: [], + tone: 'casual', + 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; + + await runScript(['test text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.anthropic.com/v1/messages', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'x-api-key': 'test-key', + 'anthropic-version': '2023-06-01', + }), + }) + ); + }); + + it('handles whitespace-only text', async () => { + const mockAnalysis = { + sentiment: 'neutral', + score: 0.0, + confidence: 0.3, + emotions: [], + tone: 'unclear', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 5, output_tokens: 15 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([' '], { + ANTHROPIC_API_KEY: 'test-key', + }); + + // Whitespace is treated as valid text + expect(result.code).toBe(0); + }); }); \ 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..3bca9be 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 } - }) - }); - - 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); - }); + const runScript = async (args, env = {}) => { + const { main } = require('./summarize.js'); + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + process.argv = ['node', 'summarize.js', ...args]; - 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,313 @@ describe('summarize.js', () => { const output = JSON.parse(result.stdout); expect(output.style).toBe('unknown'); }); + + it('handles extremely short text', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Short text summarized.' }], + usage: { input_tokens: 5, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['Hi'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.originalLength).toBe(2); + }); + + it('handles multi-paragraph text', async () => { + const multiPara = 'First paragraph.\\n\\nSecond paragraph.\\n\\nThird paragraph.'; + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Multi-paragraph summary.' }], + usage: { input_tokens: 50, output_tokens: 15 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([multiPara], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles very long target length', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Very long summary with many words repeated to reach target length.'.repeat(50) }], + usage: { input_tokens: 100, output_tokens: 500 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--length', '1000'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.targetWords).toBe(1000); + }); + + it('handles file with special characters', async () => { + const tempFile = join(tmpdir(), `test-summarize-特殊文字-${Date.now()}.txt`); + tempFiles.push(tempFile); + writeFileSync(tempFile, 'Content with special chars: @#$% émojis 🎉'); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Summary of special content.' }], + 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('counts words accurately with whitespace variations', async () => { + const summary = 'Word1 Word2\t\tWord3\nWord4'; + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: summary }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + 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.actualWords).toBe(4); + }); + + it('handles network errors during summarization', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('Connection reset')); + global.fetch = mockFetch; + + const result = await runScript(['text to summarize'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('error'); + }); + + it('handles empty file content', 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: 'File is empty.' }], + 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); + }); + + it('handles malformed JSON in API response', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => { + throw new Error('Malformed JSON'); + }, + }); + 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 negative length value', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '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 zero length value', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '' }], + usage: { input_tokens: 100, output_tokens: 1 }, + }), + }); + 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 API 400 bad request error', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => 'Bad request', + }); + 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('400'); + }); + + it('handles summary with only whitespace', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: ' \n\t ' }], + usage: { input_tokens: 100, output_tokens: 5 }, + }), + }); + 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.actualWords).toBe(0); + }); + + it('handles file with binary content', async () => { + const tempFile = join(tmpdir(), `test-binary-${Date.now()}.bin`); + tempFiles.push(tempFile); + const binaryData = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe]); + writeFileSync(tempFile, binaryData); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Binary content summary.' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([tempFile, '--file'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles combination of all 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', '150', '--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(150); + expect(output.style).toBe('bullets'); + expect(output.model).toBe('claude-3-opus-20240229'); + }); + + it('validates fetch is called with correct parameters', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Summary.' }], + usage: { input_tokens: 100, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + await runScript(['test text', '--length', '200', '--style', 'detailed'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.anthropic.com/v1/messages', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'x-api-key': 'test-key', + 'anthropic-version': '2023-06-01', + }), + }) + ); + }); + + it('handles text with tabs and mixed whitespace', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Summary of mixed whitespace text.' }], + usage: { input_tokens: 50, output_tokens: 15 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text\twith\t\ttabs and spaces'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); }); \ 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..5025aa9 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(); - }); - - 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 runScript = async (args, env = {}) => { + const { main } = require('./vision.js'); + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + process.argv = ['node', 'vision.js', ...args]; - 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,209 @@ describe('vision.js', () => { it('supports WEBP file format', async () => { const tempFile = join(tmpdir(), `test-image-${Date.now()}.webp`); tempFiles.push(tempFile); + 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']]), + }; + }); + + const result = await runScript([tempFile], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('defaults to PNG for unknown extensions', async () => { + const tempFile = join(tmpdir(), `test-image-${Date.now()}.unknown`); + tempFiles.push(tempFile); + writeFileSync(tempFile, Buffer.from('fake image data')); + + 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']]), + }; + }); + + const result = await runScript([tempFile], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles very large images', async () => { + const tempFile = join(tmpdir(), `test-image-${Date.now()}.png`); + tempFiles.push(tempFile); + const largeBuffer = Buffer.alloc(10 * 1024 * 1024); // 10MB + writeFileSync(tempFile, largeBuffer); + + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Large image analyzed.' }], + usage: { input_tokens: 5000, output_tokens: 20 }, + }), + }; + } + }); + + 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.mockImplementation(async (url) => { + if (!url.startsWith('https://api.anthropic.com')) { + throw 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 image URL with redirect', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Redirected 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(['https://example.com/redirect-image.png'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles complex prompts with special characters', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Analysis complete.' }], + 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', 'What\'s this? @#$% émojis 🎉'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + }); + + it('handles .jpeg extension (alternate JPEG extension)', async () => { + const tempFile = join(tmpdir(), `test-image-${Date.now()}.jpeg`); + tempFiles.push(tempFile); + writeFileSync(tempFile, Buffer.from('fake-jpeg-data')); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'JPEG image.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }); + + const result = await runScript([tempFile], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles malformed JSON in API response', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => { + throw new Error('Invalid JSON'); + }, + }; + } + 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); + expect(result.stderr).toContain('error'); + }); - // Create a minimal WEBP file header - const webpData = Buffer.from('524946461400000057454250', 'hex'); - writeFileSync(tempFile, webpData); + it('handles GIF images', async () => { + const tempFile = join(tmpdir(), `test-image-${Date.now()}.gif`); + tempFiles.push(tempFile); + writeFileSync(tempFile, Buffer.from('fake-gif-data')); - const mockFetch = vi.fn().mockResolvedValue({ + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ - content: [{ text: 'WEBP image.' }], + content: [{ text: 'GIF image (treated as PNG).' }], usage: { input_tokens: 1500, output_tokens: 20 }, }), }); - global.fetch = mockFetch; const result = await runScript([tempFile], { ANTHROPIC_API_KEY: 'test-key', @@ -375,19 +575,174 @@ describe('vision.js', () => { expect(result.code).toBe(0); }); - it('defaults to PNG for unknown extensions', async () => { - const tempFile = join(tmpdir(), `test-image-${Date.now()}.unknown`); + it('handles URL with no content-type header', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Image without content type.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map(), + }; + }); + + const result = await runScript(['https://example.com/image'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles empty prompt with default', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Default prompt used.' }], + 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); + }); + + it('handles API 413 payload too large error', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: false, + status: 413, + text: async () => 'Payload too large', + }; + } + 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('413'); + }); + + it('handles file path with spaces', async () => { + const tempFile = join(tmpdir(), `test image ${Date.now()}.png`); tempFiles.push(tempFile); - writeFileSync(tempFile, Buffer.from('fake image data')); + writeFileSync(tempFile, Buffer.from('fake-png-data')); - const mockFetch = vi.fn().mockResolvedValue({ + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ - content: [{ text: 'Unknown format image.' }], + content: [{ text: 'Image with spaces in path.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }); + + const result = await runScript([tempFile], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles URL with port number', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Image from URL with port.' }], + 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:8080/image.jpg'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('validates fetch is called with correct anthropic parameters', 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: 20 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; + }); + + await runScript(['https://example.com/image.jpg', 'What is this?'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + const anthropicCalls = mockFetch.mock.calls.filter(call => + call[0].startsWith('https://api.anthropic.com') + ); + expect(anthropicCalls.length).toBeGreaterThan(0); + expect(anthropicCalls[0][1]).toMatchObject({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'x-api-key': 'test-key', + 'anthropic-version': '2023-06-01', + }), + }); + }); + + it('handles uppercase file extensions', async () => { + const tempFile = join(tmpdir(), `test-image-${Date.now()}.PNG`); + tempFiles.push(tempFile); + writeFileSync(tempFile, Buffer.from('fake-png-data')); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'PNG image.' }], usage: { input_tokens: 1500, output_tokens: 20 }, }), }); - global.fetch = mockFetch; const result = await runScript([tempFile], { ANTHROPIC_API_KEY: 'test-key', diff --git a/skills/cloudflare-browser/scripts/cdp-client.test.js b/skills/cloudflare-browser/scripts/cdp-client.test.js index 799c634..8b95877 100644 --- a/skills/cloudflare-browser/scripts/cdp-client.test.js +++ b/skills/cloudflare-browser/scripts/cdp-client.test.js @@ -1,484 +1,495 @@ 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(); - + class MockWSScreenshot extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Page.captureScreenshot') { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { data: Buffer.from('fake-screenshot').toString('base64') } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSScreenshot }); 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'); - expect(Buffer.isBuffer(screenshot)).toBe(true); + expect(screenshot.toString()).toBe('fake-screenshot'); }); 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(); - + it('evaluate method executes JavaScript', async () => { + class MockWSEvaluate extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate') { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { result: { value: 42 } } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSEvaluate }); const result = await client.evaluate('2 + 2'); - - expect(result).toBeDefined(); + expect(result.result.value).toBe(42); }); 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(); - + 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 content', async () => { + class MockWSGetHTML extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate') { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { result: { value: 'Test' } } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSGetHTML }); 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 MockWSGetText extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate') { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { result: { value: 'Test text content' } } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSGetText }); const text = await client.getText(); - - expect(typeof text).toBe('string'); + expect(text).toBe('Test 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 }); + let closeCalled = false; + client.ws.on('close', () => { closeCalled = true; }); + client.close(); + expect(closeCalled).toBe(true); + }); - expect(() => client.close()).not.toThrow(); + it('handles timeout errors', async () => { + class MockWSTimeout extends EventEmitter { + constructor(url) { + super(); + this.url = url; + this.readyState = 1; + 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 { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSTimeout, timeout: 100 }); + await expect(client.send('Test.method')).rejects.toThrow('Timeout'); }); - it('send method with no params', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles API errors', async () => { + class MockWSError extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + this.emit('message', JSON.stringify({ + id: msg.id, + error: { message: 'API error occurred' } + })); + }, 10); + } + } + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSError }); + await expect(client.navigate('https://example.com')).rejects.toThrow('API error occurred'); + }); - const client = await createClient(); + it('handles WebSocket connection errors', async () => { + class MockWSConnectionError 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: MockWSConnectionError })).rejects.toThrow('Connection failed'); + }); - const result = await client.send('Page.enable'); + it('handles no target created error', async () => { + class MockWSNoTarget extends EventEmitter { + constructor(url) { + super(); + this.url = url; + setTimeout(() => { + this.emit('open'); + }, 10); + } + send() {} + close() {} + } + const { createClient } = require('./cdp-client.js'); + await expect(createClient({ WebSocket: MockWSNoTarget })).rejects.toThrow('No target created'); + }, 15000); - expect(result).toBeDefined(); + it('accepts custom timeout option', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS, timeout: 30000 }); + expect(client).toBeDefined(); }); - it('send method with params', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); + it('accepts custom secret and workerUrl options', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ + WebSocket: MockWS, + secret: 'custom-secret', + workerUrl: 'https://custom-worker.com' + }); + expect(client).toBeDefined(); + }); - const result = await client.send('Page.navigate', { url: 'https://example.com' }); + it('screenshot accepts format parameter', async () => { + class MockWSScreenshotJPEG extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Page.captureScreenshot') { + expect(msg.params.format).toBe('jpeg'); + this.emit('message', JSON.stringify({ + id: msg.id, + result: { data: Buffer.from('fake-jpeg').toString('base64') } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSScreenshotJPEG }); + const screenshot = await client.screenshot('jpeg'); + expect(Buffer.isBuffer(screenshot)).toBe(true); + }); - expect(result).toBeDefined(); + it('setViewport accepts default values', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.setViewport()).resolves.toBeUndefined(); }); - it('handles WORKER_URL with http://', async () => { - process.env.WORKER_URL = 'http://test-worker.example.com'; + it('scroll accepts custom scroll amount', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.scroll(1000)).resolves.toBeUndefined(); + }); - const { createClient } = await import('./cdp-client.js'); + it('handles multiple pending requests simultaneously', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); - const client = await createClient(); + const promises = [ + client.evaluate('1 + 1'), + client.evaluate('2 + 2'), + client.evaluate('3 + 3'), + ]; - expect(client.ws.url).toContain('wss://'); - expect(client.ws.url).not.toContain('http://'); + await expect(Promise.all(promises)).resolves.toBeDefined(); }); - it('handles WORKER_URL with https://', async () => { - process.env.WORKER_URL = 'https://test-worker.example.com'; - - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); + it('navigate accepts zero wait time', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.navigate('https://example.com', 0)).resolves.toBeUndefined(); + }); - expect(client.ws.url).toContain('wss://'); - expect(client.ws.url).not.toContain('https://'); + it('handles screenshot with webp format', async () => { + class MockWSScreenshotWebP extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Page.captureScreenshot') { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { data: Buffer.from('fake-webp').toString('base64') } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSScreenshotWebP }); + const screenshot = await client.screenshot('webp'); + expect(Buffer.isBuffer(screenshot)).toBe(true); }); - it('uses default timeout when not specified', async () => { - const { createClient } = await import('./cdp-client.js'); + it('type method handles special characters', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.type('#input', 'Test @#$% 123')).resolves.toBeUndefined(); + }); - const client = await createClient(); + it('click method handles complex selectors', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.click('div.class > button[type="submit"]')).resolves.toBeUndefined(); + }); - expect(client).toBeDefined(); + it('evaluate returns error results correctly', async () => { + class MockWSEvalError extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate') { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { + exceptionDetails: { text: 'ReferenceError' }, + result: { type: 'undefined' } + } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSEvalError }); + const result = await client.evaluate('unknownVariable'); + expect(result).toHaveProperty('exceptionDetails'); }); - it('navigate with custom wait time', async () => { - const { createClient } = await import('./cdp-client.js'); + it('getHTML handles null or undefined result', async () => { + class MockWSGetHTMLNull extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate') { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { result: { value: null } } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSGetHTMLNull }); + const html = await client.getHTML(); + expect(html).toBeNull(); + }); - const client = await createClient(); + it('getText handles empty body', async () => { + class MockWSGetTextEmpty extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate') { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { result: { value: '' } } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSGetTextEmpty }); + 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('setViewport handles extreme dimensions', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.setViewport(4000, 3000, 3, false)).resolves.toBeUndefined(); + }); - expect(duration).toBeGreaterThanOrEqual(200); + it('scroll handles negative scroll values', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.scroll(-500)).resolves.toBeUndefined(); }); - it('scroll with default distance', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles rapid consecutive screenshot captures', async () => { + class MockWSRapidScreenshot extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Page.captureScreenshot') { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { data: Buffer.from(`screenshot-${msg.id}`).toString('base64') } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWSRapidScreenshot }); - const client = await createClient(); + const screenshots = await Promise.all([ + client.screenshot(), + client.screenshot(), + client.screenshot(), + ]); - await expect(client.scroll()).resolves.toBeUndefined(); + expect(screenshots).toHaveLength(3); + screenshots.forEach(s => expect(Buffer.isBuffer(s)).toBe(true)); }); - it('setViewport with default values', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles workerUrl with protocol prefix', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ + WebSocket: MockWS, + secret: 'custom-secret', + workerUrl: 'https://worker.example.com' + }); + expect(client).toBeDefined(); + }); - const client = await createClient(); + it('close is idempotent', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + client.close(); + client.close(); // Should not throw + expect(true).toBe(true); + }); - await expect(client.setViewport()).resolves.toBeUndefined(); + it('type method handles empty text', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.type('#input', '')).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..8b2b3c7 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,94 @@ describe('screenshot.js', () => { expect(result.stderr).not.toContain('Usage'); }); + + it('handles WORKER_URL with wss:// prefix', async () => { + const result = await runScript(['https://example.com'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://test-worker.com', + }); + + expect(result.code).not.toBe(0); + }); + + it('handles URL with international characters', async () => { + const result = await runScript(['https://例え.jp/ページ'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles output filename with path traversal', async () => { + const result = await runScript(['https://example.com', '../output.png'], { + 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.code).not.toBe(0); + }); + + it('handles output filename with extension mismatch', async () => { + const result = await runScript(['https://example.com', 'output.jpg'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles localhost URLs', async () => { + const result = await runScript(['http://localhost:3000'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles IP address URLs', async () => { + const result = await runScript(['http://192.168.1.1'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles data URLs', async () => { + const result = await runScript(['data:text/html,

Hello

'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles empty CDP_SECRET after being set', async () => { + const result = await runScript(['https://example.com'], { + CDP_SECRET: ' ', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.code).not.toBe(0); + }); + + it('handles output path with ./ prefix', async () => { + const result = await runScript(['https://example.com', './output.png'], { + CDP_SECRET: 'test-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..825db8a 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,149 @@ describe('video.js', () => { expect(result.stderr).not.toContain('Usage'); }); + + it('handles --fps with decimal value', async () => { + const result = await runScript(['https://example.com', '--fps', '10.5'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles --fps with invalid value', async () => { + const result = await runScript(['https://example.com', '--fps', 'invalid'], { + 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 WORKER_URL with wss:// prefix', async () => { + const result = await runScript(['https://example.com'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://test-worker.com', + }); + + expect(result.code).not.toBe(0); + }); + + it('handles single URL with trailing comma', async () => { + const result = await runScript(['https://example.com,'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles URL with fragment identifier', async () => { + const result = await runScript(['https://example.com#section'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles CDP_SECRET with spaces', async () => { + const result = await runScript(['https://example.com'], { + CDP_SECRET: 'test secret value', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.code).not.toBe(0); + }); + + it('handles localhost URLs', async () => { + const result = await runScript(['http://localhost:8080'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles IP address URLs', async () => { + const result = await runScript(['http://192.168.0.1'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles --fps and --scroll together in different order', async () => { + const result = await runScript( + ['https://example.com', '--scroll', '--fps', '25'], + { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + } + ); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles output with custom filename and options', async () => { + const result = await runScript( + ['https://example.com', 'custom-video.mp4', '--fps', '24', '--scroll'], + { + 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 mixed URLs with and without protocols', async () => { + const result = await runScript( + ['https://example.com,example.org,http://test.com'], + { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + } + ); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles output path with spaces', async () => { + const result = await runScript(['https://example.com', 'my video.mp4'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles empty WORKER_URL with whitespace', async () => { + const result = await runScript(['https://example.com'], { + CDP_SECRET: 'test-secret', + WORKER_URL: ' ', + }); + + expect(result.code).not.toBe(0); + }); }); \ No newline at end of file