From 6b4ed4c6a69286cb5e2b033528c50f3ce188fb60 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:00:04 +0000 Subject: [PATCH] CodeRabbit Generated Unit Tests: Add unit tests for PR changes --- skills/ai-tools/scripts/embeddings.test.js | 712 ++++++++------ skills/ai-tools/scripts/extract.test.js | 545 +++++++++-- skills/ai-tools/scripts/sentiment.test.js | 626 ++++++++++-- skills/ai-tools/scripts/summarize.test.js | 751 +++++++++------ skills/ai-tools/scripts/vision.test.js | 844 ++++++++++++---- .../scripts/cdp-client.test.js | 901 +++++++++++------- .../scripts/screenshot.test.js | 283 ++++-- .../cloudflare-browser/scripts/video.test.js | 378 ++++++-- 8 files changed, 3591 insertions(+), 1449 deletions(-) diff --git a/skills/ai-tools/scripts/embeddings.test.js b/skills/ai-tools/scripts/embeddings.test.js index cc7abe9..115b234 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'); - }); - - 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'); + 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('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,351 @@ describe('embeddings.js', () => { expect(result.code).toBe(0); }); + + it('handles very large embeddings correctly', async () => { + const largeEmbedding = Array(3072).fill(0.123); + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: largeEmbedding }], + usage: { total_tokens: 10 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text', '--dimensions', '3072'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.dimensions).toBe(3072); + expect(output.embedding[0]).toBe(0.123); + }); + + it('handles special characters in text input', 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 with "quotes" and \'apostrophes\' & symbols!'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles rate limit errors', async () => { + const mockResponse = { + ok: false, + status: 429, + text: async () => 'Rate limit exceeded', + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('429'); + }); + + it('handles invalid dimensions parameter gracefully', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.1] }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text', '--dimensions', 'invalid'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + // parseInt('invalid') returns NaN, which becomes null when stringified to JSON + expect(output.dimensions).toBe(null); + }); + + it('handles network timeout errors', async () => { + mockFetch.mockImplementation(() => + new Promise((_, reject) => + setTimeout(() => reject(new Error('ETIMEDOUT')), 100) + ) + ); + + const result = await runScript(['test text'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('ETIMEDOUT'); + }); + + it('handles malformed JSON response', async () => { + const mockResponse = { + ok: true, + json: async () => { + throw new Error('Unexpected token in JSON'); + }, + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + }); + + it('saves embeddings to file with --output flag', async () => { + const fs = require('fs'); + const path = require('path'); + const tempFile = path.join('/tmp', `test-embeddings-${Date.now()}.json`); + + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + try { + const result = await runScript(['test text', '--output', tempFile], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output).toHaveProperty('success', true); + expect(output).toHaveProperty('saved', tempFile); + expect(output).toHaveProperty('dimensions'); + expect(output).toHaveProperty('usage'); + + // Verify file was created + expect(fs.existsSync(tempFile)).toBe(true); + const savedData = JSON.parse(fs.readFileSync(tempFile, 'utf-8')); + expect(savedData).toHaveProperty('embedding'); + expect(savedData.embedding).toEqual([0.1, 0.2, 0.3]); + } finally { + // Cleanup + if (fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile); + } + } + }); + + it('handles mixed API key configurations', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.5] }], + usage: { total_tokens: 3 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + // Test with both keys set - should prefer OPENAI_API_KEY + const result = await runScript(['test'], { + OPENAI_API_KEY: 'primary-key', + AI_GATEWAY_API_KEY: 'fallback-key', + }); + + expect(result.code).toBe(0); + expect(mockFetch).toHaveBeenCalled(); + const callArgs = mockFetch.mock.calls[0][1]; + expect(callArgs.headers.Authorization).toContain('primary-key'); + }); + + it('handles zero-dimension embeddings gracefully', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [] }], + usage: { total_tokens: 1 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text', '--dimensions', '0'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + // Empty embedding still gets formatted with ... and total count + expect(output.embedding).toHaveLength(2); + expect(output.embedding[0]).toBe('...'); + }); + + it('handles API response with missing data array', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + }); + + it('preserves exact embedding values without rounding', async () => { + const preciseEmbedding = [0.123456789, -0.987654321, 0.000000001]; + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: preciseEmbedding }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.embedding[0]).toBe(0.123456789); + expect(output.embedding[1]).toBe(-0.987654321); + expect(output.embedding[2]).toBe(0.000000001); + }); + + it('handles concurrent embedding generation', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { total_tokens: 10 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result1Promise = runScript(['text one'], { + OPENAI_API_KEY: 'test-key', + }); + const result2Promise = runScript(['text two'], { + OPENAI_API_KEY: 'test-key', + }); + + const [result1, result2] = await Promise.all([result1Promise, result2Promise]); + + expect(result1.code).toBe(0); + expect(result2.code).toBe(0); + }); + + it('handles API server returning 500 internal error', async () => { + const mockResponse = { + ok: false, + status: 500, + text: async () => 'Internal server error', + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['test text'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('500'); + }); + + it('handles embeddings for text with only special characters', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.5, 0.6] }], + usage: { total_tokens: 3 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await runScript(['!@#$%^&*()_+-={}[]|:;"<>,.?/'], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.embedding).toBeDefined(); + }); + + it('handles output file path with special characters', async () => { + const fs = require('fs'); + const path = require('path'); + const tempFile = path.join('/tmp', `test embeddings [special] (chars) ${Date.now()}.json`); + + const mockResponse = { + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2] }], + usage: { total_tokens: 5 }, + }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + try { + const result = await runScript(['text', '--output', tempFile], { + OPENAI_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + expect(fs.existsSync(tempFile)).toBe(true); + } finally { + if (fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile); + } + } + }); + + 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); + }); }); \ 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..d743f6f 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'); + 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; }); - 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'); - }); - }); - - 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,431 @@ describe('extract.js', () => { expect(output.extracted.person.name).toBe('Alice'); expect(output.extracted.person.address.city).toBe('Boston'); }); + + it('handles extraction with array schemas', async () => { + const arraySchema = { + items: ['string'], + count: 'number', + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [ + { + text: JSON.stringify({ + items: ['apple', 'banana', 'cherry'], + count: 3, + }), + }, + ], + usage: { input_tokens: 15, output_tokens: 25 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['I bought apple, banana, and cherry', '--schema', JSON.stringify(arraySchema)], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.items).toEqual(['apple', 'banana', 'cherry']); + expect(output.extracted.count).toBe(3); + }); + + it('handles multi-word text arguments', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"result": "success"}' }], + usage: { input_tokens: 20, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['This', 'is', 'multiple', 'words', '--schema', '{"result":"string"}'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + }); + + it('handles network timeout errors', async () => { + const mockFetch = vi.fn().mockImplementation(() => + Promise.reject(new Error('Network timeout')) + ); + global.fetch = mockFetch; + + const result = await runScript( + ['text', '--schema', '{"field":"string"}'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(1); + }); + + it('handles rate limit errors', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 429, + text: async () => 'Rate limit exceeded', + }); + global.fetch = mockFetch; + + const result = await runScript( + ['text', '--schema', '{"field":"string"}'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('429'); + }); + + it('handles empty text with schema', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{}' }], + usage: { input_tokens: 5, output_tokens: 5 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['', '--schema', '{"field":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + }); + + it('validates usage tracking', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"name": "test"}' }], + usage: { input_tokens: 150, output_tokens: 75 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['long text', '--schema', '{"name":"string"}'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.usage.input_tokens).toBe(150); + expect(output.usage.output_tokens).toBe(75); + }); + + 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); + const output = JSON.parse(result.stdout); + expect(output.extracted['field-name']).toBe('value'); + }); + + it('extracts JSON from markdown code blocks without language marker', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '```\n{"name": "NoLang", "value": 99}\n```' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['text', '--schema', '{"name":"string","value":"number"}'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.name).toBe('NoLang'); + expect(output.extracted.value).toBe(99); + }); + + it('handles extraction when response has extra whitespace', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '\n\n {"data": "trimmed"} \n\n' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', '{"data":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.data).toBe('trimmed'); + }); + + it('handles extraction with boolean and null values', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [ + { + text: JSON.stringify({ + active: true, + deleted: false, + metadata: null, + }), + }, + ], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['text', '--schema', '{"active":"boolean","deleted":"boolean","metadata":"null"}'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.active).toBe(true); + expect(output.extracted.deleted).toBe(false); + expect(output.extracted.metadata).toBe(null); + }); + + it('handles API response with missing content array', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', '{"field":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + }); + + it('handles very large extracted objects', async () => { + const largeObject = { + items: Array(100) + .fill(null) + .map((_, i) => ({ id: i, name: `Item ${i}` })), + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(largeObject) }], + usage: { input_tokens: 500, output_tokens: 800 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['text with many items', '--schema', '{"items":"array"}'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.items).toHaveLength(100); + expect(output.usage.output_tokens).toBe(800); + }); + + it('handles schema with unicode characters', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"名前": "太郎", "年齢": 25}' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['日本語のテキスト', '--schema', '{"名前":"string","年齢":"number"}'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted['名前']).toBe('太郎'); + expect(output.extracted['年齢']).toBe(25); + }); + + it('handles extraction with empty schema', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{}' }], + usage: { input_tokens: 10, output_tokens: 5 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', '{}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted).toEqual({}); + }); + + it('handles response with multiple JSON code blocks', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '```json\n{"first": "block"}\n```\nSome text\n```json\n{"second": "block"}\n```' }], + usage: { input_tokens: 20, output_tokens: 30 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', '{"field":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + // Should extract the first JSON block + expect(output.extracted).toHaveProperty('first'); + }); + + it('handles extraction with circular reference simulation', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"data": {"nested": {"value": "test"}}}' }], + usage: { input_tokens: 15, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['text', '--schema', '{"data":{"nested":{"value":"string"}}}'], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.data.nested.value).toBe('test'); + }); + + it('handles extraction with very long schema', async () => { + const longSchema = JSON.stringify({ + fields: Array(50) + .fill(null) + .map((_, i) => ({ [`field${i}`]: 'string' })) + .reduce((acc, val) => ({ ...acc, ...val }), {}), + }); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"fields": {}}' }], + usage: { input_tokens: 200, output_tokens: 50 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--schema', longSchema], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles concurrent extraction requests', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '{"result": "success"}' }], + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const promise1 = runScript(['text one', '--schema', '{"result":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + const promise2 = runScript(['text two', '--schema', '{"result":"string"}'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + expect(result1.code).toBe(0); + expect(result2.code).toBe(0); + }); + + it('handles extraction with very nested schema', async () => { + const deepSchema = { + level1: { + level2: { + level3: { + level4: { + level5: 'string', + }, + }, + }, + }, + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [ + { + text: JSON.stringify({ + level1: { + level2: { + level3: { + level4: { + level5: 'deep value', + }, + }, + }, + }, + }), + }, + ], + usage: { input_tokens: 30, output_tokens: 40 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript( + ['nested data', '--schema', JSON.stringify(deepSchema)], + { ANTHROPIC_API_KEY: 'test-key' } + ); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.extracted.level1.level2.level3.level4.level5).toBe('deep value'); + }); }); \ No newline at end of file diff --git a/skills/ai-tools/scripts/sentiment.test.js b/skills/ai-tools/scripts/sentiment.test.js index c3d6841..4dccb41 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'); + const runScript = async (args, env = {}) => { + const { main } = require('./sentiment.js'); + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + process.argv = ['node', 'sentiment.js', ...args]; + + for (const key in env) { + process.env[key] = env[key]; + } + + let stdout = ''; + let stderr = ''; + let exitCode = 0; + const spyLog = vi.spyOn(console, 'log').mockImplementation(m => { stdout += m + '\n'; }); + const spyError = vi.spyOn(console, 'error').mockImplementation(m => { stderr += m + '\n'; }); + const spyExit = vi.spyOn(process, 'exit').mockImplementation((code) => { + exitCode = code; + const err = new Error('process.exit'); + err.code = code; + throw err; }); - }); - - describe('API key validation', () => { - it('fails when no API key is provided', async () => { - delete process.env.ANTHROPIC_API_KEY; - delete process.env.AI_GATEWAY_API_KEY; - - const result = await runScript(['test text']); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('ANTHROPIC_API_KEY or AI_GATEWAY_API_KEY required'); - }); - }); - - describe('edge cases', () => { - it('handles single word input', async () => { - const result = await runScript(['test']); - // May fail without real API but should parse args correctly - expect(result.exitCode === 0 || result.exitCode === 1).toBe(true); - }); - proc.on('close', (code) => { - resolve({ code, stdout, stderr }); - }); - - proc.on('error', (err) => { - reject(err); - }); - }); + try { + await main(); + } catch (err) { + if (err.message !== 'process.exit') { + stderr += err.message; + exitCode = 1; + } + } finally { + process.argv = originalArgv; + for (const key in env) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + spyLog.mockRestore(); + spyError.mockRestore(); + spyExit.mockRestore(); + } + + return { code: exitCode, stdout, stderr }; }; it('shows usage when no text is provided', async () => { @@ -211,7 +185,7 @@ describe('sentiment.js', () => { global.fetch = mockFetch; const result = await runScript( - ['Good day', '--model', 'claude-3-sonnet-20240229'], + ['--model', 'claude-3-sonnet-20240229', 'Good day'], { ANTHROPIC_API_KEY: 'test-key' } ); @@ -371,4 +345,518 @@ describe('sentiment.js', () => { expect(result.code).toBe(0); }); + + it('analyzes sentiment with multiple emotions', async () => { + const mockAnalysis = { + sentiment: 'mixed', + score: 0.1, + confidence: 0.75, + emotions: [ + { emotion: 'excitement', intensity: 0.8 }, + { emotion: 'anxiety', intensity: 0.6 }, + { emotion: 'hope', intensity: 0.7 }, + ], + tone: 'complex', + keywords: ['nervous', 'excited', 'hopeful'], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 15, output_tokens: 30 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['I am excited but nervous about the opportunity'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.emotions.length).toBe(3); + }); + + it('handles very long text input', async () => { + const longText = 'word '.repeat(1000); + 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: 1000, 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).toBeLessThan(longText.length); + }); + + it('handles extreme positive sentiment', async () => { + const mockAnalysis = { + sentiment: 'positive', + score: 1.0, + confidence: 0.95, + emotions: [{ emotion: 'euphoria', intensity: 1.0 }], + tone: 'ecstatic', + keywords: ['amazing', 'incredible', 'perfect'], + }; + + 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(['This is the most amazing thing ever!'], { + 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 negative sentiment', async () => { + const mockAnalysis = { + sentiment: 'negative', + score: -1.0, + confidence: 0.95, + emotions: [{ emotion: 'despair', intensity: 0.9 }], + tone: 'devastating', + keywords: ['horrible', 'worst', 'disaster'], + }; + + 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(['This is absolutely horrible and the worst'], { + 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 network timeout', async () => { + const mockFetch = vi.fn().mockImplementation(() => + Promise.reject(new Error('ETIMEDOUT')) + ); + global.fetch = mockFetch; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + }); + + it('handles sarcastic text', async () => { + const mockAnalysis = { + sentiment: 'negative', + score: -0.6, + confidence: 0.7, + emotions: [{ emotion: 'sarcasm', intensity: 0.8 }], + tone: 'sarcastic', + keywords: ['great', 'wonderful'], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 10, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['Oh great, another bug. Wonderful.'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.sentiment).toBe('negative'); + }); + + it('uses custom base URL when provided', 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; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'test-key', + AI_GATEWAY_BASE_URL: 'https://custom.api.com', + }); + + expect(result.code).toBe(0); + }); + + it('truncates text at exactly 100 characters boundary', async () => { + const text100 = 'a'.repeat(100); + const mockAnalysis = { + sentiment: 'neutral', + score: 0, + confidence: 0.5, + emotions: [], + tone: 'monotonous', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 50, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([text100], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + // Should be exactly 100 chars with no ellipsis + expect(output.text).toBe(text100); + expect(output.text).not.toContain('...'); + }); + + it('truncates text at exactly 101 characters with ellipsis', async () => { + const text101 = 'a'.repeat(101); + const mockAnalysis = { + sentiment: 'neutral', + score: 0, + confidence: 0.5, + emotions: [], + tone: 'monotonous', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 50, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([text101], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + // Should be 103 chars total: 100 + "..." + expect(output.text).toBe('a'.repeat(100) + '...'); + expect(output.text.length).toBe(103); + }); + + it('handles zero confidence score', async () => { + const mockAnalysis = { + sentiment: 'neutral', + score: 0, + confidence: 0, + emotions: [], + tone: 'ambiguous', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 10, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['ambiguous text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.confidence).toBe(0); + }); + + it('handles empty emotions array', async () => { + const mockAnalysis = { + sentiment: 'neutral', + score: 0, + confidence: 0.8, + emotions: [], + tone: 'factual', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 10, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['The data shows results'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.emotions).toEqual([]); + }); + + it('handles API response with missing usage data', 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) }], + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + }); + + it('handles text with only whitespace', async () => { + const mockAnalysis = { + sentiment: 'neutral', + score: 0, + confidence: 0.1, + emotions: [], + tone: 'empty', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 5, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([' \t\n '], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles text with emojis', async () => { + const mockAnalysis = { + sentiment: 'positive', + score: 0.9, + confidence: 0.95, + emotions: [{ emotion: 'joy', intensity: 0.9 }], + tone: 'cheerful', + keywords: ['😊', '🎉', '❤️'], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 10, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['I love this! 😊🎉❤️'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.sentiment).toBe('positive'); + }); + + it('handles ironic positive text', async () => { + const mockAnalysis = { + sentiment: 'negative', + score: -0.5, + confidence: 0.6, + emotions: [{ emotion: 'frustration', intensity: 0.7 }], + tone: 'ironic', + keywords: ['perfect', '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(['Perfect! Just what I needed - more bugs!'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.sentiment).toBe('negative'); + expect(output.analysis.tone).toBe('ironic'); + }); + + it('handles concurrent sentiment analysis requests', async () => { + const mockAnalysis = { + sentiment: 'neutral', + score: 0, + confidence: 0.8, + emotions: [], + tone: 'informative', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 10, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const promise1 = runScript(['text one'], { + ANTHROPIC_API_KEY: 'test-key', + }); + const promise2 = runScript(['text two'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + expect(result1.code).toBe(0); + expect(result2.code).toBe(0); + }); + + it('handles text with mixed languages', async () => { + const mockAnalysis = { + sentiment: 'positive', + score: 0.6, + confidence: 0.75, + emotions: [{ emotion: 'joy', intensity: 0.6 }], + tone: 'cheerful', + keywords: ['good', 'bien', 'gut'], + }; + + 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 good, c\'est bien, das ist gut'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.sentiment).toBe('positive'); + }); + + it('handles text with only punctuation', async () => { + const mockAnalysis = { + sentiment: 'neutral', + score: 0, + confidence: 0.1, + emotions: [], + tone: 'ambiguous', + keywords: [], + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: JSON.stringify(mockAnalysis) }], + usage: { input_tokens: 5, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['!?!?!...!!!'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles API returning 503 service unavailable', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + text: async () => 'Service temporarily unavailable', + }); + global.fetch = mockFetch; + + const result = await runScript(['some text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('503'); + }); }); \ 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..265421c 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); - }); - - 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 runScript = async (args, env = {}) => { + const { main } = require('./summarize.js'); + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + process.argv = ['node', 'summarize.js', ...args]; - 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,408 @@ describe('summarize.js', () => { const output = JSON.parse(result.stdout); expect(output.style).toBe('unknown'); }); + + it('handles very short text input', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Hi' }], + usage: { input_tokens: 5, output_tokens: 2 }, + }), + }); + 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-word text arguments', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Summary of multiple words.' }], + usage: { input_tokens: 20, output_tokens: 15 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['This', 'is', 'multiple', 'words'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles very large length parameter', async () => { + const longSummary = 'word '.repeat(500); + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: longSummary }], + usage: { input_tokens: 100, output_tokens: 500 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--length', '10000'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.targetWords).toBe(10000); + }); + + it('handles network errors', async () => { + const mockFetch = vi.fn().mockImplementation(() => + Promise.reject(new Error('Connection reset')) + ); + global.fetch = mockFetch; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + }); + + it('handles rate limit errors', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 429, + text: async () => 'Too many requests', + }); + 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('429'); + }); + + it('handles file with special characters', async () => { + const tempFile = join(tmpdir(), `test-summarize-special-${Date.now()}.txt`); + tempFiles.push(tempFile); + const content = 'Text with "quotes" and \'apostrophes\' & symbols!'; + writeFileSync(tempFile, content); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Summary of special text.' }], + usage: { input_tokens: 50, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([tempFile, '--file'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('tracks token usage accurately', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Summary.' }], + usage: { input_tokens: 250, output_tokens: 50 }, + }), + }); + 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.usage.input_tokens).toBe(250); + expect(output.usage.output_tokens).toBe(50); + }); + + it('handles empty summary response', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: ' ' }], + usage: { input_tokens: 10, output_tokens: 1 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.summary).toBe(''); + expect(output.actualWords).toBe(0); + }); + + it('handles combination of style and length parameters', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '- Point one\n- Point two\n- Point three' }], + usage: { input_tokens: 100, output_tokens: 30 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--style', 'bullets', '--length', '50'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.style).toBe('bullets'); + expect(output.targetWords).toBe(50); + }); + + it('calculates actualWords correctly for single word', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Summary' }], + usage: { input_tokens: 10, 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(1); + }); + + it('calculates actualWords correctly with multiple spaces', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Word1 Word2 Word3' }], + 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(3); + }); + + it('handles file with unicode content', async () => { + const tempFile = join(tmpdir(), `test-summarize-unicode-${Date.now()}.txt`); + tempFiles.push(tempFile); + const content = '日本語のテキスト。これは要約のテストです。'; + writeFileSync(tempFile, content); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '日本語の要約' }], + usage: { input_tokens: 50, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([tempFile, '--file'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.summary).toBe('日本語の要約'); + }); + + it('handles file path with spaces', async () => { + const tempFile = join(tmpdir(), `test summarize with spaces ${Date.now()}.txt`); + tempFiles.push(tempFile); + writeFileSync(tempFile, 'Content to summarize'); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Summary' }], + usage: { input_tokens: 20, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([tempFile, '--file'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles summary with newlines and special characters', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Line 1\nLine 2\nLine 3\t\tTabbed' }], + usage: { input_tokens: 100, output_tokens: 30 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.summary).toContain('\n'); + // Split by whitespace: "Line", "1", "Line", "2", "Line", "3", "Tabbed" = 7 words + expect(output.actualWords).toBe(7); + }); + + it('handles zero-length target gracefully', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Minimal' }], + usage: { input_tokens: 50, output_tokens: 5 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--length', '0'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.targetWords).toBe(0); + }); + + it('handles API response with missing content array', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + usage: { input_tokens: 10, output_tokens: 10 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + }); + + it('correctly tracks originalLength for multi-line text', async () => { + const multiLineText = 'Line 1\nLine 2\nLine 3'; + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Summary' }], + usage: { input_tokens: 20, output_tokens: 5 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript([multiLineText], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.originalLength).toBe(multiLineText.length); + }); + + it('handles concurrent summarization requests', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Summary text' }], + usage: { input_tokens: 50, output_tokens: 20 }, + }), + }); + global.fetch = mockFetch; + + const promise1 = runScript(['text one'], { + ANTHROPIC_API_KEY: 'test-key', + }); + const promise2 = runScript(['text two'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + expect(result1.code).toBe(0); + expect(result2.code).toBe(0); + }); + + it('handles text with code blocks', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Code summary' }], + usage: { input_tokens: 100, output_tokens: 30 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['function test() { return true; }'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles text with markdown formatting', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: '**Bold summary**' }], + usage: { input_tokens: 80, output_tokens: 25 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['# Title\n\n**Bold** and *italic* text'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles invalid length parameter gracefully', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Summary' }], + usage: { input_tokens: 10, output_tokens: 5 }, + }), + }); + global.fetch = mockFetch; + + const result = await runScript(['text', '--length', 'not-a-number'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + // parseInt('not-a-number') returns NaN, which JSON.stringify converts to null + expect(output.targetWords).toBe(null); + }); }); \ 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..062849b 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,60 +336,533 @@ describe('vision.js', () => { }); it('uses custom base URL when provided', async () => { - const mockFetch = vi.fn().mockResolvedValue({ + 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']]), + }; + }); + + const result = await runScript(['https://example.com/image.jpg'], { + ANTHROPIC_API_KEY: 'test-key', + AI_GATEWAY_BASE_URL: 'https://custom.api.com', + }); + + expect(result.code).toBe(0); + }); + + 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-large-${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 analysis.' }], + usage: { input_tokens: 5000, output_tokens: 50 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; + }); + + const result = await runScript([tempFile, 'Analyze this'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles network errors when fetching URL', async () => { + mockFetch.mockImplementation(async (url) => { + if (!url.startsWith('https://api.anthropic.com')) { + throw new Error('Failed to fetch image'); + } + return { + ok: true, + json: async () => ({ + content: [{ text: 'Analysis.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + }); + + const result = await runScript(['https://example.com/bad-image.jpg'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + }); + + it('handles content-type header from URL fetch', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Image with custom content type.' }], + usage: { input_tokens: 1500, output_tokens: 30 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/webp']]), + }; + }); + + const result = await runScript(['https://example.com/image.webp'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles rate limit errors', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: false, + status: 429, + text: async () => 'Rate limit exceeded', + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; + }); + + const result = await runScript(['https://example.com/image.jpg'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('429'); + }); + + it('handles very long custom prompts', async () => { + const longPrompt = 'Describe '.repeat(100) + 'this image'; + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Detailed analysis based on long prompt.' }], + usage: { input_tokens: 2000, output_tokens: 100 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; + }); + + const result = await runScript(['https://example.com/image.jpg', ...longPrompt.split(' ')], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles image URL without 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 paths with spaces', async () => { + const tempFile = join(tmpdir(), `test image with spaces ${Date.now()}.png`); + tempFiles.push(tempFile); + writeFileSync(tempFile, Buffer.from('image data')); + + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ - content: [{ text: 'Image analysis.' }], + content: [{ text: 'Image from path with spaces.' }], usage: { input_tokens: 1500, output_tokens: 20 }, }), }); - global.fetch = mockFetch; + + const result = await runScript([tempFile], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('tracks token usage for vision requests', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Analysis.' }], + usage: { input_tokens: 3500, output_tokens: 150 }, + }), + }; + } + 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', - AI_GATEWAY_BASE_URL: 'https://custom.api.com', }); expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.usage.input_tokens).toBe(3500); + expect(output.usage.output_tokens).toBe(150); }); - it('supports WEBP file format', async () => { + it('handles webp image format from file', async () => { const tempFile = join(tmpdir(), `test-image-${Date.now()}.webp`); + writeFileSync(tempFile, 'dummy webp data'); tempFiles.push(tempFile); - // Create a minimal WEBP file header - const webpData = Buffer.from('524946461400000057454250', 'hex'); - writeFileSync(tempFile, webpData); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'WebP image analysis.' }], + usage: { input_tokens: 1500, output_tokens: 30 }, + }), + }); - const mockFetch = vi.fn().mockResolvedValue({ + const result = await runScript([tempFile], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis).toContain('WebP'); + }); + + it('handles webp image from URL with content-type header', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'WebP from URL.' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/webp']]), + }; + }); + + const result = await runScript(['https://example.com/image.webp'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles URL without content-type header', 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(), + }; + }); + + const result = await runScript(['https://example.com/no-header.png'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles very large image analysis response', async () => { + const longAnalysis = 'word '.repeat(500).trim(); + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: longAnalysis }], + usage: { input_tokens: 5000, output_tokens: 500 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/png']]), + }; + }); + + const result = await runScript(['https://example.com/complex.png'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.analysis.length).toBeGreaterThan(1000); + }); + + it('handles API response with missing content array', async () => { + const tempFile = join(tmpdir(), `test-image-${Date.now()}.png`); + writeFileSync(tempFile, 'dummy'); + tempFiles.push(tempFile); + + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ - content: [{ text: 'WEBP image.' }], - usage: { input_tokens: 1500, output_tokens: 20 }, + usage: { input_tokens: 1500, output_tokens: 50 }, }), }); - global.fetch = mockFetch; const result = await runScript([tempFile], { ANTHROPIC_API_KEY: 'test-key', }); + expect(result.code).toBe(1); + }); + + it('handles multi-word prompt with special characters', async () => { + const tempFile = join(tmpdir(), `test-image-${Date.now()}.jpg`); + writeFileSync(tempFile, 'dummy'); + tempFiles.push(tempFile); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'Specific analysis.' }], + usage: { input_tokens: 1500, output_tokens: 30 }, + }), + }); + + const result = await runScript( + [tempFile, 'What', 'colors', 'are', 'in', 'this?'], + { 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`); + it('handles custom detail parameter', async () => { + const tempFile = join(tmpdir(), `test-image-${Date.now()}.png`); + writeFileSync(tempFile, 'dummy'); tempFiles.push(tempFile); - writeFileSync(tempFile, Buffer.from('fake image data')); - const mockFetch = vi.fn().mockResolvedValue({ + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'High detail analysis.' }], + usage: { input_tokens: 2000, output_tokens: 100 }, + }), + }); + + const result = await runScript([tempFile, '--detail', 'high'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles image URL fetch error', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('http://broken')) { + throw new Error('Failed to fetch image'); + } + return { + ok: true, + json: async () => ({ + content: [{ text: 'Analysis' }], + usage: { input_tokens: 1500, output_tokens: 20 }, + }), + }; + }); + + const result = await runScript(['http://broken.com/image.png'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(1); + }); + + it('handles image analysis with custom detail parameter low', async () => { + const tempFile = join(tmpdir(), `test-image-${Date.now()}.png`); + writeFileSync(tempFile, 'dummy'); + tempFiles.push(tempFile); + + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ - content: [{ text: 'Unknown format image.' }], + content: [{ text: 'Low detail analysis.' }], + usage: { input_tokens: 1000, output_tokens: 20 }, + }), + }); + + const result = await runScript([tempFile, '--detail', 'low'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + expect(result.code).toBe(0); + }); + + it('handles concurrent image analysis requests', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: true, + json: async () => ({ + content: [{ text: 'Analysis' }], + usage: { input_tokens: 1500, output_tokens: 30 }, + }), + }; + } + return { + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + headers: new Map([['content-type', 'image/jpeg']]), + }; + }); + + const promise1 = runScript(['https://example.com/image1.jpg'], { + ANTHROPIC_API_KEY: 'test-key', + }); + const promise2 = runScript(['https://example.com/image2.jpg'], { + ANTHROPIC_API_KEY: 'test-key', + }); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + expect(result1.code).toBe(0); + expect(result2.code).toBe(0); + }); + + it('handles image with .jpeg extension (uppercase)', async () => { + const tempFile = join(tmpdir(), `test-image-${Date.now()}.JPEG`); + writeFileSync(tempFile, Buffer.from('fake-jpeg-data')); + tempFiles.push(tempFile); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + content: [{ text: 'JPEG uppercase image.' }], usage: { input_tokens: 1500, output_tokens: 20 }, }), }); - global.fetch = mockFetch; const result = await runScript([tempFile], { ANTHROPIC_API_KEY: 'test-key', @@ -395,4 +870,29 @@ describe('vision.js', () => { expect(result.code).toBe(0); }); + + it('handles API returning 401 unauthorized', async () => { + mockFetch.mockImplementation(async (url) => { + if (url.startsWith('https://api.anthropic.com')) { + return { + ok: false, + status: 401, + text: async () => 'Unauthorized - Invalid API key', + }; + } + 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: 'invalid-key', + }); + + expect(result.code).toBe(1); + const error = JSON.parse(result.stderr); + expect(error.error).toContain('401'); + }); }); \ No newline at end of file diff --git a/skills/cloudflare-browser/scripts/cdp-client.test.js b/skills/cloudflare-browser/scripts/cdp-client.test.js index 799c634..072920c 100644 --- a/skills/cloudflare-browser/scripts/cdp-client.test.js +++ b/skills/cloudflare-browser/scripts/cdp-client.test.js @@ -1,484 +1,695 @@ 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 } = require('./cdp-client.js'); + expect(() => createClient()).toThrow('CDP_SECRET'); + }); - const { createClient } = await import('./cdp-client.js'); + it('creates client successfully', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + expect(client).toBeDefined(); + expect(client.targetId).toBe('mock-id'); + }); - await expect(createClient()).rejects.toThrow('CDP_SECRET'); + it('navigate method works', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + await expect(client.navigate('https://example.com', 10)).resolves.toBeUndefined(); }); - it('creates client successfully', async () => { - const { createClient } = await import('./cdp-client.js'); + it('screenshot method returns buffer', async () => { + class ScreenshotMockWS 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('test-image').toString('base64') } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } + + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: ScreenshotMockWS }); + const screenshot = await client.screenshot(); + expect(Buffer.isBuffer(screenshot)).toBe(true); + expect(screenshot.toString()).toBe('test-image'); + }); - const client = await createClient(); + it('screenshot accepts format parameter', async () => { + class FormatMockWS 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: 'base64data' } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } - 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'); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: FormatMockWS }); + await client.screenshot('jpeg'); }); - it('accepts custom options', async () => { - const { createClient } = await import('./cdp-client.js'); + it('setViewport method works', async () => { + let capturedParams = null; + class ViewportMockWS extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Emulation.setDeviceMetricsOverride') { + capturedParams = msg.params; + } + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + }, 10); + } + } - 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: ViewportMockWS }); + await client.setViewport(1920, 1080, 2, true); - expect(client).toBeDefined(); + expect(capturedParams).toBeDefined(); + expect(capturedParams.width).toBe(1920); + expect(capturedParams.height).toBe(1080); + expect(capturedParams.deviceScaleFactor).toBe(2); + expect(capturedParams.mobile).toBe(true); }); - it('creates WebSocket with correct URL', async () => { - const { createClient } = await import('./cdp-client.js'); + it('setViewport uses default values', async () => { + let capturedParams = null; + class ViewportMockWS extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Emulation.setDeviceMetricsOverride') { + capturedParams = msg.params; + } + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + }, 10); + } + } - const client = await createClient(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: ViewportMockWS }); + await client.setViewport(); - 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='); + expect(capturedParams.width).toBe(1280); + expect(capturedParams.height).toBe(800); + expect(capturedParams.deviceScaleFactor).toBe(1); + expect(capturedParams.mobile).toBe(false); }); - it('has valid targetId after connection', async () => { - const { createClient } = await import('./cdp-client.js'); - - const client = await createClient(); + it('evaluate method works', async () => { + class EvalMockWS 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: 'evaluated' } } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } - expect(client.targetId).toBe('mock-target-id-123'); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: EvalMockWS }); + const result = await client.evaluate('1 + 1'); + expect(result).toBeDefined(); }); - it('navigate method works', async () => { - const { createClient } = await import('./cdp-client.js'); + it('scroll method works', async () => { + let scrollExpression = null; + class ScrollMockWS extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate') { + scrollExpression = msg.params.expression; + } + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + }, 10); + } + } - const client = await createClient(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: ScrollMockWS }); + await client.scroll(500); - await expect(client.navigate('https://example.com', 100)).resolves.toBeUndefined(); + expect(scrollExpression).toContain('window.scrollBy(0, 500)'); }); - it('screenshot method returns buffer', async () => { - const { createClient } = await import('./cdp-client.js'); + it('scroll uses default value', async () => { + let scrollExpression = null; + class ScrollMockWS extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate') { + scrollExpression = msg.params.expression; + } + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + }, 10); + } + } - const client = await createClient(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: ScrollMockWS }); + await client.scroll(); - const screenshot = await client.screenshot(); + expect(scrollExpression).toContain('window.scrollBy(0, 300)'); + }); - expect(Buffer.isBuffer(screenshot)).toBe(true); + it('click method works', async () => { + let clickExpression = null; + class ClickMockWS extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate') { + clickExpression = msg.params.expression; + } + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + }, 10); + } + } + + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: ClickMockWS }); + await client.click('#button'); + + expect(clickExpression).toContain('#button'); + expect(clickExpression).toContain('click()'); }); - it('screenshot accepts format parameter', async () => { - const { createClient } = await import('./cdp-client.js'); + it('type method works', async () => { + let typeExpression = null; + class TypeMockWS extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate') { + typeExpression = msg.params.expression; + } + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + }, 10); + } + } - const client = await createClient(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: TypeMockWS }); + await client.type('#input', 'test text'); - const screenshot = await client.screenshot('jpeg'); + expect(typeExpression).toContain('#input'); + expect(typeExpression).toContain('test text'); + }); - expect(Buffer.isBuffer(screenshot)).toBe(true); + it('getHTML method returns HTML', async () => { + class HTMLMockWS extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate' && msg.params.expression.includes('outerHTML')) { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { result: { value: 'Test' } } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } + + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: HTMLMockWS }); + const html = await client.getHTML(); + expect(html).toBe('Test'); }); - it('setViewport method works', async () => { - const { createClient } = await import('./cdp-client.js'); + it('getText method returns text', async () => { + class TextMockWS extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate' && msg.params.expression.includes('innerText')) { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { result: { value: 'Page text content' } } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } - const client = await createClient(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: TextMockWS }); + const text = await client.getText(); + expect(text).toBe('Page text content'); + }); - await expect(client.setViewport(1920, 1080, 2, true)).resolves.toBeUndefined(); + it('close method closes WebSocket', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + const closeSpy = vi.spyOn(client.ws, 'close'); + client.close(); + expect(closeSpy).toHaveBeenCalled(); }); - it('evaluate method works', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles WebSocket errors', async () => { + class ErrorMockWS extends EventEmitter { + constructor(url) { + super(); + this.url = url; + this.readyState = 1; + setTimeout(() => { + this.emit('error', new Error('Connection failed')); + }, 10); + } + send() {} + close() {} + } - const client = await createClient(); + const { createClient } = require('./cdp-client.js'); + await expect(createClient({ WebSocket: ErrorMockWS })).rejects.toThrow('Connection failed'); + }); + + it('handles timeout for target creation', async () => { + class NoTargetMockWS extends EventEmitter { + constructor(url) { + super(); + this.url = url; + this.readyState = 1; + setTimeout(() => { + this.emit('open'); + // Don't emit target created + }, 10); + } + send() {} + close() {} + } - const result = await client.evaluate('2 + 2'); + const { createClient } = require('./cdp-client.js'); + await expect(createClient({ WebSocket: NoTargetMockWS })).rejects.toThrow('No target created'); + }, 15000); + + it('handles API error responses', async () => { + class ErrorResponseMockWS extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + this.emit('message', JSON.stringify({ + id: msg.id, + error: { message: 'CDP error: Invalid method' } + })); + }, 10); + } + } - expect(result).toBeDefined(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: ErrorResponseMockWS }); + await expect(client.navigate('https://example.com')).rejects.toThrow('CDP error: Invalid method'); }); - it('scroll method works', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles timeout for CDP commands', async () => { + class TimeoutMockWS extends MockWS { + send(data) { + // Don't respond to simulate timeout + } + } - const client = await createClient(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ + WebSocket: TimeoutMockWS, + timeout: 100 + }); + await expect(client.navigate('https://example.com')).rejects.toThrow('Timeout'); + }); - await expect(client.scroll(500)).resolves.toBeUndefined(); + it('accepts custom secret via options', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ + WebSocket: MockWS, + secret: 'custom-secret' + }); + expect(client.ws.url).toContain('custom-secret'); }); - it('click method works', async () => { - const { createClient } = await import('./cdp-client.js'); + it('accepts custom workerUrl via options', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ + WebSocket: MockWS, + workerUrl: 'https://custom-worker.com' + }); + expect(client.ws.url).toContain('custom-worker.com'); + }); - const client = await createClient(); + it('strips protocol from workerUrl', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ + WebSocket: MockWS, + workerUrl: 'https://test-worker.com' + }); + expect(client.ws.url).toContain('wss://test-worker.com'); + expect(client.ws.url).not.toContain('https://https://'); + }); - await expect(client.click('#button')).resolves.toBeUndefined(); + it('provides access to targetId', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + expect(client.targetId).toBe('mock-id'); }); - it('type method works', async () => { - const { createClient } = await import('./cdp-client.js'); + it('provides access to ws object', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + expect(client.ws).toBeDefined(); + expect(client.ws.url).toContain('wss://'); + }); + + it('handles concurrent commands', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); - const client = await createClient(); + const promises = [ + client.navigate('https://example.com', 10), + client.setViewport(800, 600), + client.scroll(100) + ]; - await expect(client.type('#input', 'test text')).resolves.toBeUndefined(); + await expect(Promise.all(promises)).resolves.toBeDefined(); }); - it('getHTML method returns string', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles getHTML method', async () => { + class HTMLMockWS extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate' && msg.params.expression.includes('outerHTML')) { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { result: { value: 'test' } } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } - const client = await createClient(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: HTMLMockWS }); 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'); + it('handles getText method', async () => { + class TextMockWS extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate' && msg.params.expression.includes('innerText')) { + this.emit('message', JSON.stringify({ + id: msg.id, + result: { result: { value: 'test text content' } } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } - const client = await createClient(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: TextMockWS }); const text = await client.getText(); + expect(text).toBe('test text content'); + }); + + it('handles type method with special characters', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); - expect(typeof text).toBe('string'); + await expect(client.type('#input', "Text with 'quotes' and \"double\"")).resolves.toBeUndefined(); }); - it('close method works', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles click on non-existent selector', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); - const client = await createClient(); + await expect(client.click('#nonexistent')).resolves.toBeUndefined(); + }); - expect(() => client.close()).not.toThrow(); + it('handles screenshot with jpeg format', async () => { + class JPEGMockWS 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('test-jpeg-image').toString('base64') } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } + + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: JPEGMockWS }); + + const screenshot = await client.screenshot('jpeg'); + expect(screenshot).toBeInstanceOf(Buffer); }); - it('send method with no params', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles multiple consecutive scrolls', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); - const client = await createClient(); + await client.scroll(100); + await client.scroll(200); + await client.scroll(300); - const result = await client.send('Page.enable'); + // Should complete without errors + }); - expect(result).toBeDefined(); + it('handles very large viewport dimensions', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + + await expect(client.setViewport(10000, 10000, 3, false)).resolves.toBeUndefined(); }); - it('send method with params', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles zero wait time navigation', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); - const client = await createClient(); + await expect(client.navigate('https://fast.com', 0)).resolves.toBeUndefined(); + }); - const result = await client.send('Page.navigate', { url: 'https://example.com' }); + it('handles mobile viewport emulation', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); - expect(result).toBeDefined(); + await expect(client.setViewport(375, 667, 2, true)).resolves.toBeUndefined(); }); - it('handles WORKER_URL with http://', async () => { - process.env.WORKER_URL = 'http://test-worker.example.com'; + it('handles evaluate with complex expression', async () => { + class EvalMockWS 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: { type: 'object', value: { test: true, value: 42 } } } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } - const { createClient } = await import('./cdp-client.js'); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: EvalMockWS }); - const client = await createClient(); + const result = await client.evaluate('(() => { return { test: true, value: 42 }; })()'); + expect(result).toHaveProperty('result'); + expect(result.result.value).toEqual({ test: true, value: 42 }); + }); - expect(client.ws.url).toContain('wss://'); - expect(client.ws.url).not.toContain('http://'); + it('handles custom timeout option', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ + WebSocket: MockWS, + timeout: 30000 + }); + + expect(client).toBeDefined(); }); - it('handles WORKER_URL with https://', async () => { - process.env.WORKER_URL = 'https://test-worker.example.com'; + it('encodes CDP_SECRET in WebSocket URL', async () => { + process.env.CDP_SECRET = 'test&secret=value'; + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); - const { createClient } = await import('./cdp-client.js'); + expect(client.ws.url).toContain(encodeURIComponent('test&secret=value')); + }); - const client = await createClient(); + it('handles type with empty string', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); - expect(client.ws.url).toContain('wss://'); - expect(client.ws.url).not.toContain('https://'); + await expect(client.type('#input', '')).resolves.toBeUndefined(); }); - it('uses default timeout when not specified', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles navigation to data URLs', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); - const client = await createClient(); + await expect(client.navigate('data:text/html,

Test

')).resolves.toBeUndefined(); + }); - expect(client).toBeDefined(); + it('handles close method', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); + + // Should close without throwing + expect(() => client.close()).not.toThrow(); }); - it('navigate with custom wait time', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles send method with empty params', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); - const client = await createClient(); + await expect(client.send('Page.reload')).resolves.toBeDefined(); + }); - const start = Date.now(); - await client.navigate('https://example.com', 200); - const duration = Date.now() - start; + it('handles viewport with zero dimensions', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); - expect(duration).toBeGreaterThanOrEqual(200); + await expect(client.setViewport(0, 0, 1, false)).resolves.toBeUndefined(); }); - it('scroll with default distance', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles negative scroll values', async () => { + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: MockWS }); - const client = await createClient(); + await expect(client.scroll(-500)).resolves.toBeUndefined(); + }); - await expect(client.scroll()).resolves.toBeUndefined(); + it('handles screenshot with webp format', async () => { + class WebPMockWS 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('test-webp-image').toString('base64') } + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } + + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: WebPMockWS }); + + const screenshot = await client.screenshot('webp'); + expect(screenshot).toBeInstanceOf(Buffer); }); - it('setViewport with default values', async () => { - const { createClient } = await import('./cdp-client.js'); + it('handles getHTML when result is undefined', async () => { + class NoResultMockWS extends MockWS { + send(data) { + const msg = JSON.parse(data); + setTimeout(() => { + if (msg.method === 'Runtime.evaluate' && msg.params.expression.includes('outerHTML')) { + this.emit('message', JSON.stringify({ + id: msg.id, + result: {} + })); + } else { + this.emit('message', JSON.stringify({ id: msg.id, result: {} })); + } + }, 10); + } + } - const client = await createClient(); + const { createClient } = require('./cdp-client.js'); + const client = await createClient({ WebSocket: NoResultMockWS }); - await expect(client.setViewport()).resolves.toBeUndefined(); + const html = await client.getHTML(); + expect(html).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..0916a79 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,205 @@ describe('screenshot.js', () => { expect(result.stderr).not.toContain('Usage'); }); + + it('handles URLs with fragments', async () => { + const result = await runScript(['https://example.com/page#section'], { + 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 file extensions in output filename', async () => { + const result = await runScript(['https://example.com', 'my-screenshot.png'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles output filename without extension', async () => { + const result = await runScript(['https://example.com', 'screenshot'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles subdomain URLs', async () => { + const result = await runScript(['https://sub.domain.example.com'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles URLs with ports', async () => { + const result = await runScript(['https://example.com:8080'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles URLs with encoded characters', async () => { + const result = await runScript(['https://example.com/path%20with%20spaces'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles output path with directory that needs creation', async () => { + const result = await runScript(['https://example.com', 'output/nested/screenshot.png'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles absolute output paths', async () => { + const result = await runScript(['https://example.com', '/tmp/absolute-screenshot.png'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles URLs with query parameters', async () => { + const result = await runScript(['https://example.com?param=value&other=test'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles URLs with hash fragments', 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 IP addresses as URLs', async () => { + const result = await runScript(['https://192.168.1.1'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles 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 file:// protocol URLs', async () => { + const result = await runScript(['file:///path/to/file.html'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('shows error when CDP_SECRET is empty string', async () => { + const result = await runScript(['https://example.com'], { + CDP_SECRET: '', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('CDP_SECRET'); + }); + + it('handles WORKER_URL with trailing slash', 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 very complex query strings in URLs', async () => { + const complexQuery = 'https://example.com/?' + Array(50) + .fill(0) + .map((_, i) => `param${i}=value${i}`) + .join('&'); + + const result = await runScript([complexQuery], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles multiple consecutive URL arguments (uses first)', async () => { + const result = await runScript( + ['https://first.com', 'output.png', 'https://second.com'], + { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + } + ); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles output path that needs directory creation', async () => { + const result = await runScript( + ['https://example.com', 'new/nested/dir/screenshot.png'], + { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + } + ); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles WORKER_URL with multiple protocol prefixes', async () => { + const result = await runScript(['https://example.com'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'https://https://test-worker.com', + }); + + 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..11fa478 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,300 @@ describe('video.js', () => { 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 URLs with fragments', 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 mixed http and https URLs', async () => { + const result = await runScript(['https://secure.com,http://insecure.com'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles custom output with --fps and --scroll', async () => { + const result = await runScript( + ['https://example.com', 'custom.mp4', '--fps', '25', '--scroll'], + { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + } + ); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles zero FPS value', async () => { + const result = await runScript(['https://example.com', '--fps', '0'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles negative FPS value', async () => { + const result = await runScript(['https://example.com', '--fps', '-5'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles output filename without mp4 extension', async () => { + const result = await runScript(['https://example.com', 'myvideo'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles long list of URLs', async () => { + const urls = Array(20) + .fill(0) + .map((_, i) => `https://example${i}.com`) + .join(','); + + const result = await runScript([urls], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles URLs with special characters in path', async () => { + const result = await runScript(['https://example.com/path%20with%20spaces'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles empty string in comma-separated list', async () => { + const result = await runScript(['https://example.com,,https://test.com'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles single URL without comma', async () => { + const result = await runScript(['https://single-url.com'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles combination of all options', async () => { + const result = await runScript( + ['https://a.com,https://b.com', 'custom.mp4', '--fps', '15', '--scroll'], + { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + } + ); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles URLs with ports', async () => { + const result = await runScript(['https://example.com:8080,http://test.com:3000'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles URLs with query parameters', async () => { + const result = await runScript(['https://example.com?a=1,https://test.com?b=2'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles scroll flag without fps flag', async () => { + const result = await runScript(['https://example.com', '--scroll'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles fps flag without scroll flag', async () => { + const result = await runScript(['https://example.com', '--fps', '30'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles output path with subdirectories', async () => { + const result = await runScript(['https://example.com', 'videos/output/video.mp4'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles absolute output path', async () => { + const result = await runScript(['https://example.com', '/tmp/absolute-video.mp4'], { + 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:8080,http://127.0.0.1:3000'], { + 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,

Test

'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles URLs with whitespace that gets trimmed', async () => { + const result = await runScript([' https://example.com , https://test.com '], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles maximum fps value', async () => { + const result = await runScript(['https://example.com', '--fps', '120'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles non-numeric fps value', async () => { + const result = await runScript(['https://example.com', '--fps', 'invalid'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('shows error when CDP_SECRET is not set', async () => { + const result = await runScript(['https://example.com'], { + CDP_SECRET: '', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('CDP_SECRET'); + }); + + it('handles very long comma-separated URL list', async () => { + const urls = Array(100) + .fill(0) + .map((_, i) => `https://site${i}.com`) + .join(','); + + const result = await runScript([urls], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles both --scroll and --fps in different order', async () => { + const result = await runScript( + ['https://example.com', '--scroll', '--fps', '20'], + { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + } + ); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles complex URLs with auth, port, and query', async () => { + const result = await runScript( + ['https://user:pass@example.com:8443/path?query=value#hash'], + { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + } + ); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles output filename with multiple extensions', async () => { + const result = await runScript(['https://example.com', 'video.backup.mp4'], { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + }); + + expect(result.stderr).not.toContain('Usage'); + }); + + it('handles URLs with authentication credentials in multiple URLs', async () => { + const result = await runScript( + ['https://user:pass@example.com,https://user2:pass2@test.com'], + { + CDP_SECRET: 'test-secret', + WORKER_URL: 'wss://invalid.test', + } + ); + + expect(result.stderr).not.toContain('Usage'); + }); }); \ No newline at end of file