diff --git a/code/core/src/core-server/server-channel/ghost-stories-channel.test.ts b/code/core/src/core-server/server-channel/ghost-stories-channel.test.ts index baec931b2e2d..fd452e588281 100644 --- a/code/core/src/core-server/server-channel/ghost-stories-channel.test.ts +++ b/code/core/src/core-server/server-channel/ghost-stories-channel.test.ts @@ -137,7 +137,6 @@ describe('ghostStoriesChannel', () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFile.mockResolvedValue( JSON.stringify({ - success: true, numTotalTests: 2, numPassedTests: 2, numFailedTests: 0, @@ -186,7 +185,6 @@ describe('ghostStoriesChannel', () => { // Telemetry is called with the correct data expect(mockTelemetry.telemetry).toHaveBeenCalledWith('ghost-stories', { - success: true, stats: { globMatchCount: 10, candidateAnalysisDuration: expect.any(Number), @@ -199,8 +197,6 @@ describe('ghostStoriesChannel', () => { results: { total: 2, passed: 2, - failed: 0, - failureRate: 0, successRate: 1, successRateWithoutEmptyRender: 1, categorizedErrors: expect.any(Object), @@ -238,7 +234,6 @@ describe('ghostStoriesChannel', () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFile.mockResolvedValue( JSON.stringify({ - success: false, numTotalTests: 2, numPassedTests: 0, numFailedTests: 2, @@ -291,7 +286,6 @@ describe('ghostStoriesChannel', () => { expect(mockTelemetry.telemetry).toHaveBeenCalledWith( 'ghost-stories', expect.objectContaining({ - success: false, stats: { globMatchCount: 10, candidateAnalysisDuration: expect.any(Number), @@ -304,8 +298,6 @@ describe('ghostStoriesChannel', () => { results: expect.objectContaining({ total: 2, passed: 0, - failed: 2, - failureRate: 1, successRate: 0, // categorizedErrors is now an object with categories as keys categorizedErrors: expect.any(Object), @@ -437,8 +429,7 @@ describe('ghostStoriesChannel', () => { expect(mockStoryGeneration.getComponentCandidates).toHaveBeenCalled(); expect(mockTelemetry.telemetry).toHaveBeenCalledWith('ghost-stories', { - success: false, - error: 'Failed to analyze components', + runError: 'Failed to analyze components', stats: { globMatchCount: 0, candidateAnalysisDuration: 0, @@ -478,8 +469,7 @@ describe('ghostStoriesChannel', () => { expect(mockStoryGeneration.getComponentCandidates).toHaveBeenCalled(); expect(mockTelemetry.telemetry).toHaveBeenCalledWith('ghost-stories', { - success: false, - error: 'No candidates found', + runError: 'No candidates found', stats: { globMatchCount: 5, candidateAnalysisDuration: expect.any(Number), @@ -523,8 +513,7 @@ describe('ghostStoriesChannel', () => { }); expect(mockTelemetry.telemetry).toHaveBeenCalledWith('ghost-stories', { - success: false, - error: 'JSON report not found', + runError: 'JSON report not found', stats: { globMatchCount: 5, candidateAnalysisDuration: 0, @@ -561,7 +550,6 @@ describe('ghostStoriesChannel', () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFile.mockResolvedValue( JSON.stringify({ - success: false, numTotalTests: 2, numPassedTests: 0, numFailedTests: 2, @@ -578,8 +566,7 @@ describe('ghostStoriesChannel', () => { }); expect(mockTelemetry.telemetry).toHaveBeenCalledWith('ghost-stories', { - success: false, - error: 'Startup Error', + runError: 'Startup Error', stats: { globMatchCount: 5, candidateAnalysisDuration: 0, @@ -605,8 +592,7 @@ describe('ghostStoriesChannel', () => { }); expect(mockTelemetry.telemetry).toHaveBeenCalledWith('ghost-stories', { - success: false, - error: 'Cache error', + runError: 'Unknown error during ghost run', stats: {}, }); }); diff --git a/code/core/src/core-server/server-channel/ghost-stories-channel.ts b/code/core/src/core-server/server-channel/ghost-stories-channel.ts index 471900ba664b..2b334865556e 100644 --- a/code/core/src/core-server/server-channel/ghost-stories-channel.ts +++ b/code/core/src/core-server/server-channel/ghost-stories-channel.ts @@ -35,12 +35,16 @@ export function initGhostStoriesChannel( try { const ghostRunStart = Date.now(); const lastEvents = await getLastEvents(); - const sessionId = await getSessionId(); const lastInit = lastEvents?.init; - const lastGhostStoriesRun = lastEvents?.['ghost-stories']; + if (!lastEvents || !lastInit) { + return; + } + + const sessionId = await getSessionId(); + const lastGhostStoriesRun = lastEvents['ghost-stories']; if ( lastGhostStoriesRun || - (lastInit?.body?.sessionId && lastInit?.body?.sessionId !== sessionId) + (lastInit.body?.sessionId && lastInit.body.sessionId !== sessionId) ) { return; } @@ -70,9 +74,8 @@ export function initGhostStoriesChannel( if (candidatesResult.error) { stats.totalRunDuration = Date.now() - ghostRunStart; telemetry('ghost-stories', { - success: false, - error: candidatesResult.error, stats, + runError: candidatesResult.error, }); return; } @@ -80,9 +83,8 @@ export function initGhostStoriesChannel( if (candidatesResult.candidates.length === 0) { stats.totalRunDuration = Date.now() - ghostRunStart; telemetry('ghost-stories', { - success: false, - error: 'No candidates found', stats, + runError: 'No candidates found', }); return; } @@ -92,19 +94,22 @@ export function initGhostStoriesChannel( const testRunResult = await runStoryTests(candidatesResult.candidates); stats.totalRunDuration = Date.now() - ghostRunStart; stats.testRunDuration = testRunResult.duration; + if (testRunResult.runError) { + telemetry('ghost-stories', { + stats, + runError: testRunResult.runError, + }); + return; + } + telemetry('ghost-stories', { - ...(testRunResult.error !== undefined ? { error: testRunResult.error } : {}), - success: testRunResult.success, stats, results: testRunResult.summary, }); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - + } catch { telemetry('ghost-stories', { - success: false, - error: errorMessage, stats, + runError: 'Unknown error during ghost run', }); } finally { // we don't currently do anything with this, but will be useful in the future diff --git a/code/core/src/core-server/utils/ghost-stories/get-candidates.ts b/code/core/src/core-server/utils/ghost-stories/get-candidates.ts index 538fd89461e4..8c7d7a113cb3 100644 --- a/code/core/src/core-server/utils/ghost-stories/get-candidates.ts +++ b/code/core/src/core-server/utils/ghost-stories/get-candidates.ts @@ -185,8 +185,7 @@ export async function getComponentCandidates({ candidates, globMatchCount, }; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); + } catch { return { candidates: [], error: 'Failed to find candidates', diff --git a/code/core/src/core-server/utils/ghost-stories/parse-vitest-report.test.ts b/code/core/src/core-server/utils/ghost-stories/parse-vitest-report.test.ts index 16a43b3a21f9..d617c6355e17 100644 --- a/code/core/src/core-server/utils/ghost-stories/parse-vitest-report.test.ts +++ b/code/core/src/core-server/utils/ghost-stories/parse-vitest-report.test.ts @@ -41,15 +41,12 @@ describe('parse-vitest-report', () => { const result = parseVitestResults(mockVitestResults); - expect(result.success).toBe(true); expect(result.summary).toEqual({ total: 3, passed: 3, passedButEmptyRender: 0, - failed: 0, successRate: 1.0, successRateWithoutEmptyRender: 1.0, - failureRate: 0.0, uniqueErrorCount: 0, categorizedErrors: {}, }); @@ -88,12 +85,9 @@ describe('parse-vitest-report', () => { const result = parseVitestResults(mockVitestResults); - expect(result.success).toBe(false); expect(result.summary?.total).toBe(3); expect(result.summary?.passed).toBe(1); - expect(result.summary?.failed).toBe(2); expect(result.summary?.successRate).toBe(0.33); - expect(result.summary?.failureRate).toBe(0.67); expect(result.summary?.uniqueErrorCount).toBe(2); }); @@ -121,11 +115,18 @@ describe('parse-vitest-report', () => { { fullName: 'Story3', status: 'failed', - failureMessages: ['Error: Module not found: react-router\n at import statement'], + failureMessages: [ + 'Error: Cannot read property "x" of undefined\n at /deps/styled-components.js:1168:14', + ], }, { fullName: 'Story4', status: 'failed', + failureMessages: ['Error: Module not found: react-router\n at import statement'], + }, + { + fullName: 'Story5', + status: 'failed', failureMessages: ['Error: Invalid hook call\n at useEffect'], }, ], @@ -135,25 +136,23 @@ describe('parse-vitest-report', () => { const result = parseVitestResults(mockVitestResults); - expect(result.success).toBe(false); expect(result.summary?.total).toBe(4); expect(result.summary?.passed).toBe(1); - expect(result.summary?.failed).toBe(3); expect(result.summary?.uniqueErrorCount).toBe(3); expect(result.summary?.categorizedErrors).toEqual({ HOOK_USAGE_ERROR: { + uniqueCount: 1, count: 1, - description: 'React hook was used incorrectly', matchedDependencies: [], }, MISSING_THEME_PROVIDER: { - count: 1, - description: 'Component attempted to access theme values without a theme provider', + uniqueCount: 1, + count: 2, matchedDependencies: ['styled-components'], }, MODULE_IMPORT_ERROR: { + uniqueCount: 1, count: 1, - description: 'A required dependency could not be resolved', matchedDependencies: [], }, }); @@ -246,7 +245,6 @@ describe('parse-vitest-report', () => { expect(result.summary?.total).toBe(4); expect(result.summary?.passed).toBe(3); - expect(result.summary?.failed).toBe(1); }); it('should handle zero total tests', () => { @@ -262,7 +260,6 @@ describe('parse-vitest-report', () => { expect(result.summary?.total).toBe(0); expect(result.summary?.successRate).toBe(0); - expect(result.summary?.failureRate).toBe(0); }); }); }); diff --git a/code/core/src/core-server/utils/ghost-stories/parse-vitest-report.ts b/code/core/src/core-server/utils/ghost-stories/parse-vitest-report.ts index cdd3dc2e665f..8c783abdccbe 100644 --- a/code/core/src/core-server/utils/ghost-stories/parse-vitest-report.ts +++ b/code/core/src/core-server/utils/ghost-stories/parse-vitest-report.ts @@ -1,8 +1,5 @@ import type { ErrorCategory } from '../../../shared/utils/categorize-render-errors'; -import { - categorizeError, - getCategoryDescription, -} from '../../../shared/utils/categorize-render-errors'; +import { categorizeError } from '../../../shared/utils/categorize-render-errors'; import { type ErrorCategorizationResult, type StoryTestResult, type TestRunSummary } from './types'; /** @@ -16,7 +13,11 @@ import { type ErrorCategorizationResult, type StoryTestResult, type TestRunSumma function extractCategorizedErrors(testResults: StoryTestResult[]): ErrorCategorizationResult { const failed = testResults.filter((r) => r.status === 'FAIL' && r.error); - const map = new Map }>(); + // Map: category -> { count, uniqueErrors: Set, matchedDependencies } + const map = new Map< + ErrorCategory, + { count: number; uniqueErrors: Set; matchedDependencies: Set } + >(); // To count unique error messages (by their message, not by category) const uniqueErrorMessages = new Set(); @@ -25,7 +26,7 @@ function extractCategorizedErrors(testResults: StoryTestResult[]): ErrorCategori const { category, matchedDependencies } = categorizeError(r.error!, r.stack); if (!map.has(category)) { - map.set(category, { count: 0, matchedDependencies: new Set() }); + map.set(category, { count: 0, uniqueErrors: new Set(), matchedDependencies: new Set() }); } const data = map.get(category)!; @@ -34,12 +35,13 @@ function extractCategorizedErrors(testResults: StoryTestResult[]): ErrorCategori // Use the full error message for unique error message counting uniqueErrorMessages.add(r.error!); + data.uniqueErrors.add(r.error!); } const categorizedErrors = Array.from(map.entries()).reduce>( (acc, [category, data]) => { acc[category] = { - description: getCategoryDescription(category), + uniqueCount: data.uniqueErrors.size, count: data.count, matchedDependencies: Array.from(data.matchedDependencies).sort(), }; @@ -56,12 +58,12 @@ function extractCategorizedErrors(testResults: StoryTestResult[]): ErrorCategori } /** Transform the Vitest test results to our expected format and return a TestRunSummary */ -export function parseVitestResults(testResults: any): TestRunSummary { +export function parseVitestResults(report: any): TestRunSummary { // Transform the Vitest test results to our expected format const storyTestResults: StoryTestResult[] = []; let passedButEmptyRender = 0; - for (const testSuite of testResults.testResults) { + for (const testSuite of report.testResults) { for (const assertion of testSuite.assertionResults) { const storyId = assertion.meta?.storyId || assertion.fullName; @@ -95,11 +97,9 @@ export function parseVitestResults(testResults: any): TestRunSummary { } } - const total = testResults.numTotalTests; - const passed = testResults.numPassedTests; - const failed = testResults.numFailedTests; + const total = report.numTotalTests; + const passed = report.numPassedTests; const successRate = total > 0 ? parseFloat((passed / total).toFixed(2)) : 0; - const failureRate = total > 0 ? parseFloat((failed / total).toFixed(2)) : 0; const successRateWithoutEmptyRender = total > 0 ? parseFloat(((passed - passedButEmptyRender) / total).toFixed(2)) : 0; @@ -107,22 +107,15 @@ export function parseVitestResults(testResults: any): TestRunSummary { const errorClassification = extractCategorizedErrors(storyTestResults); const categorizedErrors = errorClassification.categorizedErrors; - const summary = { - total, - passed, - passedButEmptyRender, - failed, - successRate, - successRateWithoutEmptyRender, - failureRate, - uniqueErrorCount: errorClassification.uniqueErrorCount, - categorizedErrors, - }; - - const enhancedResponse: TestRunSummary = { - success: testResults.success, - summary, + return { + summary: { + total, + passed, + passedButEmptyRender, + successRate, + successRateWithoutEmptyRender, + uniqueErrorCount: errorClassification.uniqueErrorCount, + categorizedErrors, + }, }; - - return enhancedResponse; } diff --git a/code/core/src/core-server/utils/ghost-stories/run-story-tests.ts b/code/core/src/core-server/utils/ghost-stories/run-story-tests.ts index 3df8395a9a9d..42ab270ee58a 100644 --- a/code/core/src/core-server/utils/ghost-stories/run-story-tests.ts +++ b/code/core/src/core-server/utils/ghost-stories/run-story-tests.ts @@ -64,39 +64,42 @@ export async function runStoryTests(componentFilePaths: string[]): Promise; }; // Error message if the operation failed - error?: string; + runError?: string; } diff --git a/code/core/src/shared/utils/categorize-render-errors.test.ts b/code/core/src/shared/utils/categorize-render-errors.test.ts index d7eea844793d..f8c468abef36 100644 --- a/code/core/src/shared/utils/categorize-render-errors.test.ts +++ b/code/core/src/shared/utils/categorize-render-errors.test.ts @@ -25,6 +25,34 @@ describe('categorize-render-errors', () => { }); }); + describe('TEST_FILE_IMPORT_ERROR', () => { + it('should categorize test file import errors', () => { + expect( + categorizeError( + 'Failed to import test file /foo/node_modules/@storybook/addon-vitest/dist/vitest-plugin/setup-file.js' + ).category + ).toBe(ERROR_CATEGORIES.TEST_FILE_IMPORT_ERROR); + + expect( + categorizeError('Failed to import test file /path/to/test/setup.ts').category + ).toBe(ERROR_CATEGORIES.TEST_FILE_IMPORT_ERROR); + }); + }); + + describe('DYNAMIC_MODULE_IMPORT_ERROR', () => { + it('should categorize dynamic module import errors', () => { + expect( + categorizeError( + 'TypeError: Failed to fetch dynamically imported module: http://localhost:63315/node_modules/.cache/storybook/2523d14eb1a348695c30002850a8852e9305e60b397e9b529978c17a0d2cd524/sb-vitest/deps/react-18-PYSEDAWB-3KPRILU2.js?v=6f767e0c' + ).category + ).toBe(ERROR_CATEGORIES.DYNAMIC_MODULE_IMPORT_ERROR); + + expect(categorizeError('Failed to fetch dynamically imported module').category).toBe( + ERROR_CATEGORIES.DYNAMIC_MODULE_IMPORT_ERROR + ); + }); + }); + describe('HOOK_USAGE_ERROR', () => { it('should categorize all hook usage errors appropriately', () => { expect( diff --git a/code/core/src/shared/utils/categorize-render-errors.ts b/code/core/src/shared/utils/categorize-render-errors.ts index 2be6d5d0fb0f..68e9653139cf 100644 --- a/code/core/src/shared/utils/categorize-render-errors.ts +++ b/code/core/src/shared/utils/categorize-render-errors.ts @@ -17,6 +17,10 @@ export const ERROR_CATEGORIES = { COMPONENT_RENDER_ERROR: 'COMPONENT_RENDER_ERROR', SERVER_COMPONENTS_ERROR: 'SERVER_COMPONENTS_ERROR', UNKNOWN_ERROR: 'UNKNOWN_ERROR', + // Vite related errors + DYNAMIC_MODULE_IMPORT_ERROR: 'DYNAMIC_MODULE_IMPORT_ERROR', + // Vitest test run related errors + TEST_FILE_IMPORT_ERROR: 'TEST_FILE_IMPORT_ERROR', } as const; export type ErrorCategory = (typeof ERROR_CATEGORIES)[keyof typeof ERROR_CATEGORIES]; @@ -88,6 +92,18 @@ const CATEGORIZATION_RULES: CategorizationRule[] = [ ctx.normalizedMessage.includes('cannot resolve module'), }, + { + category: ERROR_CATEGORIES.TEST_FILE_IMPORT_ERROR, + priority: 95, + match: (ctx) => ctx.normalizedMessage.includes('failed to import test file'), + }, + + { + category: ERROR_CATEGORIES.DYNAMIC_MODULE_IMPORT_ERROR, + priority: 95, + match: (ctx) => ctx.normalizedMessage.includes('failed to fetch dynamically imported module'), + }, + { category: ERROR_CATEGORIES.HOOK_USAGE_ERROR, priority: 90, @@ -242,6 +258,12 @@ export function getCategoryDescription(category: ErrorCategory): string { case ERROR_CATEGORIES.MODULE_IMPORT_ERROR: return 'A required dependency could not be resolved'; + case ERROR_CATEGORIES.TEST_FILE_IMPORT_ERROR: + return 'Failed to import a test file during test execution'; + + case ERROR_CATEGORIES.DYNAMIC_MODULE_IMPORT_ERROR: + return 'Failed to dynamically import a module at runtime'; + case ERROR_CATEGORIES.COMPONENT_RENDER_ERROR: return 'Component failed during render due to a runtime error'; diff --git a/code/core/src/shared/utils/ecosystem-identifier.ts b/code/core/src/shared/utils/ecosystem-identifier.ts index be93a00ef16a..76aaad1b49d6 100644 --- a/code/core/src/shared/utils/ecosystem-identifier.ts +++ b/code/core/src/shared/utils/ecosystem-identifier.ts @@ -98,14 +98,14 @@ export const UI_LIBRARY_PACKAGES = [ export const I18N_PACKAGES = ['*i18n*', '*intl', '@lingui/*'] as const; export const ROUTER_PACKAGES = [ - // e.g. react-router, react-easy-router - '*-router', - // e.g. react-router-dom - '*-router-*', - // e.g. @reach/router, @remix-run/router - '*/router', + 'react-router', + 'react-router-dom', + 'react-easy-router', + '@remix-run/router', + 'expo-router', '@tanstack/*-router', 'wouter', + '@reach/router', ] as const; export function globToRegex(pattern: string): RegExp {