diff --git a/sim/blocks/blocks/typeform.ts b/sim/blocks/blocks/typeform.ts new file mode 100644 index 0000000000..fd3d156626 --- /dev/null +++ b/sim/blocks/blocks/typeform.ts @@ -0,0 +1,233 @@ +import { TypeformIcon } from '@/components/icons' +import { ToolResponse } from '@/tools/types' +import { BlockConfig } from '../types' + +interface TypeformResponse extends ToolResponse { + output: { + total_items: number + page_count: number + items: Array<{ + landing_id: string + token: string + landed_at: string + submitted_at: string + metadata: { + user_agent: string + platform: string + referer: string + network_id: string + browser: string + } + answers: Array<{ + field: { + id: string + type: string + ref: string + } + type: string + [key: string]: any // For different answer types (text, boolean, number, etc.) + }> + hidden: Record + calculated: { + score: number + } + variables: Array<{ + key: string + type: string + [key: string]: any // For different variable types + }> + }> + } | { + fileUrl: string + contentType: string + filename: string + } | { + fields: Array<{ + dropoffs: number + id: string + label: string + ref: string + title: string + type: string + views: number + }> + form: { + platforms: Array<{ + average_time: number + completion_rate: number + platform: string + responses_count: number + total_visits: number + unique_visits: number + }> + summary: { + average_time: number + completion_rate: number + responses_count: number + total_visits: number + unique_visits: number + } + } + } +} + +export const TypeformBlock: BlockConfig = { + type: 'typeform', + name: 'Typeform', + description: 'Interact with Typeform', + longDescription: + 'Access and retrieve responses from your Typeform forms. Integrate form submissions data into your workflow for analysis, storage, or processing.', + category: 'tools', + bgColor: '#262627', // Typeform brand color + icon: TypeformIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Retrieve Responses', id: 'typeform_responses' }, + { label: 'Download File', id: 'typeform_files' }, + { label: 'Form Insights', id: 'typeform_insights' }, + ], + value: () => 'typeform_responses', + }, + { + id: 'formId', + title: 'Form ID', + type: 'short-input', + layout: 'full', + placeholder: 'Enter your Typeform form ID', + }, + { + id: 'apiKey', + title: 'Personal Access Token', + type: 'short-input', + layout: 'full', + placeholder: 'Enter your Typeform personal access token', + password: true, + }, + // Response operation fields + { + id: 'pageSize', + title: 'Page Size', + type: 'short-input', + layout: 'half', + placeholder: 'Number of responses per page (default: 25)', + condition: { field: 'operation', value: 'typeform_responses' }, + }, + { + id: 'since', + title: 'Since', + type: 'short-input', + layout: 'half', + placeholder: 'Retrieve responses after this date (ISO format)', + condition: { field: 'operation', value: 'typeform_responses' }, + }, + { + id: 'until', + title: 'Until', + type: 'short-input', + layout: 'half', + placeholder: 'Retrieve responses before this date (ISO format)', + condition: { field: 'operation', value: 'typeform_responses' }, + }, + { + id: 'completed', + title: 'Completed', + type: 'dropdown', + layout: 'half', + options: [ + { label: 'All Responses', id: 'all' }, + { label: 'Only Completed', id: 'true' }, + { label: 'Only Incomplete', id: 'false' }, + ], + condition: { field: 'operation', value: 'typeform_responses' }, + }, + // File operation fields + { + id: 'responseId', + title: 'Response ID', + type: 'short-input', + layout: 'full', + placeholder: 'Enter response ID (token)', + condition: { field: 'operation', value: 'typeform_files' }, + }, + { + id: 'fieldId', + title: 'Field ID', + type: 'short-input', + layout: 'half', + placeholder: 'Enter file upload field ID', + condition: { field: 'operation', value: 'typeform_files' }, + }, + { + id: 'filename', + title: 'Filename', + type: 'short-input', + layout: 'half', + placeholder: 'Enter exact filename of the file', + condition: { field: 'operation', value: 'typeform_files' }, + }, + { + id: 'inline', + title: 'Inline Display', + type: 'switch', + layout: 'half', + condition: { field: 'operation', value: 'typeform_files' }, + }, + ], + tools: { + access: ['typeform_responses', 'typeform_files', 'typeform_insights'], + config: { + tool: (params) => { + switch (params.operation) { + case 'typeform_responses': + return 'typeform_responses' + case 'typeform_files': + return 'typeform_files' + case 'typeform_insights': + return 'typeform_insights' + default: + return 'typeform_responses' + } + }, + }, + }, + inputs: { + operation: { type: 'string', required: true }, + formId: { type: 'string', required: true }, + apiKey: { type: 'string', required: true }, + // Response operation params + pageSize: { type: 'number', required: false }, + since: { type: 'string', required: false }, + until: { type: 'string', required: false }, + completed: { type: 'string', required: false }, + // File operation params + responseId: { type: 'string', required: false }, + fieldId: { type: 'string', required: false }, + filename: { type: 'string', required: false }, + inline: { type: 'boolean', required: false }, + }, + outputs: { + response: { + type: { + total_items: 'number', + page_count: 'number', + items: 'json', + }, + dependsOn: { + subBlockId: 'operation', + condition: { + whenEmpty: { + total_items: 'number', + page_count: 'number', + items: 'json', + }, + whenFilled: 'json', + }, + }, + }, + }, +} diff --git a/sim/blocks/index.ts b/sim/blocks/index.ts index 04fbc77a66..7968bbc3f4 100644 --- a/sim/blocks/index.ts +++ b/sim/blocks/index.ts @@ -27,6 +27,7 @@ import { StarterBlock } from './blocks/starter' import { SupabaseBlock } from './blocks/supabase' import { TavilyBlock } from './blocks/tavily' import { TranslateBlock } from './blocks/translate' +import { TypeformBlock } from './blocks/typeform' import { VisionBlock } from './blocks/vision' import { WhatsAppBlock } from './blocks/whatsapp' import { XBlock } from './blocks/x' @@ -67,6 +68,7 @@ export { PerplexityBlock, ConfluenceBlock, ImageGeneratorBlock, + TypeformBlock, } // Registry of all block configurations, alphabetically sorted @@ -99,6 +101,7 @@ const blocks: Record = { supabase: SupabaseBlock, tavily: TavilyBlock, translate: TranslateBlock, + typeform: TypeformBlock, vision: VisionBlock, whatsapp: WhatsAppBlock, x: XBlock, diff --git a/sim/components/icons.tsx b/sim/components/icons.tsx index f3f61c3ec8..a64983838a 100644 --- a/sim/components/icons.tsx +++ b/sim/components/icons.tsx @@ -1756,3 +1756,21 @@ export function ImageIcon(props: SVGProps) { ) } + +export function TypeformIcon(props: SVGProps) { + return ( + + + + + + + ) +} diff --git a/sim/tools/index.ts b/sim/tools/index.ts index ac1f4d74d1..bd90186fb7 100644 --- a/sim/tools/index.ts +++ b/sim/tools/index.ts @@ -36,6 +36,7 @@ import { sheetsReadTool, sheetsUpdateTool, sheetsWriteTool } from './sheets' import { slackMessageTool } from './slack/message' import { supabaseInsertTool, supabaseQueryTool, supabaseUpdateTool } from './supabase' import { tavilyExtractTool, tavilySearchTool } from './tavily' +import { typeformFilesTool, typeformInsightsTool, typeformResponsesTool } from './typeform' import { OAuthTokenPayload, ToolConfig, ToolResponse } from './types' import { formatRequestParams, validateToolRequest } from './utils' import { visionTool } from './vision/vision' @@ -65,6 +66,9 @@ export const tools: Record = { supabase_query: supabaseQueryTool, supabase_insert: supabaseInsertTool, supabase_update: supabaseUpdateTool, + typeform_responses: typeformResponsesTool, + typeform_files: typeformFilesTool, + typeform_insights: typeformInsightsTool, youtube_search: youtubeSearchTool, notion_read: notionReadTool, notion_write: notionWriteTool, diff --git a/sim/tools/typeform/files.test.ts b/sim/tools/typeform/files.test.ts new file mode 100644 index 0000000000..c599365c75 --- /dev/null +++ b/sim/tools/typeform/files.test.ts @@ -0,0 +1,194 @@ +/** + * @vitest-environment jsdom + * + * Typeform Files Tool Unit Tests + * + * This file contains unit tests for the Typeform Files tool, + * which is used to download files uploaded in Typeform responses. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { ToolTester } from '../__test-utils__/test-tools' +import { filesTool } from './files' + +describe('Typeform Files Tool', () => { + let tester: ToolTester + + // Mock file response + const mockFileResponseHeaders = { + 'content-type': 'application/pdf', + 'content-disposition': 'attachment; filename="test-file.pdf"' + } + + beforeEach(() => { + tester = new ToolTester(filesTool) + }) + + afterEach(() => { + tester.cleanup() + vi.resetAllMocks() + }) + + describe('URL Construction', () => { + test('should construct correct URL for file endpoint', () => { + const params = { + formId: 'form123', + responseId: 'resp456', + fieldId: 'field789', + filename: 'test-file.pdf', + apiKey: 'test-token' + } + + expect(tester.getRequestUrl(params)).toBe( + 'https://api.typeform.com/forms/form123/responses/resp456/fields/field789/files/test-file.pdf' + ) + }) + + test('should add inline parameter when provided', () => { + const params = { + formId: 'form123', + responseId: 'resp456', + fieldId: 'field789', + filename: 'test-file.pdf', + inline: true, + apiKey: 'test-token' + } + + const url = tester.getRequestUrl(params) + expect(url).toContain('?inline=true') + }) + + test('should handle special characters in form ID and response ID', () => { + const params = { + formId: 'form/with/special?chars', + responseId: 'resp&with#chars', + fieldId: 'field-id', + filename: 'file name.pdf', + apiKey: 'test-token' + } + + const url = tester.getRequestUrl(params) + // Just verify the URL is constructed and doesn't throw errors + expect(url).toContain('https://api.typeform.com/forms/') + expect(url).toContain('files') + }) + }) + + describe('Headers Construction', () => { + test('should include correct authorization header', () => { + const params = { + formId: 'form123', + responseId: 'resp456', + fieldId: 'field789', + filename: 'test-file.pdf', + apiKey: 'test-token' + } + + const headers = tester.getRequestHeaders(params) + expect(headers.Authorization).toBe('Bearer test-token') + expect(headers['Content-Type']).toBe('application/json') + }) + }) + + describe('Data Transformation', () => { + test('should transform file data correctly', async () => { + // Setup mock response for binary file data + tester.setup('file-content-binary-data', { + headers: mockFileResponseHeaders + }) + + // Execute the tool + const result = await tester.execute({ + formId: 'form123', + responseId: 'resp456', + fieldId: 'field789', + filename: 'test-file.pdf', + apiKey: 'test-token' + }) + + // Check the result + expect(result.success).toBe(true) + expect(result.output.filename).toBe('test-file.pdf') + expect(result.output.contentType).toBe('application/pdf') + // Don't check the fileUrl property as it depends on implementation details + }) + + test('should handle missing content-disposition header', async () => { + // Setup mock response without content-disposition + tester.setup('file-content-binary-data', { + headers: { 'content-type': 'application/pdf' } + }) + + // Execute the tool + const result = await tester.execute({ + formId: 'form123', + responseId: 'resp456', + fieldId: 'field789', + filename: 'test-file.pdf', + apiKey: 'test-token' + }) + + // Check the result + expect(result.success).toBe(true) + expect(result.output.contentType).toBe('application/pdf') + // Don't check the fileUrl property as it depends on implementation details + // filename should be empty since there's no content-disposition + expect(result.output.filename).toBe('') + }) + }) + + describe('Error Handling', () => { + test('should handle file not found errors', async () => { + // Setup 404 error response + tester.setup({ message: 'File not found' }, { ok: false, status: 404 }) + + // Execute the tool + const result = await tester.execute({ + formId: 'form123', + responseId: 'resp456', + fieldId: 'field789', + filename: 'nonexistent.pdf', + apiKey: 'test-token' + }) + + // Check error handling + expect(result.success).toBe(false) + expect(result.error).toContain('Not Found') + }) + + test('should handle unauthorized errors', async () => { + // Setup 401 error response + tester.setup({ message: 'Unauthorized access' }, { ok: false, status: 401 }) + + // Execute the tool + const result = await tester.execute({ + formId: 'form123', + responseId: 'resp456', + fieldId: 'field789', + filename: 'test-file.pdf', + apiKey: 'invalid-token' + }) + + // Check error handling + expect(result.success).toBe(false) + expect(result.error).toContain('Unauthorized') + }) + + test('should handle network errors', async () => { + // Setup network error + tester.setupError('Network error') + + // Execute the tool + const result = await tester.execute({ + formId: 'form123', + responseId: 'resp456', + fieldId: 'field789', + filename: 'test-file.pdf', + apiKey: 'test-token' + }) + + // Check error handling + expect(result.success).toBe(false) + expect(result.error).toBeDefined() + }) + }) +}) \ No newline at end of file diff --git a/sim/tools/typeform/files.ts b/sim/tools/typeform/files.ts new file mode 100644 index 0000000000..1938fcb7e2 --- /dev/null +++ b/sim/tools/typeform/files.ts @@ -0,0 +1,147 @@ +import { ToolConfig, ToolResponse } from '../types' + +interface TypeformFilesParams { + formId: string + responseId: string + fieldId: string + filename: string + inline?: boolean + apiKey: string +} + +interface TypeformFilesResponse extends ToolResponse { + output: { + fileUrl: string + contentType: string + filename: string + } +} + +export const filesTool: ToolConfig = { + id: 'typeform_files', + name: 'Typeform Files', + description: 'Download files uploaded in Typeform responses', + version: '1.0.0', + params: { + formId: { + type: 'string', + required: true, + description: 'Typeform form ID', + }, + responseId: { + type: 'string', + required: true, + description: 'Response ID containing the files', + }, + fieldId: { + type: 'string', + required: true, + description: 'Unique ID of the file upload field', + }, + filename: { + type: 'string', + required: true, + description: 'Filename of the uploaded file', + }, + inline: { + type: 'boolean', + required: false, + description: 'Whether to request the file with inline Content-Disposition', + }, + apiKey: { + type: 'string', + required: true, + description: 'Typeform Personal Access Token', + }, + }, + request: { + url: (params: TypeformFilesParams) => { + const encodedFormId = encodeURIComponent(params.formId) + const encodedResponseId = encodeURIComponent(params.responseId) + const encodedFieldId = encodeURIComponent(params.fieldId) + const encodedFilename = encodeURIComponent(params.filename) + + let url = `https://api.typeform.com/forms/${encodedFormId}/responses/${encodedResponseId}/fields/${encodedFieldId}/files/${encodedFilename}` + + // Add the inline parameter if provided + if (params.inline !== undefined) { + url += `?inline=${params.inline}` + } + + return url + }, + method: 'GET', + headers: (params) => ({ + 'Authorization': `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + }, + transformResponse: async (response: Response, params?: TypeformFilesParams) => { + if (!response.ok) { + let errorMessage = response.statusText || 'Unknown error'; + + try { + const errorData = await response.json(); + if (errorData && errorData.message) { + errorMessage = errorData.message; + } else if (errorData && errorData.description) { + errorMessage = errorData.description; + } else if (typeof errorData === 'string') { + errorMessage = errorData; + } + } catch (e) { + // If we can't parse the error as JSON, just use the status text + } + + throw new Error(`Typeform API error (${response.status}): ${errorMessage}`); + } + + // For file downloads, we get the file directly + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + const contentDisposition = response.headers.get('content-disposition') || ''; + + // Try to extract filename from content-disposition if possible + let filename = ''; + const filenameMatch = contentDisposition.match(/filename="(.+?)"/); + if (filenameMatch && filenameMatch[1]) { + filename = filenameMatch[1]; + } + + // Get file URL from the response URL or construct it from parameters if not available + let fileUrl = response.url; + + // If the response URL is not available (common in test environments), construct it from params + if (!fileUrl && params) { + const encodedFormId = encodeURIComponent(params.formId); + const encodedResponseId = encodeURIComponent(params.responseId); + const encodedFieldId = encodeURIComponent(params.fieldId); + const encodedFilename = encodeURIComponent(params.filename); + + fileUrl = `https://api.typeform.com/forms/${encodedFormId}/responses/${encodedResponseId}/fields/${encodedFieldId}/files/${encodedFilename}`; + + if (params.inline !== undefined) { + fileUrl += `?inline=${params.inline}`; + } + } + + return { + success: true, + output: { + fileUrl: fileUrl || '', + contentType, + filename + } + }; + }, + transformError: (error) => { + if (error instanceof Error) { + return `Failed to retrieve Typeform file: ${error.message}` + } + + if (typeof error === 'object' && error !== null) { + return `Failed to retrieve Typeform file: ${JSON.stringify(error)}` + } + + return `Failed to retrieve Typeform file: An unknown error occurred` + }, +} \ No newline at end of file diff --git a/sim/tools/typeform/index.test.ts b/sim/tools/typeform/index.test.ts new file mode 100644 index 0000000000..e5a172ab6d --- /dev/null +++ b/sim/tools/typeform/index.test.ts @@ -0,0 +1,325 @@ +/** + * @vitest-environment jsdom + * + * Typeform Integration Tests + * + * This file contains integration tests that verify the Typeform tools + * work correctly together and can be properly used from the block. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { typeformFilesTool, typeformResponsesTool, typeformInsightsTool } from './index' +import { ToolTester } from '../__test-utils__/test-tools' + +describe('Typeform Tools Integration', () => { + describe('Typeform Responses Tool Export', () => { + let tester: ToolTester + + beforeEach(() => { + tester = new ToolTester(typeformResponsesTool) + }) + + afterEach(() => { + tester.cleanup() + vi.resetAllMocks() + }) + + test('should use the correct tool ID', () => { + expect(typeformResponsesTool.id).toBe('typeform_responses') + }) + + test('should handle basic responses request', async () => { + // Setup mock response data + const mockData = { + total_items: 1, + page_count: 1, + items: [ + { + landing_id: 'test-landing', + token: 'test-token', + submitted_at: '2023-01-01T00:00:00Z', + answers: [], + }, + ], + } + + tester.setup(mockData) + + // Execute the tool + const result = await tester.execute({ + formId: 'test-form', + apiKey: 'test-api-key', + }) + + expect(result.success).toBe(true) + expect(result.output.total_items).toBe(1) + }) + }) + + describe('Typeform Files Tool Export', () => { + let tester: ToolTester + + beforeEach(() => { + tester = new ToolTester(typeformFilesTool) + }) + + afterEach(() => { + tester.cleanup() + vi.resetAllMocks() + }) + + test('should use the correct tool ID', () => { + expect(typeformFilesTool.id).toBe('typeform_files') + }) + + test('should handle basic file request', async () => { + // Setup mock response with file headers + tester.setup('binary-file-content', { + headers: { + 'content-type': 'application/pdf', + 'content-disposition': 'attachment; filename="test.pdf"' + } + }) + + // Execute the tool + const result = await tester.execute({ + formId: 'test-form', + responseId: 'test-response', + fieldId: 'test-field', + filename: 'test.pdf', + apiKey: 'test-api-key', + }) + + expect(result.success).toBe(true) + expect(result.output.contentType).toBe('application/pdf') + expect(result.output.filename).toBe('test.pdf') + }) + }) + + describe('Typeform Insights Tool Export', () => { + let tester: ToolTester + + beforeEach(() => { + tester = new ToolTester(typeformInsightsTool) + }) + + afterEach(() => { + tester.cleanup() + vi.resetAllMocks() + }) + + test('should use the correct tool ID', () => { + expect(typeformInsightsTool.id).toBe('typeform_insights') + }) + + test('should handle basic insights request', async () => { + // Setup mock response data + const mockData = { + fields: [ + { + dropoffs: 5, + id: 'field123', + label: '1', + ref: 'ref123', + title: 'What is your name?', + type: 'short_text', + views: 100 + } + ], + form: { + platforms: [ + { + average_time: 120000, + completion_rate: 75.5, + platform: 'desktop', + responses_count: 80, + total_visits: 120, + unique_visits: 100 + } + ], + summary: { + average_time: 140000, + completion_rate: 72.3, + responses_count: 120, + total_visits: 180, + unique_visits: 150 + } + } + } + + tester.setup(mockData) + + // Execute the tool + const result = await tester.execute({ + formId: 'test-form', + apiKey: 'test-api-key', + }) + + expect(result.success).toBe(true) + expect(result.output.form.summary.responses_count).toBe(120) + expect(result.output.fields).toHaveLength(1) + }) + }) + + describe('End-to-End Flow', () => { + // This test simulates using both tools together in a workflow + + test('should be able to get responses and then file', async () => { + // First set up responses tester + const responsesTester = new ToolTester(typeformResponsesTool) + + // Mock responses data with a file upload + const mockResponsesData = { + total_items: 1, + page_count: 1, + items: [ + { + landing_id: 'landing-id', + token: 'response-id', + submitted_at: '2023-01-01T00:00:00Z', + answers: [ + { + field: { + id: 'file-field', + type: 'file_upload', + }, + type: 'file_url', + file_url: 'https://example.com/placeholder.pdf', + }, + ], + }, + ], + } + + responsesTester.setup(mockResponsesData) + + // Get responses + const responsesResult = await responsesTester.execute({ + formId: 'test-form', + apiKey: 'test-api-key', + }) + + expect(responsesResult.success).toBe(true) + + // Now get the response ID and field ID + const responseId = responsesResult.output.items[0].token + expect(responseId).toBe('response-id') + + const fieldId = responsesResult.output.items[0].answers[0].field.id + expect(fieldId).toBe('file-field') + + // Now set up files tester + const filesTester = new ToolTester(typeformFilesTool) + + // Mock file data + filesTester.setup('binary-file-data', { + headers: { + 'content-type': 'application/pdf', + 'content-disposition': 'attachment; filename="uploaded.pdf"' + } + }) + + // Get file using the response ID and field ID from previous request + const filesResult = await filesTester.execute({ + formId: 'test-form', + responseId, + fieldId, + filename: 'uploaded.pdf', + apiKey: 'test-api-key', + }) + + expect(filesResult.success).toBe(true) + expect(filesResult.output.contentType).toBe('application/pdf') + expect(filesResult.output.filename).toBe('uploaded.pdf') + + // Clean up + responsesTester.cleanup() + filesTester.cleanup() + }) + + test('should be able to get responses and then insights', async () => { + // First set up responses tester + const responsesTester = new ToolTester(typeformResponsesTool) + + // Mock responses data + const mockResponsesData = { + total_items: 10, + page_count: 1, + items: [ + { + landing_id: 'landing-id', + token: 'response-id', + submitted_at: '2023-01-01T00:00:00Z', + answers: [], + }, + ], + } + + responsesTester.setup(mockResponsesData) + + // Get responses + const responsesResult = await responsesTester.execute({ + formId: 'test-form', + apiKey: 'test-api-key', + }) + + expect(responsesResult.success).toBe(true) + expect(responsesResult.output.total_items).toBe(10) + + // Now set up insights tester + const insightsTester = new ToolTester(typeformInsightsTool) + + // Mock insights data + const mockInsightsData = { + fields: [ + { + dropoffs: 5, + id: 'field123', + label: '1', + ref: 'ref123', + title: 'What is your name?', + type: 'short_text', + views: 100 + } + ], + form: { + platforms: [ + { + average_time: 120000, + completion_rate: 75.5, + platform: 'desktop', + responses_count: 80, + total_visits: 120, + unique_visits: 100 + } + ], + summary: { + average_time: 140000, + completion_rate: 72.3, + responses_count: 120, + total_visits: 180, + unique_visits: 150 + } + } + } + + insightsTester.setup(mockInsightsData) + + // Get insights for the same form + const insightsResult = await insightsTester.execute({ + formId: 'test-form', + apiKey: 'test-api-key', + }) + + expect(insightsResult.success).toBe(true) + expect(insightsResult.output.form.summary.responses_count).toBe(120) + + // Verify we can analyze the data by looking at completion rates + expect(insightsResult.output.form.summary.completion_rate).toBe(72.3) + expect(insightsResult.output.form.platforms[0].platform).toBe('desktop') + + // Clean up + responsesTester.cleanup() + insightsTester.cleanup() + }) + }) +}) \ No newline at end of file diff --git a/sim/tools/typeform/index.ts b/sim/tools/typeform/index.ts new file mode 100644 index 0000000000..0e8dbf78e1 --- /dev/null +++ b/sim/tools/typeform/index.ts @@ -0,0 +1,7 @@ +import { responsesTool } from './responses' +import { filesTool } from './files' +import { insightsTool } from './insights' + +export const typeformResponsesTool = responsesTool +export const typeformFilesTool = filesTool +export const typeformInsightsTool = insightsTool \ No newline at end of file diff --git a/sim/tools/typeform/insights.test.ts b/sim/tools/typeform/insights.test.ts new file mode 100644 index 0000000000..c8b80d893f --- /dev/null +++ b/sim/tools/typeform/insights.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment jsdom + * + * Typeform Insights Tool Unit Tests + * + * This file contains unit tests for the Typeform Insights tool, + * which is used to retrieve form insights and analytics. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { ToolTester } from '../__test-utils__/test-tools' +import { insightsTool } from './insights' + +describe('Typeform Insights Tool', () => { + let tester: ToolTester + + // Mock insights response + const mockInsightsData = { + fields: [ + { + dropoffs: 5, + id: 'field123', + label: '1', + ref: 'ref123', + title: 'What is your name?', + type: 'short_text', + views: 100 + }, + { + dropoffs: 10, + id: 'field456', + label: '2', + ref: 'ref456', + title: 'How did you hear about us?', + type: 'multiple_choice', + views: 95 + } + ], + form: { + platforms: [ + { + average_time: 120000, + completion_rate: 75.5, + platform: 'desktop', + responses_count: 80, + total_visits: 120, + unique_visits: 100 + }, + { + average_time: 180000, + completion_rate: 65.2, + platform: 'mobile', + responses_count: 40, + total_visits: 60, + unique_visits: 50 + } + ], + summary: { + average_time: 140000, + completion_rate: 72.3, + responses_count: 120, + total_visits: 180, + unique_visits: 150 + } + } + } + + beforeEach(() => { + tester = new ToolTester(insightsTool) + }) + + afterEach(() => { + tester.cleanup() + vi.resetAllMocks() + }) + + describe('URL Construction', () => { + test('should construct correct URL for insights endpoint', () => { + const params = { + formId: 'form123', + apiKey: 'test-token' + } + + expect(tester.getRequestUrl(params)).toBe( + 'https://api.typeform.com/insights/form123/summary' + ) + }) + + test('should handle special characters in form ID', () => { + const params = { + formId: 'form/with/special?chars', + apiKey: 'test-token' + } + + const url = tester.getRequestUrl(params) + // Just verify the URL is constructed and doesn't throw errors + expect(url).toContain('https://api.typeform.com/insights/') + expect(url).toContain('summary') + }) + }) + + describe('Headers Construction', () => { + test('should include correct authorization header', () => { + const params = { + formId: 'form123', + apiKey: 'test-token' + } + + const headers = tester.getRequestHeaders(params) + expect(headers.Authorization).toBe('Bearer test-token') + expect(headers['Content-Type']).toBe('application/json') + }) + }) + + describe('Data Transformation', () => { + test('should transform insights data correctly', async () => { + // Setup mock response + tester.setup(mockInsightsData) + + // Execute the tool + const result = await tester.execute({ + formId: 'form123', + apiKey: 'test-token' + }) + + // Check the result + expect(result.success).toBe(true) + + // Verify form summary data + expect(result.output.form.summary.responses_count).toBe(120) + expect(result.output.form.summary.completion_rate).toBe(72.3) + + // Verify platforms data + expect(result.output.form.platforms).toHaveLength(2) + expect(result.output.form.platforms[0].platform).toBe('desktop') + expect(result.output.form.platforms[1].platform).toBe('mobile') + + // Verify fields data + expect(result.output.fields).toHaveLength(2) + expect(result.output.fields[0].title).toBe('What is your name?') + expect(result.output.fields[1].title).toBe('How did you hear about us?') + }) + }) + + describe('Error Handling', () => { + test('should handle form not found errors', async () => { + // Setup 404 error response + tester.setup({ message: 'Form not found' }, { ok: false, status: 404 }) + + // Execute the tool + const result = await tester.execute({ + formId: 'nonexistent', + apiKey: 'test-token' + }) + + // Check error handling + expect(result.success).toBe(false) + expect(result.error).toContain('Not Found') + }) + + test('should handle unauthorized errors', async () => { + // Setup 401 error response + tester.setup({ message: 'Unauthorized access' }, { ok: false, status: 401 }) + + // Execute the tool + const result = await tester.execute({ + formId: 'form123', + apiKey: 'invalid-token' + }) + + // Check error handling + expect(result.success).toBe(false) + expect(result.error).toContain('Unauthorized') + }) + + test('should handle network errors', async () => { + // Setup network error + tester.setupError('Network error') + + // Execute the tool + const result = await tester.execute({ + formId: 'form123', + apiKey: 'test-token' + }) + + // Check error handling + expect(result.success).toBe(false) + expect(result.error).toBeDefined() + }) + }) +}) \ No newline at end of file diff --git a/sim/tools/typeform/insights.ts b/sim/tools/typeform/insights.ts new file mode 100644 index 0000000000..0d494fd7a6 --- /dev/null +++ b/sim/tools/typeform/insights.ts @@ -0,0 +1,132 @@ +import { ToolConfig, ToolResponse } from '../types' + +interface TypeformInsightsParams { + formId: string + apiKey: string +} + +// This is the actual output data structure from the API +interface TypeformInsightsData { + fields: Array<{ + dropoffs: number + id: string + label: string + ref: string + title: string + type: string + views: number + }> + form: { + platforms: Array<{ + average_time: number + completion_rate: number + platform: string + responses_count: number + total_visits: number + unique_visits: number + }> + summary: { + average_time: number + completion_rate: number + responses_count: number + total_visits: number + unique_visits: number + } + } +} + +// The ToolResponse uses a union type to allow either successful data or empty object in error case +interface TypeformInsightsResponse extends ToolResponse { + output: TypeformInsightsData | Record +} + +export const insightsTool: ToolConfig = { + id: 'typeform_insights', + name: 'Typeform Insights', + description: 'Retrieve insights and analytics for Typeform forms', + version: '1.0.0', + params: { + formId: { + type: 'string', + required: true, + description: 'Typeform form ID', + }, + apiKey: { + type: 'string', + required: true, + description: 'Typeform Personal Access Token', + }, + }, + request: { + url: (params: TypeformInsightsParams) => { + const encodedFormId = encodeURIComponent(params.formId) + return `https://api.typeform.com/insights/${encodedFormId}/summary` + }, + method: 'GET', + headers: (params) => ({ + 'Authorization': `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + }, + transformResponse: async (response: Response) => { + if (!response.ok) { + let errorMessage = response.statusText || 'Unknown error'; + let errorDetails = ''; + + try { + const errorData = await response.json(); + console.log('Typeform API error response:', JSON.stringify(errorData, null, 2)); + + if (errorData && errorData.message) { + errorMessage = errorData.message; + } else if (errorData && errorData.description) { + errorMessage = errorData.description; + } else if (typeof errorData === 'string') { + errorMessage = errorData; + } + + // Extract more details if available + if (errorData && errorData.details) { + errorDetails = ` Details: ${JSON.stringify(errorData.details)}`; + } + + // Special handling for 403 errors + if (response.status === 403) { + return { + success: false, + output: {}, + error: `Access forbidden (403) to Typeform Insights API. This could be due to: +1. Missing 'read:insights' scope on your API token +2. Insufficient plan subscription (insights may require a higher plan) +3. No access rights to the specified form +4. API token is invalid or expired +Details from API: ${errorMessage}${errorDetails}` + }; + } + } catch (e) { + // If we can't parse the error as JSON, just use the status text + console.log('Error parsing Typeform API error:', e); + } + + throw new Error(`Typeform API error (${response.status}): ${errorMessage}${errorDetails}`); + } + + const data = await response.json(); + + return { + success: true, + output: data + }; + }, + transformError: (error) => { + if (error instanceof Error) { + return `Failed to retrieve Typeform insights: ${error.message}` + } + + if (typeof error === 'object' && error !== null) { + return `Failed to retrieve Typeform insights: ${JSON.stringify(error)}` + } + + return `Failed to retrieve Typeform insights: An unknown error occurred` + }, +} \ No newline at end of file diff --git a/sim/tools/typeform/responses.test.ts b/sim/tools/typeform/responses.test.ts new file mode 100644 index 0000000000..62dde7af5e --- /dev/null +++ b/sim/tools/typeform/responses.test.ts @@ -0,0 +1,269 @@ +/** + * @vitest-environment jsdom + * + * Typeform Responses Tool Unit Tests + * + * This file contains unit tests for the Typeform Responses tool, + * which is used to fetch form responses from the Typeform API. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { ToolTester } from '../__test-utils__/test-tools' +import { responsesTool } from './responses' + +describe('Typeform Responses Tool', () => { + let tester: ToolTester + + // Mock response data + const mockResponsesData = { + total_items: 2, + page_count: 1, + items: [ + { + landing_id: 'landing-id-1', + token: 'response-id-1', + landed_at: '2023-01-01T10:00:00Z', + submitted_at: '2023-01-01T10:05:00Z', + metadata: { + user_agent: 'Mozilla/5.0', + platform: 'web', + referer: 'https://example.com', + network_id: 'network-id-1', + browser: 'chrome', + }, + answers: [ + { + field: { + id: 'field-id-1', + type: 'short_text', + ref: 'ref-1', + }, + type: 'text', + text: 'Sample answer', + }, + ], + hidden: {}, + calculated: { + score: 0, + }, + variables: [], + }, + { + landing_id: 'landing-id-2', + token: 'response-id-2', + landed_at: '2023-01-02T10:00:00Z', + submitted_at: '2023-01-02T10:05:00Z', + metadata: { + user_agent: 'Mozilla/5.0', + platform: 'web', + referer: 'https://example.com', + network_id: 'network-id-2', + browser: 'chrome', + }, + answers: [ + { + field: { + id: 'field-id-1', + type: 'short_text', + ref: 'ref-1', + }, + type: 'text', + text: 'Another answer', + }, + ], + hidden: {}, + calculated: { + score: 0, + }, + variables: [], + }, + ], + } + + beforeEach(() => { + tester = new ToolTester(responsesTool) + }) + + afterEach(() => { + tester.cleanup() + vi.resetAllMocks() + }) + + describe('URL Construction', () => { + test('should construct correct base Typeform API URL', () => { + const params = { + formId: 'form123', + apiKey: 'test-token', + } + + expect(tester.getRequestUrl(params)).toBe( + 'https://api.typeform.com/forms/form123/responses' + ) + }) + + test('should add pageSize parameter to URL when provided', () => { + const params = { + formId: 'form123', + apiKey: 'test-token', + pageSize: 50, + } + + expect(tester.getRequestUrl(params)).toBe( + 'https://api.typeform.com/forms/form123/responses?page_size=50' + ) + }) + + test('should add since parameter to URL when provided', () => { + const params = { + formId: 'form123', + apiKey: 'test-token', + since: '2023-01-01T00:00:00Z', + } + + const url = tester.getRequestUrl(params) + expect(url).toContain('https://api.typeform.com/forms/form123/responses?since=') + expect(url).toContain('2023-01-01T00:00:00Z') + }) + + test('should add until parameter to URL when provided', () => { + const params = { + formId: 'form123', + apiKey: 'test-token', + until: '2023-01-31T23:59:59Z', + } + + const url = tester.getRequestUrl(params) + expect(url).toContain('https://api.typeform.com/forms/form123/responses?until=') + expect(url).toContain('2023-01-31T23:59:59Z') + }) + + test('should add completed parameter to URL when provided and not "all"', () => { + const params = { + formId: 'form123', + apiKey: 'test-token', + completed: 'true', + } + + expect(tester.getRequestUrl(params)).toBe( + 'https://api.typeform.com/forms/form123/responses?completed=true' + ) + }) + + test('should not add completed parameter to URL when set to "all"', () => { + const params = { + formId: 'form123', + apiKey: 'test-token', + completed: 'all', + } + + expect(tester.getRequestUrl(params)).toBe( + 'https://api.typeform.com/forms/form123/responses' + ) + }) + + test('should combine multiple parameters correctly', () => { + const params = { + formId: 'form123', + apiKey: 'test-token', + pageSize: 10, + since: '2023-01-01T00:00:00Z', + until: '2023-01-31T23:59:59Z', + completed: 'true', + } + + const url = tester.getRequestUrl(params) + expect(url).toContain('https://api.typeform.com/forms/form123/responses?') + expect(url).toContain('page_size=10') + expect(url).toContain('since=') + expect(url).toContain('until=') + expect(url).toContain('completed=true') + }) + }) + + describe('Headers Construction', () => { + test('should include correct authorization header', () => { + const params = { + formId: 'form123', + apiKey: 'test-token', + } + + const headers = tester.getRequestHeaders(params) + expect(headers.Authorization).toBe('Bearer test-token') + expect(headers['Content-Type']).toBe('application/json') + }) + }) + + describe('Data Transformation', () => { + test('should fetch and transform responses correctly', async () => { + // Setup mock response + tester.setup(mockResponsesData) + + // Execute the tool + const result = await tester.execute({ + formId: 'form123', + apiKey: 'test-token', + }) + + // Check the result + expect(result.success).toBe(true) + expect(result.output.total_items).toBe(2) + expect(result.output.items).toHaveLength(2) + + // Check first response + const firstResponse = result.output.items[0] + expect(firstResponse.token).toBe('response-id-1') + expect(firstResponse.answers).toHaveLength(1) + expect(firstResponse.answers[0].text).toBe('Sample answer') + + // Check second response + const secondResponse = result.output.items[1] + expect(secondResponse.token).toBe('response-id-2') + }) + }) + + describe('Error Handling', () => { + test('should handle form not found errors', async () => { + // Setup 404 error response + tester.setup({ message: 'Form not found' }, { ok: false, status: 404 }) + + // Execute the tool + const result = await tester.execute({ + formId: 'nonexistent-form', + apiKey: 'test-token', + }) + + // Check error handling + expect(result.success).toBe(false) + expect(result.error).toContain('Not Found') + }) + + test('should handle unauthorized errors', async () => { + // Setup 401 error response + tester.setup({ message: 'Unauthorized access' }, { ok: false, status: 401 }) + + // Execute the tool + const result = await tester.execute({ + formId: 'form123', + apiKey: 'invalid-token', + }) + + // Check error handling + expect(result.success).toBe(false) + expect(result.error).toContain('Unauthorized') + }) + + test('should handle network errors', async () => { + // Setup network error + tester.setupError('Network error') + + // Execute the tool + const result = await tester.execute({ + formId: 'form123', + apiKey: 'test-token', + }) + + // Check error handling + expect(result.success).toBe(false) + expect(result.error).toBeDefined() + }) + }) +}) \ No newline at end of file diff --git a/sim/tools/typeform/responses.ts b/sim/tools/typeform/responses.ts new file mode 100644 index 0000000000..734fced892 --- /dev/null +++ b/sim/tools/typeform/responses.ts @@ -0,0 +1,159 @@ +import { ToolConfig, ToolResponse } from '../types' + +interface TypeformResponsesParams { + formId: string + apiKey: string + pageSize?: number + since?: string + until?: string + completed?: string +} + +interface TypeformResponsesResponse extends ToolResponse { + output: { + total_items: number + page_count: number + items: Array<{ + landing_id: string + token: string + landed_at: string + submitted_at: string + metadata: { + user_agent: string + platform: string + referer: string + network_id: string + browser: string + } + answers: Array<{ + field: { + id: string + type: string + ref: string + } + type: string + [key: string]: any + }> + hidden: Record + calculated: { + score: number + } + variables: Array<{ + key: string + type: string + [key: string]: any + }> + }> + } +} + +export const responsesTool: ToolConfig = { + id: 'typeform_responses', + name: 'Typeform Responses', + description: 'Retrieve form responses from Typeform', + version: '1.0.0', + params: { + formId: { + type: 'string', + required: true, + description: 'Typeform form ID', + }, + apiKey: { + type: 'string', + required: true, + description: 'Typeform Personal Access Token', + }, + pageSize: { + type: 'number', + required: false, + description: 'Number of responses to retrieve (default: 25)', + }, + since: { + type: 'string', + required: false, + description: 'Retrieve responses submitted after this date (ISO 8601 format)', + }, + until: { + type: 'string', + required: false, + description: 'Retrieve responses submitted before this date (ISO 8601 format)', + }, + completed: { + type: 'string', + required: false, + description: 'Filter by completion status (true/false)', + }, + }, + request: { + url: (params: TypeformResponsesParams) => { + const url = `https://api.typeform.com/forms/${params.formId}/responses` + + const queryParams = [] + + if (params.pageSize) { + queryParams.push(`page_size=${params.pageSize}`) + } + + if (params.since) { + queryParams.push(`since=${encodeURIComponent(params.since)}`) + } + + if (params.until) { + queryParams.push(`until=${encodeURIComponent(params.until)}`) + } + + if (params.completed && params.completed !== 'all') { + queryParams.push(`completed=${params.completed}`) + } + + return queryParams.length > 0 ? `${url}?${queryParams.join('&')}` : url + }, + method: 'GET', + headers: (params) => ({ + 'Authorization': `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + }, + transformResponse: async (response: Response) => { + if (!response.ok) { + let errorMessage = response.statusText || 'Unknown error'; + + try { + const errorData = await response.json(); + if (errorData && errorData.message) { + errorMessage = errorData.message; + } else if (errorData && errorData.description) { + errorMessage = errorData.description; + } else if (typeof errorData === 'string') { + errorMessage = errorData; + } + } catch (e) { + // If we can't parse the error as JSON, just use the status text + } + + throw new Error(`Typeform API error (${response.status}): ${errorMessage}`); + } + + try { + const data = await response.json(); + + return { + success: true, + output: data, + }; + } catch (error) { + throw new Error(`Failed to parse Typeform response: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }, + transformError: (error) => { + if (error instanceof Error) { + return `Failed to retrieve Typeform responses: ${error.message}` + } + + if (typeof error === 'object' && error !== null) { + return `Failed to retrieve Typeform responses: ${JSON.stringify(error)}` + } + + return `Failed to retrieve Typeform responses: An unknown error occurred` + }, +} \ No newline at end of file