mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 23:48:09 -05:00
feat(typeform): add Typeform integration ref (#207)
* feat(typeform): add Typeform integration ref #106 * fix(typeform): icon --------- Co-authored-by: rodrick-mpofu <rodrickmpofu@gmail.com>
This commit is contained in:
233
sim/blocks/blocks/typeform.ts
Normal file
233
sim/blocks/blocks/typeform.ts
Normal file
@@ -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<string, any>
|
||||
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<TypeformResponse> = {
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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<string, BlockConfig> = {
|
||||
supabase: SupabaseBlock,
|
||||
tavily: TavilyBlock,
|
||||
translate: TranslateBlock,
|
||||
typeform: TypeformBlock,
|
||||
vision: VisionBlock,
|
||||
whatsapp: WhatsAppBlock,
|
||||
x: XBlock,
|
||||
|
||||
@@ -1756,3 +1756,21 @@ export function ImageIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TypeformIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g transform="translate(1, 4)">
|
||||
<rect x="0" y="0" width="5" height="16" rx="2.5" fill="currentColor" />
|
||||
<rect x="8" y="0" width="14" height="16" rx="4" fill="currentColor" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, ToolConfig> = {
|
||||
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,
|
||||
|
||||
194
sim/tools/typeform/files.test.ts
Normal file
194
sim/tools/typeform/files.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
147
sim/tools/typeform/files.ts
Normal file
147
sim/tools/typeform/files.ts
Normal file
@@ -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<TypeformFilesParams, TypeformFilesResponse> = {
|
||||
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`
|
||||
},
|
||||
}
|
||||
325
sim/tools/typeform/index.test.ts
Normal file
325
sim/tools/typeform/index.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
7
sim/tools/typeform/index.ts
Normal file
7
sim/tools/typeform/index.ts
Normal file
@@ -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
|
||||
190
sim/tools/typeform/insights.test.ts
Normal file
190
sim/tools/typeform/insights.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
132
sim/tools/typeform/insights.ts
Normal file
132
sim/tools/typeform/insights.ts
Normal file
@@ -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<string, never>
|
||||
}
|
||||
|
||||
export const insightsTool: ToolConfig<TypeformInsightsParams, TypeformInsightsResponse> = {
|
||||
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`
|
||||
},
|
||||
}
|
||||
269
sim/tools/typeform/responses.test.ts
Normal file
269
sim/tools/typeform/responses.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
159
sim/tools/typeform/responses.ts
Normal file
159
sim/tools/typeform/responses.ts
Normal file
@@ -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<string, any>
|
||||
calculated: {
|
||||
score: number
|
||||
}
|
||||
variables: Array<{
|
||||
key: string
|
||||
type: string
|
||||
[key: string]: any
|
||||
}>
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export const responsesTool: ToolConfig<TypeformResponsesParams, TypeformResponsesResponse> = {
|
||||
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`
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user