diff --git a/tests/evals/graders/__tests__/react-provider-mount.spec.ts b/tests/evals/graders/__tests__/react-provider-mount.spec.ts new file mode 100644 index 00000000..4da467f3 --- /dev/null +++ b/tests/evals/graders/__tests__/react-provider-mount.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { findMountedAuthKitProvider } from '../react.grader.js'; + +const PROVIDER = `import { AuthKitProvider } from '@workos-inc/authkit-react'; +export function AuthProvider({ children }) { + return {children}; +} +`; + +describe('findMountedAuthKitProvider', () => { + let workDir: string; + + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'react-grader-')); + await mkdir(join(workDir, 'src/auth'), { recursive: true }); + }); + + afterEach(async () => { + await rm(workDir, { recursive: true, force: true }); + }); + + it('accepts AuthKitProvider in the entry file', async () => { + await writeFile(join(workDir, 'src/main.tsx'), PROVIDER); + expect(await findMountedAuthKitProvider(workDir)).toBe('src/main.tsx'); + }); + + it('accepts a provider module imported by the entry file', async () => { + await writeFile(join(workDir, 'src/auth/AuthProvider.tsx'), PROVIDER); + await writeFile(join(workDir, 'src/main.tsx'), `import { AuthProvider } from './auth/AuthProvider';\n`); + expect(await findMountedAuthKitProvider(workDir)).toBe('src/auth/AuthProvider.tsx'); + }); + + it('accepts a provider module imported via the @/ alias from another component', async () => { + await writeFile(join(workDir, 'src/auth/index.tsx'), PROVIDER); + await writeFile(join(workDir, 'src/App.tsx'), `import { AuthProvider } from '@/auth';\n`); + expect(await findMountedAuthKitProvider(workDir)).toBe('src/auth/index.tsx'); + }); + + it('rejects a provider module nothing imports', async () => { + await writeFile(join(workDir, 'src/auth/AuthProvider.tsx'), PROVIDER); + await writeFile(join(workDir, 'src/main.tsx'), `import App from './App';\n`); + expect(await findMountedAuthKitProvider(workDir)).toBeNull(); + }); + + it('rejects a provider from a different SDK', async () => { + await writeFile(join(workDir, 'src/main.tsx'), `import { AuthKitProvider } from '@workos-inc/authkit-js';\n`); + expect(await findMountedAuthKitProvider(workDir)).toBeNull(); + }); +}); diff --git a/tests/evals/graders/nextjs.grader.ts b/tests/evals/graders/nextjs.grader.ts index 6caf7374..f4c3fd74 100644 --- a/tests/evals/graders/nextjs.grader.ts +++ b/tests/evals/graders/nextjs.grader.ts @@ -113,11 +113,11 @@ export class NextjsGrader implements Grader { const sdkImportChecks = await this.fileGrader.checkFileContains(middlewareFile, ['@workos-inc/authkit-nextjs']); checks.push(...sdkImportChecks); - // Check for authkit integration: authkitMiddleware OR (authkit + handleAuthkitHeaders) + // Check for authkit integration: authkitMiddleware OR (authkit + handleAuthkitProxy, or its deprecated alias handleAuthkitHeaders) const middlewareChecks = await this.fileGrader.checkFileContains(middlewareFile, ['authkitMiddleware']); const composableChecks = await this.fileGrader.checkFileContains(middlewareFile, [ 'authkit(', - 'handleAuthkitHeaders', + /handleAuthkit(Proxy|Headers)\b/, ]); const usesAuthkitMiddleware = middlewareChecks.every((c) => c.passed); @@ -129,7 +129,7 @@ export class NextjsGrader implements Grader { message: usesAuthkitMiddleware ? 'Uses authkitMiddleware' : usesComposable - ? 'Uses authkit() composable with handleAuthkitHeaders' + ? 'Uses authkit() composable with handleAuthkitProxy' : 'Missing authkitMiddleware or authkit() composable integration', }; checks.push(authkitCheck); diff --git a/tests/evals/graders/react.grader.ts b/tests/evals/graders/react.grader.ts index 86779a67..2f47c138 100644 --- a/tests/evals/graders/react.grader.ts +++ b/tests/evals/graders/react.grader.ts @@ -1,3 +1,6 @@ +import fg from 'fast-glob'; +import { readFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; import { FileGrader } from './file-grader.js'; import { BuildGrader } from './build-grader.js'; import type { Grader, GradeResult, GradeCheck } from '../types.js'; @@ -18,7 +21,7 @@ export class ReactGrader implements Grader { private fileGrader: FileGrader; private buildGrader: BuildGrader; - constructor(workDir: string) { + constructor(private workDir: string) { this.fileGrader = new FileGrader(workDir); this.buildGrader = new BuildGrader(workDir); } @@ -26,15 +29,15 @@ export class ReactGrader implements Grader { async grade(): Promise { const checks: GradeCheck[] = []; - // Check AuthKitProvider wrapper exists in entry file - // Can be main.tsx (Vite) or index.tsx (CRA) - checks.push( - await this.fileGrader.checkFileWithPattern( - 'src/{main,index}.tsx', - ['AuthKitProvider', '@workos-inc/authkit-react'], - 'AuthKitProvider configured with correct SDK', - ), - ); + // AuthKitProvider may live in the entry file or an app-owned provider module, but it must be mounted + const provider = await findMountedAuthKitProvider(this.workDir); + checks.push({ + name: 'AuthKitProvider configured with correct SDK', + passed: provider !== null, + message: provider + ? `Found in: ${provider}` + : 'No mounted AuthKitProvider from @workos-inc/authkit-react (entry file or a module imported elsewhere in src/)', + }); // Check useAuth hook usage somewhere in the app // Can be in App.tsx, pages/, components/, or anywhere @@ -46,12 +49,11 @@ export class ReactGrader implements Grader { ), ); - // Check environment config in entry file - // Supports both Vite (import.meta.env) and CRA (process.env) + // Client ID comes from build-time env (Vite or CRA), read anywhere under src/ checks.push( await this.fileGrader.checkFileWithPattern( - 'src/{main,index}.tsx', - [/VITE_WORKOS_CLIENT_ID|REACT_APP_WORKOS_CLIENT_ID|import\.meta\.env|process\.env/], + 'src/**/*.{ts,tsx,js,jsx}', + [/(VITE|REACT_APP)_WORKOS_CLIENT_ID/], 'Environment variable configuration', ), ); @@ -65,3 +67,37 @@ export class ReactGrader implements Grader { }; } } + +const SOURCE_EXT = /\.(tsx|ts|jsx|js)$/; +const IMPORT_SPEC = /(?:from\s+|import\s*\(\s*|import\s+)['"]([^'"]+)['"]/g; + +/** + * Returns the src-relative file that defines AuthKitProvider when it is mounted: + * either it is the entry file, or another src file imports its module. + */ +export async function findMountedAuthKitProvider(workDir: string): Promise { + const files = await fg('src/**/*.{tsx,ts,jsx,js}', { cwd: workDir }); + const contents = new Map(); + for (const file of files) contents.set(file, await readFile(join(workDir, file), 'utf-8')); + + const providers = files.filter((f) => { + const c = contents.get(f)!; + return c.includes('AuthKitProvider') && c.includes('@workos-inc/authkit-react'); + }); + + for (const provider of providers) { + if (/^src\/(main|index)\.(tsx|jsx)$/.test(provider)) return provider; + const target = provider.replace(SOURCE_EXT, '').replace(/\/index$/, ''); + for (const [importer, content] of contents) { + if (importer === provider) continue; + for (const [, spec] of content.matchAll(IMPORT_SPEC)) { + let resolved: string | null = null; + if (spec.startsWith('.')) resolved = relative(workDir, resolve(workDir, dirname(importer), spec)); + else if (spec.startsWith('@/')) resolved = join('src', spec.slice(2)); + else if (spec.startsWith('src/')) resolved = spec; + if (resolved && resolved.replace(SOURCE_EXT, '').replace(/\/index$/, '') === target) return provider; + } + } + } + return null; +}