From 2a68ce803b11a038b04fc62fdb8e51974e73034d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 3 Apr 2026 12:25:43 +0200 Subject: [PATCH 01/29] feat(e2e): scaffold testing/e2e package with TanStack Start --- testing/e2e/.env | 1 + testing/e2e/package.json | 50 ++++++++++++++++++++++++++++++++++++++ testing/e2e/src/router.tsx | 10 ++++++++ testing/e2e/src/styles.css | 1 + testing/e2e/tsconfig.json | 20 +++++++++++++++ testing/e2e/vite.config.ts | 20 +++++++++++++++ 6 files changed, 102 insertions(+) create mode 100644 testing/e2e/.env create mode 100644 testing/e2e/package.json create mode 100644 testing/e2e/src/router.tsx create mode 100644 testing/e2e/src/styles.css create mode 100644 testing/e2e/tsconfig.json create mode 100644 testing/e2e/vite.config.ts diff --git a/testing/e2e/.env b/testing/e2e/.env new file mode 100644 index 000000000..5ad69a16b --- /dev/null +++ b/testing/e2e/.env @@ -0,0 +1 @@ +LLMOCK_URL=http://127.0.0.1:4010 diff --git a/testing/e2e/package.json b/testing/e2e/package.json new file mode 100644 index 000000000..a3b9d3137 --- /dev/null +++ b/testing/e2e/package.json @@ -0,0 +1,50 @@ +{ + "name": "@tanstack/ai-e2e", + "private": true, + "type": "module", + "scripts": { + "dev": "vinxi dev --port 3010", + "build": "vinxi build", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "record": "LLMOCK_RECORD=true pnpm dev", + "postinstall": "playwright install chromium" + }, + "dependencies": { + "@copilotkit/llmock": "latest", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/ai": "workspace:*", + "@tanstack/ai-anthropic": "workspace:*", + "@tanstack/ai-client": "workspace:*", + "@tanstack/ai-gemini": "workspace:*", + "@tanstack/ai-grok": "workspace:*", + "@tanstack/ai-groq": "workspace:*", + "@tanstack/ai-ollama": "workspace:*", + "@tanstack/ai-openai": "workspace:*", + "@tanstack/ai-openrouter": "workspace:*", + "@tanstack/ai-react": "workspace:*", + "@tanstack/ai-react-ui": "workspace:*", + "@tanstack/nitro-v2-vite-plugin": "^1.154.7", + "@tanstack/react-router": "^1.158.4", + "@tanstack/react-start": "^1.159.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", + "tailwindcss": "^4.1.18", + "vite-tsconfig-paths": "^5.1.4", + "zod": "^4.2.0" + }, + "devDependencies": { + "@playwright/test": "^1.57.0", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "typescript": "5.9.3", + "vite": "^7.2.7" + } +} diff --git a/testing/e2e/src/router.tsx b/testing/e2e/src/router.tsx new file mode 100644 index 000000000..a59544464 --- /dev/null +++ b/testing/e2e/src/router.tsx @@ -0,0 +1,10 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export const getRouter = () => { + return createRouter({ + routeTree, + scrollRestoration: true, + defaultPreloadStaleTime: 0, + }) +} diff --git a/testing/e2e/src/styles.css b/testing/e2e/src/styles.css new file mode 100644 index 000000000..d4b507858 --- /dev/null +++ b/testing/e2e/src/styles.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/testing/e2e/tsconfig.json b/testing/e2e/tsconfig.json new file mode 100644 index 000000000..5512f0a71 --- /dev/null +++ b/testing/e2e/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "jsx": "react-jsx", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/testing/e2e/vite.config.ts b/testing/e2e/vite.config.ts new file mode 100644 index 000000000..734b135c8 --- /dev/null +++ b/testing/e2e/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import viteTsConfigPaths from 'vite-tsconfig-paths' +import tailwindcss from '@tailwindcss/vite' +import { nitroV2Plugin } from '@tanstack/nitro-v2-vite-plugin' + +const config = defineConfig({ + plugins: [ + nitroV2Plugin(), + viteTsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tailwindcss(), + tanstackStart(), + viteReact(), + ], +}) + +export default config From cf9e5a55c8ee12c09a5649f097d027aab559befd Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 3 Apr 2026 12:26:06 +0200 Subject: [PATCH 02/29] chore: update pnpm-lock.yaml for @tanstack/ai-e2e --- pnpm-lock.yaml | 110 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ce140529..70b1b6010 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1646,6 +1646,109 @@ importers: specifier: ^2.11.10 version: 2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + testing/e2e: + dependencies: + '@copilotkit/llmock': + specifier: latest + version: 1.6.1 + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.1.18(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/ai': + specifier: workspace:* + version: link:../../packages/typescript/ai + '@tanstack/ai-anthropic': + specifier: workspace:* + version: link:../../packages/typescript/ai-anthropic + '@tanstack/ai-client': + specifier: workspace:* + version: link:../../packages/typescript/ai-client + '@tanstack/ai-gemini': + specifier: workspace:* + version: link:../../packages/typescript/ai-gemini + '@tanstack/ai-grok': + specifier: workspace:* + version: link:../../packages/typescript/ai-grok + '@tanstack/ai-groq': + specifier: workspace:* + version: link:../../packages/typescript/ai-groq + '@tanstack/ai-ollama': + specifier: workspace:* + version: link:../../packages/typescript/ai-ollama + '@tanstack/ai-openai': + specifier: workspace:* + version: link:../../packages/typescript/ai-openai + '@tanstack/ai-openrouter': + specifier: workspace:* + version: link:../../packages/typescript/ai-openrouter + '@tanstack/ai-react': + specifier: workspace:* + version: link:../../packages/typescript/ai-react + '@tanstack/ai-react-ui': + specifier: workspace:* + version: link:../../packages/typescript/ai-react-ui + '@tanstack/nitro-v2-vite-plugin': + specifier: ^1.154.7 + version: 1.154.7(rolldown@1.0.0-beta.53)(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/react-router': + specifier: ^1.158.4 + version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-start': + specifier: ^1.159.0 + version: 1.159.5(crossws@0.4.4(srvx@0.11.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.7)(react@19.2.3) + rehype-highlight: + specifier: ^7.0.2 + version: 7.0.2 + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + zod: + specifier: ^4.2.0 + version: 4.3.6 + devDependencies: + '@playwright/test': + specifier: ^1.57.0 + version: 1.57.0 + '@types/node': + specifier: ^24.10.1 + version: 24.10.3 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^5.1.2 + version: 5.1.2(vite@7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: ^7.2.7 + version: 7.3.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + testing/panel: dependencies: '@tailwindcss/vite': @@ -2067,6 +2170,11 @@ packages: '@cloudflare/workers-types@4.20260317.1': resolution: {integrity: sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ==} + '@copilotkit/llmock@1.6.1': + resolution: {integrity: sha512-5Q8fuoIJ1zN7GYwuUjm6cF2xX4qYeHJOULNcJFPEB7NinaGZviIOfdjQaECVmeRWLjjRLky8DclP8hAoWDYnIQ==} + engines: {node: '>=20.15.0'} + hasBin: true + '@crazydos/vue-markdown@1.1.4': resolution: {integrity: sha512-0I1QMP59LJ3aEjE7bolgvPU4JAFt+pykdDo5674CbsCwFo7OVFos50+MPhGdWflCz1mac5t152lB1qvV/tR/rw==} engines: {node: '>=20.0.0'} @@ -11037,6 +11145,8 @@ snapshots: '@cloudflare/workers-types@4.20260317.1': {} + '@copilotkit/llmock@1.6.1': {} + '@crazydos/vue-markdown@1.1.4(vue@3.5.25(typescript@5.9.3))': dependencies: deepmerge: 4.3.1 From d9bb37550729449971b952a7410d21a245a7a912 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 3 Apr 2026 12:28:45 +0200 Subject: [PATCH 03/29] feat(e2e): add core library files - types, providers, features, tools --- testing/e2e/src/lib/feature-support.ts | 30 ++++++++ testing/e2e/src/lib/features.ts | 94 ++++++++++++++++++++++++++ testing/e2e/src/lib/guitar-data.ts | 40 +++++++++++ testing/e2e/src/lib/providers.ts | 38 +++++++++++ testing/e2e/src/lib/schemas.ts | 14 ++++ testing/e2e/src/lib/tools.ts | 85 +++++++++++++++++++++++ testing/e2e/src/lib/types.ts | 55 +++++++++++++++ 7 files changed, 356 insertions(+) create mode 100644 testing/e2e/src/lib/feature-support.ts create mode 100644 testing/e2e/src/lib/features.ts create mode 100644 testing/e2e/src/lib/guitar-data.ts create mode 100644 testing/e2e/src/lib/providers.ts create mode 100644 testing/e2e/src/lib/schemas.ts create mode 100644 testing/e2e/src/lib/tools.ts create mode 100644 testing/e2e/src/lib/types.ts diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts new file mode 100644 index 000000000..fbb268cca --- /dev/null +++ b/testing/e2e/src/lib/feature-support.ts @@ -0,0 +1,30 @@ +import type { Provider, Feature } from '@/lib/types' + +const matrix: Record> = { + 'chat': new Set(['openai', 'anthropic', 'gemini', 'ollama', 'groq', 'grok', 'openrouter']), + 'one-shot-text': new Set(['openai', 'anthropic', 'gemini', 'ollama', 'groq', 'grok', 'openrouter']), + 'reasoning': new Set(['openai', 'anthropic', 'gemini', 'grok', 'openrouter']), + 'multi-turn': new Set(['openai', 'anthropic', 'gemini', 'ollama', 'groq', 'grok', 'openrouter']), + 'tool-calling': new Set(['openai', 'anthropic', 'gemini', 'ollama', 'groq', 'grok', 'openrouter']), + 'parallel-tool-calls': new Set(['openai', 'anthropic', 'gemini', 'groq', 'grok', 'openrouter']), + 'tool-approval': new Set(['openai', 'anthropic', 'gemini', 'ollama', 'groq', 'grok', 'openrouter']), + 'structured-output': new Set(['openai', 'anthropic', 'gemini', 'ollama', 'groq', 'grok', 'openrouter']), + 'agentic-structured': new Set(['openai', 'anthropic', 'gemini', 'ollama', 'groq', 'grok', 'openrouter']), + 'multimodal-image': new Set(['openai', 'anthropic', 'gemini', 'grok', 'openrouter']), + 'multimodal-structured': new Set(['openai', 'anthropic', 'gemini', 'grok', 'openrouter']), + 'summarize': new Set(['openai', 'anthropic', 'gemini', 'ollama', 'grok', 'openrouter']), + 'summarize-stream': new Set(['openai', 'anthropic', 'gemini', 'ollama', 'grok', 'openrouter']), + 'image-gen': new Set(['openai', 'gemini', 'grok']), + 'tts': new Set(['openai', 'gemini']), + 'transcription': new Set(['openai']), +} + +export function isSupported(provider: Provider, feature: Feature): boolean { + return matrix[feature]?.has(provider) ?? false +} + +export function getSupportedFeatures(provider: Provider): Feature[] { + return (Object.entries(matrix) as Array<[Feature, Set]>) + .filter(([_, providers]) => providers.has(provider)) + .map(([feature]) => feature) +} diff --git a/testing/e2e/src/lib/features.ts b/testing/e2e/src/lib/features.ts new file mode 100644 index 000000000..4cb9abb02 --- /dev/null +++ b/testing/e2e/src/lib/features.ts @@ -0,0 +1,94 @@ +import type { Feature, Provider } from '@/lib/types' +import type { StandardSchemaV1 } from '@standard-schema/spec' +import { getGuitars, compareGuitars, addToCart } from '@/lib/tools' +import { guitarRecommendationSchema, imageAnalysisSchema } from '@/lib/schemas' + +interface FeatureConfig { + tools: Array + modelOptions: Record + modelOverrides?: Partial> + outputSchema?: StandardSchemaV1 + stream?: boolean + dedicatedRoute?: string +} + +export const featureConfigs: Record = { + 'chat': { + tools: [], + modelOptions: {}, + }, + 'one-shot-text': { + tools: [], + modelOptions: {}, + stream: false, + }, + 'reasoning': { + tools: [], + modelOptions: { reasoning: { effort: 'high' } }, + modelOverrides: { + openai: 'o3', + anthropic: 'claude-sonnet-4-5', + }, + }, + 'multi-turn': { + tools: [], + modelOptions: {}, + }, + 'tool-calling': { + tools: [getGuitars], + modelOptions: {}, + }, + 'parallel-tool-calls': { + tools: [getGuitars, compareGuitars], + modelOptions: {}, + }, + 'tool-approval': { + tools: [addToCart], + modelOptions: {}, + }, + 'structured-output': { + tools: [], + modelOptions: {}, + outputSchema: guitarRecommendationSchema, + }, + 'agentic-structured': { + tools: [getGuitars], + modelOptions: {}, + outputSchema: guitarRecommendationSchema, + }, + 'multimodal-image': { + tools: [], + modelOptions: {}, + }, + 'multimodal-structured': { + tools: [], + modelOptions: {}, + outputSchema: imageAnalysisSchema, + }, + 'summarize': { + tools: [], + modelOptions: {}, + stream: false, + dedicatedRoute: '/api/summarize', + }, + 'summarize-stream': { + tools: [], + modelOptions: {}, + dedicatedRoute: '/api/summarize', + }, + 'image-gen': { + tools: [], + modelOptions: {}, + dedicatedRoute: '/api/image', + }, + 'tts': { + tools: [], + modelOptions: {}, + dedicatedRoute: '/api/tts', + }, + 'transcription': { + tools: [], + modelOptions: {}, + dedicatedRoute: '/api/transcription', + }, +} diff --git a/testing/e2e/src/lib/guitar-data.ts b/testing/e2e/src/lib/guitar-data.ts new file mode 100644 index 000000000..818308c3b --- /dev/null +++ b/testing/e2e/src/lib/guitar-data.ts @@ -0,0 +1,40 @@ +export interface Guitar { + id: number + name: string + description: string + shortDescription: string + price: number +} + +const guitars: Array = [ + { + id: 1, + name: 'Fender Stratocaster', + description: 'A versatile electric guitar known for its bright, clear tone and comfortable playability. Perfect for blues, rock, and everything in between.', + shortDescription: 'Versatile electric guitar with bright, clear tone.', + price: 1299, + }, + { + id: 2, + name: 'Gibson Les Paul', + description: 'A classic electric guitar with a warm, thick tone. The mahogany body and humbucker pickups deliver powerful sustain and rich harmonics.', + shortDescription: 'Classic electric guitar with warm, thick tone.', + price: 2499, + }, + { + id: 3, + name: 'Taylor 814ce', + description: 'A premium acoustic-electric guitar with exceptional clarity and projection. The Grand Auditorium body shape is comfortable and versatile.', + shortDescription: 'Premium acoustic-electric with exceptional clarity.', + price: 3299, + }, + { + id: 4, + name: 'Martin D-28', + description: 'The quintessential dreadnought acoustic guitar. Rich bass, clear trebles, and incredible volume make it a legend among flat-top guitars.', + shortDescription: 'Legendary dreadnought acoustic with rich bass.', + price: 2999, + }, +] + +export default guitars diff --git a/testing/e2e/src/lib/providers.ts b/testing/e2e/src/lib/providers.ts new file mode 100644 index 000000000..254384d34 --- /dev/null +++ b/testing/e2e/src/lib/providers.ts @@ -0,0 +1,38 @@ +import type { AnyTextAdapter } from '@tanstack/ai' +import { createChatOptions } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { anthropicText } from '@tanstack/ai-anthropic' +import { geminiText } from '@tanstack/ai-gemini' +import { ollamaText } from '@tanstack/ai-ollama' +import { groqText } from '@tanstack/ai-groq' +import { grokText } from '@tanstack/ai-grok' +import { openRouterText } from '@tanstack/ai-openrouter' +import type { Provider } from '@/lib/types' + +const LLMOCK_URL = process.env.LLMOCK_URL || 'http://127.0.0.1:4010' + +const defaultModels: Record = { + openai: 'gpt-4o', + anthropic: 'claude-sonnet-4-5', + gemini: 'gemini-2.0-flash', + ollama: 'mistral', + groq: 'llama-3.3-70b-versatile', + grok: 'grok-3', + openrouter: 'openai/gpt-4o', +} + +export function createTextAdapter(provider: Provider, modelOverride?: string): { adapter: AnyTextAdapter } { + const model = modelOverride ?? defaultModels[provider] + + const factories: Record { adapter: AnyTextAdapter }> = { + openai: () => createChatOptions({ adapter: openaiText({ baseURL: LLMOCK_URL }, model as 'gpt-4o') }), + anthropic: () => createChatOptions({ adapter: anthropicText({ baseURL: LLMOCK_URL }, model as 'claude-sonnet-4-5') }), + gemini: () => createChatOptions({ adapter: geminiText({ baseURL: LLMOCK_URL }, model as 'gemini-2.0-flash') }), + ollama: () => createChatOptions({ adapter: ollamaText({ host: LLMOCK_URL }, model as 'mistral') }), + groq: () => createChatOptions({ adapter: groqText({ baseURL: LLMOCK_URL }, model as 'llama-3.3-70b-versatile') }), + grok: () => createChatOptions({ adapter: grokText({ baseURL: LLMOCK_URL }, model as 'grok-3') }), + openrouter: () => createChatOptions({ adapter: openRouterText({ baseURL: LLMOCK_URL }, model as 'openai/gpt-4o') }), + } + + return factories[provider]() +} diff --git a/testing/e2e/src/lib/schemas.ts b/testing/e2e/src/lib/schemas.ts new file mode 100644 index 000000000..b4b337192 --- /dev/null +++ b/testing/e2e/src/lib/schemas.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' + +export const guitarRecommendationSchema = z.object({ + name: z.string(), + price: z.number(), + reason: z.string(), + rating: z.number().min(1).max(5), +}) + +export const imageAnalysisSchema = z.object({ + description: z.string(), + objects: z.array(z.string()), + mood: z.string(), +}) diff --git a/testing/e2e/src/lib/tools.ts b/testing/e2e/src/lib/tools.ts new file mode 100644 index 000000000..fb3e091a1 --- /dev/null +++ b/testing/e2e/src/lib/tools.ts @@ -0,0 +1,85 @@ +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' +import guitars from '@/lib/guitar-data' + +export const getGuitarsToolDef = toolDefinition({ + name: 'getGuitars', + description: 'Get all guitars from inventory', + inputSchema: z.object({}), + outputSchema: z.array( + z.object({ + id: z.number(), + name: z.string(), + shortDescription: z.string(), + price: z.number(), + }), + ), +}) + +export const getGuitars = getGuitarsToolDef.server(() => { + return guitars.map((g) => ({ + id: g.id, + name: g.name, + shortDescription: g.shortDescription, + price: g.price, + })) +}) + +export const compareGuitarsToolDef = toolDefinition({ + name: 'compareGuitars', + description: 'Compare two or more guitars side by side', + inputSchema: z.object({ + guitarIds: z.array(z.number()).min(2), + }), + outputSchema: z.object({ + comparison: z.array( + z.object({ + id: z.number(), + name: z.string(), + price: z.number(), + }), + ), + cheapest: z.string(), + mostExpensive: z.string(), + }), +}) + +export const compareGuitars = compareGuitarsToolDef.server((args) => { + const selected = args.guitarIds + .map((id) => guitars.find((g) => g.id === id)) + .filter(Boolean) as typeof guitars + + const prices = selected.map((g) => g.price) + return { + comparison: selected.map((g) => ({ + id: g.id, + name: g.name, + price: g.price, + })), + cheapest: selected.find((g) => g.price === Math.min(...prices))!.name, + mostExpensive: selected.find((g) => g.price === Math.max(...prices))!.name, + } +}) + +export const addToCartToolDef = toolDefinition({ + name: 'addToCart', + description: 'Add a guitar to the shopping cart', + inputSchema: z.object({ + guitarId: z.string(), + quantity: z.number(), + }), + outputSchema: z.object({ + success: z.boolean(), + cartId: z.string(), + guitarId: z.string(), + quantity: z.number(), + }), + needsApproval: true, +}) + +export const addToCart = addToCartToolDef.server((args) => ({ + success: true, + cartId: 'CART_' + Date.now(), + guitarId: args.guitarId, + quantity: args.quantity, +})) diff --git a/testing/e2e/src/lib/types.ts b/testing/e2e/src/lib/types.ts new file mode 100644 index 000000000..93e3269ec --- /dev/null +++ b/testing/e2e/src/lib/types.ts @@ -0,0 +1,55 @@ +export type Provider = + | 'openai' + | 'anthropic' + | 'gemini' + | 'ollama' + | 'grok' + | 'groq' + | 'openrouter' + +export type Feature = + | 'chat' + | 'one-shot-text' + | 'reasoning' + | 'multi-turn' + | 'tool-calling' + | 'parallel-tool-calls' + | 'tool-approval' + | 'structured-output' + | 'agentic-structured' + | 'multimodal-image' + | 'multimodal-structured' + | 'summarize' + | 'summarize-stream' + | 'image-gen' + | 'tts' + | 'transcription' + +export const ALL_PROVIDERS: Provider[] = [ + 'openai', + 'anthropic', + 'gemini', + 'ollama', + 'grok', + 'groq', + 'openrouter', +] + +export const ALL_FEATURES: Feature[] = [ + 'chat', + 'one-shot-text', + 'reasoning', + 'multi-turn', + 'tool-calling', + 'parallel-tool-calls', + 'tool-approval', + 'structured-output', + 'agentic-structured', + 'multimodal-image', + 'multimodal-structured', + 'summarize', + 'summarize-stream', + 'image-gen', + 'tts', + 'transcription', +] From 2b23f8c8fb1ff42cbcee78d45edbd904c02fbc5d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 3 Apr 2026 12:32:01 +0200 Subject: [PATCH 04/29] feat(e2e): add root layout, landing pages, and NotSupported component --- testing/e2e/src/components/NotSupported.tsx | 10 ++++++ testing/e2e/src/routes/$provider/index.tsx | 35 +++++++++++++++++++++ testing/e2e/src/routes/__root.tsx | 31 ++++++++++++++++++ testing/e2e/src/routes/index.tsx | 30 ++++++++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 testing/e2e/src/components/NotSupported.tsx create mode 100644 testing/e2e/src/routes/$provider/index.tsx create mode 100644 testing/e2e/src/routes/__root.tsx create mode 100644 testing/e2e/src/routes/index.tsx diff --git a/testing/e2e/src/components/NotSupported.tsx b/testing/e2e/src/components/NotSupported.tsx new file mode 100644 index 000000000..78c390db8 --- /dev/null +++ b/testing/e2e/src/components/NotSupported.tsx @@ -0,0 +1,10 @@ +export function NotSupported({ provider, feature }: { provider: string; feature: string }) { + return ( +
+

+ {provider} does not support{' '} + {feature} +

+
+ ) +} diff --git a/testing/e2e/src/routes/$provider/index.tsx b/testing/e2e/src/routes/$provider/index.tsx new file mode 100644 index 000000000..1906087dc --- /dev/null +++ b/testing/e2e/src/routes/$provider/index.tsx @@ -0,0 +1,35 @@ +import { Link, createFileRoute } from '@tanstack/react-router' +import type { Provider } from '@/lib/types' +import { ALL_PROVIDERS } from '@/lib/types' +import { getSupportedFeatures } from '@/lib/feature-support' + +export const Route = createFileRoute('/$provider/')({ + component: ProviderPage, +}) + +function ProviderPage() { + const { provider } = Route.useParams() as { provider: Provider } + const features = getSupportedFeatures(provider) + + if (!ALL_PROVIDERS.includes(provider)) { + return
Unknown provider: {provider}
+ } + + return ( +
+

{provider} Features

+
+ {features.map((feature) => ( + + {feature} + + ))} +
+
+ ) +} diff --git a/testing/e2e/src/routes/__root.tsx b/testing/e2e/src/routes/__root.tsx new file mode 100644 index 000000000..b29b680b5 --- /dev/null +++ b/testing/e2e/src/routes/__root.tsx @@ -0,0 +1,31 @@ +import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' +import appCss from '../styles.css?url' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'TanStack AI E2E Tests - Guitar Store' }, + ], + links: [{ rel: 'stylesheet', href: appCss }], + }), + shellComponent: RootDocument, +}) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + +
+

Guitar Store E2E

+
+
{children}
+ + + + ) +} diff --git a/testing/e2e/src/routes/index.tsx b/testing/e2e/src/routes/index.tsx new file mode 100644 index 000000000..b7750fd75 --- /dev/null +++ b/testing/e2e/src/routes/index.tsx @@ -0,0 +1,30 @@ +import { Link, createFileRoute } from '@tanstack/react-router' +import { ALL_PROVIDERS } from '@/lib/types' +import { getSupportedFeatures } from '@/lib/feature-support' + +export const Route = createFileRoute('/')({ + component: IndexPage, +}) + +function IndexPage() { + return ( +
+

Providers

+
+ {ALL_PROVIDERS.map((provider) => ( + + {provider} + + {getSupportedFeatures(provider).length} features + + + ))} +
+
+ ) +} From 743e2faa00542c5c1aacdbe4502a7bf73e062422 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 3 Apr 2026 12:32:08 +0200 Subject: [PATCH 05/29] feat(e2e): add ChatUI, ToolCallDisplay, and ApprovalPrompt components --- testing/e2e/src/components/ApprovalPrompt.tsx | 41 ++++++ testing/e2e/src/components/ChatUI.tsx | 134 ++++++++++++++++++ .../e2e/src/components/ToolCallDisplay.tsx | 22 +++ 3 files changed, 197 insertions(+) create mode 100644 testing/e2e/src/components/ApprovalPrompt.tsx create mode 100644 testing/e2e/src/components/ChatUI.tsx create mode 100644 testing/e2e/src/components/ToolCallDisplay.tsx diff --git a/testing/e2e/src/components/ApprovalPrompt.tsx b/testing/e2e/src/components/ApprovalPrompt.tsx new file mode 100644 index 000000000..ea9b6bfb3 --- /dev/null +++ b/testing/e2e/src/components/ApprovalPrompt.tsx @@ -0,0 +1,41 @@ +interface ApprovalPart { + type: 'approval-requested' + id: string + toolName: string + args: Record +} + +export function ApprovalPrompt({ + part, + onRespond, +}: { + part: ApprovalPart + onRespond: (response: { id: string; approved: boolean }) => Promise +}) { + return ( +
+
+ Tool {part.toolName} requires approval +
+
+ Args: {JSON.stringify(part.args)} +
+
+ + +
+
+ ) +} diff --git a/testing/e2e/src/components/ChatUI.tsx b/testing/e2e/src/components/ChatUI.tsx new file mode 100644 index 000000000..59240abbe --- /dev/null +++ b/testing/e2e/src/components/ChatUI.tsx @@ -0,0 +1,134 @@ +import { useEffect, useRef, useState } from 'react' +import type { UIMessage } from '@tanstack/ai-react' +import ReactMarkdown from 'react-markdown' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize from 'rehype-sanitize' +import remarkGfm from 'remark-gfm' +import { ToolCallDisplay } from '@/components/ToolCallDisplay' +import { ApprovalPrompt } from '@/components/ApprovalPrompt' + +interface ChatUIProps { + messages: Array + isLoading: boolean + onSendMessage: (text: string) => void + onSendMessageWithImage?: (text: string, file: File) => void + addToolApprovalResponse?: (response: { id: string; approved: boolean }) => Promise + showImageInput?: boolean +} + +export function ChatUI({ + messages, + isLoading, + onSendMessage, + onSendMessageWithImage, + addToolApprovalResponse, + showImageInput, +}: ChatUIProps) { + const [input, setInput] = useState('') + const messagesRef = useRef(null) + + useEffect(() => { + if (messagesRef.current) { + messagesRef.current.scrollTop = messagesRef.current.scrollHeight + } + }, [messages]) + + const handleSubmit = () => { + if (!input.trim()) return + onSendMessage(input.trim()) + setInput('') + } + + return ( +
+
+ {messages.map((message) => ( +
+ {message.parts.map((part, i) => { + if (part.type === 'text') { + return ( +
+ + {part.text} + +
+ ) + } + if (part.type === 'thinking') { + return ( +
+ {part.thinking} +
+ ) + } + if (part.type === 'tool-call') { + return + } + if (part.type === 'approval-requested' && addToolApprovalResponse) { + return + } + return null + })} +
+ ))} +
+ + {isLoading && ( +
+ Generating... +
+ )} + +
+ {showImageInput && ( + { + const file = e.target.files?.[0] + if (file && input.trim() && onSendMessageWithImage) { + onSendMessageWithImage(input.trim(), file) + setInput('') + } + }} + /> + )} + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSubmit() + } + }} + placeholder="Type a message..." + className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/50" + /> + +
+
+ ) +} diff --git a/testing/e2e/src/components/ToolCallDisplay.tsx b/testing/e2e/src/components/ToolCallDisplay.tsx new file mode 100644 index 000000000..fd4e0e4fd --- /dev/null +++ b/testing/e2e/src/components/ToolCallDisplay.tsx @@ -0,0 +1,22 @@ +interface ToolCallPart { + type: 'tool-call' + toolName: string + args: Record + result?: any +} + +export function ToolCallDisplay({ part }: { part: ToolCallPart }) { + return ( +
+
{part.toolName}
+
+ Args: {JSON.stringify(part.args)} +
+ {part.result !== undefined && ( +
+ Result: {typeof part.result === 'string' ? part.result : JSON.stringify(part.result)} +
+ )} +
+ ) +} From 342b173f890301beb2cd407208eb1e6029233c9c Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 3 Apr 2026 12:32:14 +0200 Subject: [PATCH 06/29] feat(e2e): add media components - ImageDisplay, AudioPlayer, TranscriptionDisplay, SummarizeUI --- testing/e2e/src/components/AudioPlayer.tsx | 7 ++++ testing/e2e/src/components/ImageDisplay.tsx | 7 ++++ testing/e2e/src/components/SummarizeUI.tsx | 36 +++++++++++++++++++ .../src/components/TranscriptionDisplay.tsx | 9 +++++ 4 files changed, 59 insertions(+) create mode 100644 testing/e2e/src/components/AudioPlayer.tsx create mode 100644 testing/e2e/src/components/ImageDisplay.tsx create mode 100644 testing/e2e/src/components/SummarizeUI.tsx create mode 100644 testing/e2e/src/components/TranscriptionDisplay.tsx diff --git a/testing/e2e/src/components/AudioPlayer.tsx b/testing/e2e/src/components/AudioPlayer.tsx new file mode 100644 index 000000000..c62d8bd59 --- /dev/null +++ b/testing/e2e/src/components/AudioPlayer.tsx @@ -0,0 +1,7 @@ +export function AudioPlayer({ src }: { src: string }) { + return ( +
+
+ ) +} diff --git a/testing/e2e/src/components/ImageDisplay.tsx b/testing/e2e/src/components/ImageDisplay.tsx new file mode 100644 index 000000000..1557f2aab --- /dev/null +++ b/testing/e2e/src/components/ImageDisplay.tsx @@ -0,0 +1,7 @@ +export function ImageDisplay({ src, alt }: { src: string; alt?: string }) { + return ( +
+ {alt +
+ ) +} diff --git a/testing/e2e/src/components/SummarizeUI.tsx b/testing/e2e/src/components/SummarizeUI.tsx new file mode 100644 index 000000000..eadfc8e7c --- /dev/null +++ b/testing/e2e/src/components/SummarizeUI.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react' + +interface SummarizeUIProps { + onSubmit: (text: string) => void + result: string | null + isLoading: boolean +} + +export function SummarizeUI({ onSubmit, result, isLoading }: SummarizeUIProps) { + const [input, setInput] = useState('') + + return ( +
+