From c828d6ee418405ad961b06b82ee7a16237cb2b76 Mon Sep 17 00:00:00 2001 From: mat lenhard Date: Tue, 9 Dec 2025 12:16:17 -0800 Subject: [PATCH] [feat] tests should be format agnostic ie: no regex test at runtime --- .../input/app/page.test.tsx | 94 +++++---- .../input/app/page.test.tsx | 72 ++++--- .../input/app/page.test.tsx | 196 ++++++++++-------- .../input/app/page.test.tsx | 129 +++++------- 4 files changed, 243 insertions(+), 248 deletions(-) diff --git a/evals/001-server-component/input/app/page.test.tsx b/evals/001-server-component/input/app/page.test.tsx index cddadaf2b..d4a0c59b6 100644 --- a/evals/001-server-component/input/app/page.test.tsx +++ b/evals/001-server-component/input/app/page.test.tsx @@ -1,49 +1,59 @@ -import { expect, test } from 'vitest'; -import { readFileSync } from 'fs'; -import { join } from 'path'; - -test('Page is an async server component', () => { - const pageContent = readFileSync(join(process.cwd(), 'app', 'page.tsx'), 'utf-8'); - - // Should be an async function (server component) - expect(pageContent).toMatch(/export\s+default\s+async\s+function|async\s+function.*Page/); - - // Should NOT have 'use client' directive - expect(pageContent).not.toMatch(/['"]use client['"];?/); +import { expect, test, vi, beforeEach } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import Page from './page'; + +// Mock fetch to test server component behavior +const mockProducts = [ + { id: 1, name: 'First Product' }, + { id: 2, name: 'Second Product' }, + { id: 3, name: 'Third Product' }, +]; + +beforeEach(() => { + global.fetch = vi.fn(() => + Promise.resolve({ + json: () => Promise.resolve(mockProducts), + }) + ) as any; }); -test('Page fetches from correct API endpoint', () => { - const pageContent = readFileSync(join(process.cwd(), 'app', 'page.tsx'), 'utf-8'); - - // Should fetch from the correct URL - expect(pageContent).toMatch(/api\.vercel\.app\/products/); - - // Should use fetch - expect(pageContent).toMatch(/fetch\s*\(/); - - // Should use await for the fetch - expect(pageContent).toMatch(/await.*fetch|fetch.*await/); +test('Page component is async (server component)', async () => { + // Server components must be async functions + expect(Page.constructor.name).toBe('AsyncFunction'); }); -test('Page renders first product in h1 tag', () => { - const pageContent = readFileSync(join(process.cwd(), 'app', 'page.tsx'), 'utf-8'); - - // Should access first product (array[0] or similar) - expect(pageContent).toMatch(/\[0\]|\bfirst\b|\.at\(0\)/i); - - // Should render in h1 tag - expect(pageContent).toMatch(/]*>.*<\/h1>/); - - // Should access the product name property - expect(pageContent).toMatch(/\.name\b/); +test('Page fetches data and renders first product', async () => { + // Call the async server component + const result = await Page(); + + // Render the component to HTML + const html = renderToString(result); + + // Should render the first product name in an h1 + expect(html).toContain(' { - const pageContent = readFileSync(join(process.cwd(), 'app', 'page.tsx'), 'utf-8'); - - // Should parse JSON response - expect(pageContent).toMatch(/\.json\(\)/); - - // Should await the JSON parsing - expect(pageContent).toMatch(/await.*\.json\(\)|\.json\(\).*await/); +test('Page calls fetch with correct API endpoint', async () => { + await Page(); + + // Verify fetch was called with the correct URL + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('api.vercel.app/products'), + expect.anything() + ); +}); + +test('Page renders h1 element with product name', async () => { + const result = await Page(); + const html = renderToString(result); + + // Check that h1 contains the product name + const h1Match = html.match(/]*>(.*?)<\/h1>/); + expect(h1Match).toBeTruthy(); + expect(h1Match?.[1]).toContain('First Product'); }); diff --git a/evals/022-prefer-server-actions/input/app/page.test.tsx b/evals/022-prefer-server-actions/input/app/page.test.tsx index 145c07261..0c465243a 100644 --- a/evals/022-prefer-server-actions/input/app/page.test.tsx +++ b/evals/022-prefer-server-actions/input/app/page.test.tsx @@ -1,49 +1,55 @@ import { expect, test } from 'vitest'; import { render, screen } from '@testing-library/react'; -import { readFileSync } from 'fs'; -import { join } from 'path'; import Page from './page'; -test('renders contact form component', () => { +test('renders contact form with heading', () => { render(); expect(screen.getByText('Contact Us')).toBeDefined(); }); -test('uses server action instead of client-side submission', () => { - const content = readFileSync(join(process.cwd(), 'app', 'ContactForm.tsx'), 'utf-8'); - - expect(content).not.toMatch(/['"]use client['"];?/); - expect(content).not.toMatch(/onSubmit|fetch\s*\(|useState|preventDefault/); - expect(content).toMatch(/['"]use server['"];?/); - expect(content).toMatch(/async\s+function\s+\w+.*FormData/); -}); +test('form has all required input fields', () => { + render(); + + // Check for name input + const nameInput = screen.getByLabelText(/name/i) || screen.getByPlaceholderText(/name/i); + expect(nameInput).toBeDefined(); + expect(nameInput.getAttribute('name')).toBe('name'); -test('processes form data using FormData API', () => { - const content = readFileSync(join(process.cwd(), 'app', 'ContactForm.tsx'), 'utf-8'); - - expect(content).toMatch(/formData\.get\s*\(\s*['"]name['"]\s*\)/); - expect(content).toMatch(/formData\.get\s*\(\s*['"]email['"]\s*\)/); - expect(content).toMatch(/formData\.get\s*\(\s*['"]message['"]\s*\)/); + // Check for email input + const emailInput = screen.getByLabelText(/email/i) || screen.getByPlaceholderText(/email/i); + expect(emailInput).toBeDefined(); + expect(emailInput.getAttribute('name')).toBe('email'); + + // Check for message input (textarea or input) + const messageInput = screen.getByLabelText(/message/i) || screen.getByPlaceholderText(/message/i); + expect(messageInput).toBeDefined(); + expect(messageInput.getAttribute('name')).toBe('message'); }); -test('has proper form structure with action attribute', () => { - const content = readFileSync(join(process.cwd(), 'app', 'ContactForm.tsx'), 'utf-8'); - - expect(content).toMatch(/]*action\s*=\s*{[^}]+}/); - expect(content).toMatch(/name\s*=\s*['"]name['"]/); - expect(content).toMatch(/name\s*=\s*['"]email['"]/); - expect(content).toMatch(/name\s*=\s*['"]message['"]/); - expect(content).toMatch(/type\s*=\s*['"]submit['"]/); +test('form has submit button', () => { + render(); + + const submitButton = screen.getByRole('button', { name: /submit/i }); + expect(submitButton).toBeDefined(); + expect(submitButton.getAttribute('type')).toBe('submit'); }); -test('includes form validation', () => { - const content = readFileSync(join(process.cwd(), 'app', 'ContactForm.tsx'), 'utf-8'); - - expect(content).toMatch(/!name\s*\|\|\s*!email\s*\|\|\s*!message|if\s*\([^)]*(!name|!email|!message)/); +test('form uses server action (has action attribute)', () => { + render(); + + // Get the form element + const form = screen.getByRole('form') || document.querySelector('form'); + expect(form).toBeDefined(); + + // Server actions are passed as the action prop - should exist + expect(form?.getAttribute('action')).toBeTruthy(); }); -test('does not use API routes pattern', () => { - const content = readFileSync(join(process.cwd(), 'app', 'ContactForm.tsx'), 'utf-8'); - - expect(content).not.toMatch(/\/api\/\w+|JSON\.stringify|response\.json\(\)/); +test('form does not use client-side event handlers', () => { + render(); + + const form = screen.getByRole('form') || document.querySelector('form'); + + // Server action forms should NOT have onSubmit handlers + expect(form?.getAttribute('onsubmit')).toBeNull(); }); diff --git a/evals/030-app-router-migration-hard/input/app/page.test.tsx b/evals/030-app-router-migration-hard/input/app/page.test.tsx index 5722cd8a8..49f964ec6 100644 --- a/evals/030-app-router-migration-hard/input/app/page.test.tsx +++ b/evals/030-app-router-migration-hard/input/app/page.test.tsx @@ -1,99 +1,109 @@ -import { expect, test } from 'vitest'; -import { readFileSync, existsSync } from 'fs'; +import { expect, test, vi, beforeEach } from 'vitest'; +import { existsSync } from 'fs'; import { join } from 'path'; +import { renderToString } from 'react-dom/server'; + +// Mock fetch for server components +beforeEach(() => { + global.fetch = vi.fn(() => + Promise.resolve({ + json: () => Promise.resolve([ + { id: 1, title: 'Post 1', content: 'Content 1' }, + { id: 2, title: 'Post 2', content: 'Content 2' }, + ]), + }) + ) as any; +}); -test('Root layout exists and replaces _app/_document', () => { +test('Root layout exists and replaces _app/_document', async () => { const layoutPath = join(process.cwd(), 'app', 'layout.tsx'); expect(existsSync(layoutPath)).toBe(true); - const layoutContent = readFileSync(layoutPath, 'utf-8'); - - // Should have html and body tags (replacing _document.js) - expect(layoutContent).toMatch(/ +
Test content
+ + ); - // Should accept children prop - expect(layoutContent).toMatch(/children.*React\.ReactNode/); + // Should render html and body tags + expect(html).toContain(' { +test('Home page migrated to Server Component with async data fetching', async () => { const pagePath = join(process.cwd(), 'app', 'page.tsx'); expect(existsSync(pagePath)).toBe(true); - const pageContent = readFileSync(pagePath, 'utf-8'); + // Import and call the page component + const Page = (await import('./page')).default; - // Should be async Server Component - expect(pageContent).toMatch( - /export\s+default\s+async\s+function|async\s+function.*Page/ - ); + // Should be async (server component pattern) + expect(Page.constructor.name).toBe('AsyncFunction'); - // Should NOT have 'use client' directive - expect(pageContent).not.toMatch(/['"]use client['"];?/); + // Call the async component and verify it renders + const result = await Page(); + const html = renderToString(result); - // Should use fetch instead of getServerSideProps - expect(pageContent).toMatch(/await\s+fetch|fetch\(/); - - // Should not have getServerSideProps - expect(pageContent).not.toMatch(/getServerSideProps/); + // Should render content successfully + expect(html.length).toBeGreaterThan(0); }); -test('Blog index migrated with ISR equivalent', () => { +test('Blog index migrated with ISR equivalent', async () => { const blogPath = join(process.cwd(), 'app', 'blog', 'page.tsx'); expect(existsSync(blogPath)).toBe(true); - const blogContent = readFileSync(blogPath, 'utf-8'); + // Import and verify the blog page + const BlogPage = (await import('./blog/page')).default; - // Should be async Server Component - expect(blogContent).toMatch( - /export\s+default\s+async\s+function|async\s+function/ - ); + // Should be async (server component) + expect(BlogPage.constructor.name).toBe('AsyncFunction'); - // Should use revalidate for ISR - expect(blogContent).toMatch( - /revalidate.*\d+|next.*revalidate|export.*const.*revalidate.*=.*\d+/ - ); + // Call and render the component + const result = await BlogPage(); + const html = renderToString(result); - // Should not have getStaticProps - expect(blogContent).not.toMatch(/getStaticProps/); + // Should render successfully + expect(html.length).toBeGreaterThan(0); }); -test('Dynamic blog route migrated to generateStaticParams', () => { +test('Dynamic blog route migrated to generateStaticParams', async () => { const dynamicPath = join(process.cwd(), 'app', 'blog', '[id]', 'page.tsx'); expect(existsSync(dynamicPath)).toBe(true); - const dynamicContent = readFileSync(dynamicPath, 'utf-8'); + // Import the dynamic page + const module = await import('./blog/[id]/page'); + const DynamicPage = module.default; - // Should export generateStaticParams - expect(dynamicContent).toMatch( - /export.*generateStaticParams|generateStaticParams.*export/ - ); + // Should have generateStaticParams function exported + expect(module.generateStaticParams).toBeDefined(); + expect(typeof module.generateStaticParams).toBe('function'); - // Should be async Server Component - expect(dynamicContent).toMatch( - /export\s+default\s+async\s+function|async\s+function/ - ); + // Should be async server component + expect(DynamicPage.constructor.name).toBe('AsyncFunction'); + + // Test with a params object + const result = await DynamicPage({ params: { id: '1' } }); + const html = renderToString(result); - // Should not have getStaticPaths or getStaticProps - expect(dynamicContent).not.toMatch(/getStaticPaths|getStaticProps/); + // Should render successfully + expect(html.length).toBeGreaterThan(0); }); -test('API routes migrated to Route Handlers', () => { +test('API routes migrated to Route Handlers', async () => { // Check posts index route const postsRoutePath = join(process.cwd(), 'app', 'api', 'posts', 'route.ts'); expect(existsSync(postsRoutePath)).toBe(true); - const postsRouteContent = readFileSync(postsRoutePath, 'utf-8'); + // Import and test the route handler + const postsRoute = await import('./api/posts/route'); - // Should export HTTP method functions - expect(postsRouteContent).toMatch(/export.*GET|export.*POST/); - - // Should use Request/Response or Next APIs - expect(postsRouteContent).toMatch( - /Request|Response|NextRequest|NextResponse/ - ); + // Should export GET or POST functions + const hasGetOrPost = postsRoute.GET !== undefined || postsRoute.POST !== undefined; + expect(hasGetOrPost).toBe(true); // Check dynamic API route const dynamicApiPath = join( @@ -106,60 +116,62 @@ test('API routes migrated to Route Handlers', () => { ); expect(existsSync(dynamicApiPath)).toBe(true); - const dynamicApiContent = readFileSync(dynamicApiPath, 'utf-8'); + // Import and test dynamic route + const dynamicRoute = await import('./api/posts/[id]/route'); - // Should export HTTP methods - expect(dynamicApiContent).toMatch(/export.*GET|export.*PUT|export.*DELETE/); + // Should export GET, PUT, or DELETE + const hasMethods = + dynamicRoute.GET !== undefined || + dynamicRoute.PUT !== undefined || + dynamicRoute.DELETE !== undefined; + expect(hasMethods).toBe(true); }); -test('Metadata API replaces next/head', () => { +test('Metadata API used in pages', async () => { const pagePath = join(process.cwd(), 'app', 'page.tsx'); - const pageContent = readFileSync(pagePath, 'utf-8'); + expect(existsSync(pagePath)).toBe(true); - // Should use Metadata export instead of Head component - expect(pageContent).toMatch(/export.*metadata|metadata.*Metadata/); + // Import and check for metadata export + const pageModule = await import('./page'); - // Should not import or use next/head - expect(pageContent).not.toMatch(/import.*Head.*next\/head|/); + // Should have metadata export (object or function) + expect(pageModule.metadata || pageModule.generateMetadata).toBeDefined(); // Check blog page too const blogPath = join(process.cwd(), 'app', 'blog', 'page.tsx'); if (existsSync(blogPath)) { - const blogContent = readFileSync(blogPath, 'utf-8'); - expect(blogContent).toMatch(/export.*metadata|metadata.*Metadata/); - expect(blogContent).not.toMatch(/import.*Head.*next\/head|/); + const blogModule = await import('./blog/page'); + expect(blogModule.metadata || blogModule.generateMetadata).toBeDefined(); } }); -test('Error handling migrated to error.js and not-found.js', () => { - // Check for error.js file +test('Error handling migrated to error.tsx and not-found.tsx', async () => { + // Check for error.tsx file const errorPath = join(process.cwd(), 'app', 'error.tsx'); expect(existsSync(errorPath)).toBe(true); - const errorContent = readFileSync(errorPath, 'utf-8'); + // Import and test error component + const ErrorComponent = (await import('./error')).default; - // Should be a Client Component for error boundaries - expect(errorContent).toMatch(/['"]use client['"];?/); + // Should render with error props + const mockError = new Error('Test error'); + const mockReset = vi.fn(); - // Should accept error props - expect(errorContent).toMatch(/error.*Error|Error.*error/); + const html = renderToString(); + expect(html.length).toBeGreaterThan(0); - // Check for not-found.js file + // Check for not-found.tsx file const notFoundPath = join(process.cwd(), 'app', 'not-found.tsx'); expect(existsSync(notFoundPath)).toBe(true); -}); - -test('Client components use next/navigation hooks', () => { - // Check specific client component that should use useRouter - const homeClientPath = join(process.cwd(), 'app', 'home-client.tsx'); - if (existsSync(homeClientPath)) { - const content = readFileSync(homeClientPath, 'utf-8'); + // Import and test not-found component + const NotFoundComponent = (await import('./not-found')).default; + const notFoundHtml = renderToString(); + expect(notFoundHtml.length).toBeGreaterThan(0); +}); - if (content.includes('useRouter')) { - // Should import from next/navigation, not next/router - expect(content).toMatch(/import.*useRouter.*next\/navigation/); - expect(content).not.toMatch(/import.*useRouter.*next\/router/); - } - } +test('Pages directory is removed', () => { + // The pages directory should be completely removed after migration + const pagesPath = join(process.cwd(), 'pages'); + expect(existsSync(pagesPath)).toBe(false); }); diff --git a/evals/031-ai-sdk-migration-simple/input/app/page.test.tsx b/evals/031-ai-sdk-migration-simple/input/app/page.test.tsx index 436f8f639..8bb8a58b4 100644 --- a/evals/031-ai-sdk-migration-simple/input/app/page.test.tsx +++ b/evals/031-ai-sdk-migration-simple/input/app/page.test.tsx @@ -1,9 +1,10 @@ import { afterEach, expect, test, vi } from 'vitest'; -import { readFileSync, existsSync } from 'fs'; -import { join } from 'path'; import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import Page from './page'; +// Mock the AI SDK hook to test component behavior +const mockSendMessage = vi.fn(); + vi.mock('@ai-sdk/react', () => ({ useChat: () => ({ messages: [ @@ -11,107 +12,73 @@ vi.mock('@ai-sdk/react', () => ({ id: '1', parts: [{ type: 'text', text: 'Test message' }], }, + { + id: '2', + parts: [{ type: 'text', text: 'Another message' }], + }, ], - sendMessage: vi.fn(), + sendMessage: mockSendMessage, }), })); -afterEach(()=>{ - cleanup() -}) - -test('AI SDK Chat Route handler exists in correct location', () => { - const apiRoutePath = join(process.cwd(), 'app', 'api', 'chat', 'route.ts'); - const apiRouteExists = existsSync(apiRoutePath); - - expect(apiRouteExists).toBe(true); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); }); -test('AI SDK Chat Route handler uses correct imports and exports', () => { - const apiRoutePath = join(process.cwd(), 'app', 'api', 'chat', 'route.ts'); - - if (!existsSync(apiRoutePath)) { - throw new Error('Route handler file does not exist'); - } +test('Chat component renders messages with parts-based structure', () => { + render(); - const routeContent = readFileSync(apiRoutePath, 'utf-8'); + // Should render messages from the v5 parts-based structure + expect(screen.getByText('Test message')).toBeDefined(); + expect(screen.getByText('Another message')).toBeDefined(); +}); - // Should import required functions from AI SDK - expect(routeContent).toMatch(/import.*convertToModelMessages.*from\s+['"]ai['"]/); - expect(routeContent).toMatch(/import.*streamText.*from\s+['"]ai['"]/); - expect(routeContent).toMatch(/import.*type\s+UIMessage.*from\s+['"]ai['"]/); +test('Chat component has input field', () => { + render(); - // Should export POST function - expect(routeContent).toMatch(/export\s+(async\s+)?function\s+POST/); + // Should have an input field for user to type messages + const input = screen.getByRole('textbox'); + expect(input).toBeDefined(); }); -test('AI SDK Chat Route handler uses correct configuration', () => { - const apiRoutePath = join(process.cwd(), 'app', 'api', 'chat', 'route.ts'); - - if (!existsSync(apiRoutePath)) { - throw new Error('Route handler file does not exist'); - } +test('Chat component handles input changes', () => { + render(); - const routeContent = readFileSync(apiRoutePath, 'utf-8'); + const inputs = screen.getAllByRole('textbox') as HTMLInputElement[]; + const input = inputs[0]; - // Should use streamText with correct configuration - expect(routeContent).toMatch(/streamText\s*\(\s*\{/); - expect(routeContent).toMatch(/model:\s*['"]openai\/gpt-4o['"]/); - expect(routeContent).toMatch(/system:\s*['"]You are a helpful assistant\./); - expect(routeContent).toMatch(/messages:\s*convertToModelMessages\s*\(\s*messages\s*\)/); - expect(routeContent).toMatch(/result\.toUIMessageStreamResponse\s*\(\s*\)/); + // Should be able to type in the input + fireEvent.change(input, { target: { value: 'Hello AI' } }); + expect(input.value).toBe('Hello AI'); }); -test('AI SDK Chat Route handler does not await streamText', () => { - const apiRoutePath = join(process.cwd(), 'app', 'api', 'chat', 'route.ts'); +test('Chat component can send messages', () => { + render(); + + const input = screen.getAllByRole('textbox')[0] as HTMLInputElement; + const submitButton = screen.getByRole('button'); - if (!existsSync(apiRoutePath)) { - throw new Error('Route handler file does not exist'); - } + // Type a message + fireEvent.change(input, { target: { value: 'Hello' } }); - const routeContent = readFileSync(apiRoutePath, 'utf-8'); + // Submit the form + fireEvent.click(submitButton); - // Should not have await before streamText - expect(routeContent).not.toMatch(/await\s+streamText/); - // Double check the streamText is actually called - expect(routeContent).toMatch(/const\s+result\s*=\s*streamText/); + // Should call sendMessage from useChat hook + expect(mockSendMessage).toHaveBeenCalled(); }); -test('Chat component renders input and messages', () => { +test('Chat component clears input after sending', () => { render(); - - // Check if input exists - const input = screen.getByRole('textbox'); - expect(input).toBeDefined(); - // Check if test message is rendered - const message = screen.getByText('Test message'); - expect(message).toBeDefined(); -}); + const input = screen.getAllByRole('textbox')[0] as HTMLInputElement; + const submitButton = screen.getByRole('button'); -test('useChat hook is used with messages and sendMessage', () => { - const pageContent = readFileSync(join(process.cwd(), 'app', 'page.tsx'), 'utf-8'); - - // Check if useChat is imported - expect(pageContent).toMatch(/import\s*{\s*useChat\s*}\s*from\s*['"]@ai-sdk\/react['"]/); -}); + // Type and send a message + fireEvent.change(input, { target: { value: 'Test message' } }); + fireEvent.click(submitButton); -test('useChat hook uses correct destructuring pattern', () => { - const pageContent = readFileSync(join(process.cwd(), 'app', 'page.tsx'), 'utf-8'); - - // Should not use the old destructuring pattern - expect(pageContent).not.toMatch(/const\s*{\s*messages,\s*input,\s*setInput,\s*append\s*}\s*=\s*useChat\s*\(\s*\)/); - - // Should use the new destructuring pattern with configuration object - expect(pageContent).toMatch(/const\s*{\s*messages,\s*sendMessage\s*}\s*=\s*useChat\s*\(\s*{/); -}); - -test('Chat component handles input changes', () => { - render(); - - const inputs = screen.getAllByRole('textbox') as HTMLInputElement[]; - const input = inputs[0]; - fireEvent.change(input, { target: { value: 'Hello' } }); - - expect(input.value).toBe('Hello'); + // Input should be cleared after sending + expect(input.value).toBe(''); }); \ No newline at end of file