Addon Test: Improve unhandled errors message

This commit is contained in:
Yann Braga 2025-03-05 13:21:37 +01:00
parent 724be33c9c
commit 2c1c317efb
4 changed files with 136 additions and 14 deletions

View File

@ -13,6 +13,7 @@ import type { API_StatusUpdate } from 'storybook/internal/types';
import type { Suite } from '@vitest/runner';
import { throttle } from 'es-toolkit';
import { satisfies } from 'semver';
import { dedent } from 'ts-dedent';
import { TEST_PROVIDER_ID } from '../constants';
import type { TestManager } from './test-manager';
@ -60,6 +61,36 @@ const isVitest3OrLater = vitestVersion
? satisfies(vitestVersion, '>=3.0.0-beta.3', { includePrerelease: true })
: false;
interface VitestError extends Error {
VITEST_TEST_PATH?: string;
VITEST_TEST_NAME?: string;
}
const getErrorOrigin = (error: VitestError): string => {
const parts: string[] = [];
if (error.VITEST_TEST_PATH) {
parts.push(
dedent`
\nThis error originated in "${error.VITEST_TEST_PATH}". It doesn't mean the error was thrown inside the file itself, but while it was running.
`
);
}
if (error.VITEST_TEST_NAME) {
parts.push(
dedent`
The latest test that might've caused the error is "${error.VITEST_TEST_NAME}".
It might mean one of the following:
- The error was thrown, while Vitest was running this test.
- If the error occurred after the test had been completed, this was the last documented test before it was thrown.
`
);
}
return parts.join('\n');
};
export class StorybookReporter implements Reporter {
testStatusData: API_StatusUpdate = {};
@ -219,6 +250,14 @@ export class StorybookReporter implements Reporter {
async onFinished() {
const unhandledErrors = this.ctx.state.getUnhandledErrors();
unhandledErrors.forEach((e: unknown) => {
const error = e as VitestError;
const origin = getErrorOrigin(error);
if (origin) {
error.message = `${error.message}\n${origin}`;
error.stack = `${error.stack}\n${origin}`;
}
});
const isCancelled = isVitest3OrLater
? this.testManager.vitestManager.isCancelling
@ -256,7 +295,7 @@ export class StorybookReporter implements Reporter {
name: `${unhandledErrors.length} unhandled error${unhandledErrors?.length > 1 ? 's' : ''}`,
message: unhandledErrors
.map((e, index) => `[${index}]: ${(e as any).stack || (e as any).message}`)
.join('\n----------\n'),
.join('\n\n----------\n\n'),
};
this.sendReport({

View File

@ -8,10 +8,11 @@ import { SbPage } from "../../../../code/e2e-tests/util";
const STORYBOOK_URL = "http://localhost:6006";
const TEST_STORY_PATH = path.resolve(__dirname, "..", "stories", "AddonTest.stories.tsx");
const UNHANDLED_ERRORS_STORY_PATH = path.resolve(__dirname, "..", "stories", "UnhandledErrors.stories.tsx");
const setForceFailureFlag = async (value: boolean) => {
const setForceFailureFlag = async (storyPath: string, value: boolean) => {
// Read the story file content asynchronously
const storyContent = (await fs.readFile(TEST_STORY_PATH)).toString();
const storyContent = (await fs.readFile(storyPath)).toString();
// Create a regex to match 'forceFailure: true' or 'forceFailure: false'
const forceFailureRegex = /forceFailure:\s*(true|false)/;
@ -23,7 +24,7 @@ const setForceFailureFlag = async (value: boolean) => {
);
// Write the updated content back to the file asynchronously
await fs.writeFile(TEST_STORY_PATH, updatedContent);
await fs.writeFile(storyPath, updatedContent);
// the file change causes a HMR event, which causes a browser reload,and that can take a few seconds
await new Promise((resolve) => setTimeout(resolve, 2000));
@ -129,7 +130,7 @@ test.describe("component testing", () => {
browserName,
}) => {
test.skip(browserName !== "chromium", `Skipping tests for ${browserName}`);
await setForceFailureFlag(true);
await setForceFailureFlag(TEST_STORY_PATH, true);
const sbPage = new SbPage(page, expect);
await sbPage.navigateToStory("addons/group/test", "Expected Failure");
@ -197,7 +198,7 @@ test.describe("component testing", () => {
browserName,
}) => {
test.skip(browserName !== "chromium", `Skipping tests for ${browserName}`);
await setForceFailureFlag(false);
await setForceFailureFlag(TEST_STORY_PATH, false);
const sbPage = new SbPage(page, expect);
await sbPage.navigateToStory("addons/group/test", "Expected Failure");
@ -212,7 +213,7 @@ test.describe("component testing", () => {
// We shouldn't have to do an arbitrary wait, but because there is no UI for loading state yet, we have to
await page.waitForTimeout(8000);
await setForceFailureFlag(true);
await setForceFailureFlag(TEST_STORY_PATH, true);
await page.waitForTimeout(500);
// Wait for test results to appear
@ -252,7 +253,7 @@ test.describe("component testing", () => {
}) => {
test.skip(browserName !== "chromium", `Skipping tests for ${browserName}`);
// Arrange - Prepare Storybook
await setForceFailureFlag(false);
await setForceFailureFlag(TEST_STORY_PATH, false);
const sbPage = new SbPage(page, expect);
await sbPage.navigateToStory("addons/group/test", "Expected Failure");
@ -302,7 +303,7 @@ test.describe("component testing", () => {
}) => {
test.skip(browserName !== "chromium", `Skipping tests for ${browserName}`);
// Arrange - Prepare Storybook
await setForceFailureFlag(false);
await setForceFailureFlag(TEST_STORY_PATH, false);
const sbPage = new SbPage(page, expect);
await sbPage.navigateToStory("addons/group/test", "Expected Failure");
@ -325,13 +326,50 @@ test.describe("component testing", () => {
await expect(page.locator('#storybook-explorer-menu').getByRole('status', { name: 'Test status: success' })).toHaveCount(1);
});
test("should show unhandled errors in the testing module", async ({
page,
browserName,
}) => {
test.skip(browserName !== "chromium", `Skipping tests for ${browserName}`);
// Arrange - Prepare Storybook
await setForceFailureFlag(UNHANDLED_ERRORS_STORY_PATH, true);
const sbPage = new SbPage(page, expect);
await sbPage.navigateToStory("example/unhandlederrors", "Success");
const storyElement = sbPage
.getCanvasBodyElement()
.getByText("Hello world");
await expect(storyElement).toBeVisible({ timeout: 30000 });
// Act - Open sidebar context menu and start focused test
await page.locator('[data-item-id="example-unhandlederrors"]').hover();
await page.locator('[data-item-id="example-unhandlederrors"] div[data-testid="context-menu"] button').click();
const sidebarContextMenu = page.getByTestId('tooltip');
await sidebarContextMenu.getByLabel('Start test run').click();
// Assert - Tests are running and errors are reported
const errorLink = page.locator('#testing-module-description a');
await expect(errorLink).toContainText('2 unhandled errors', { timeout: 30000 });
await errorLink.click();
await expect(page.locator('pre')).toContainText('I THREW AN UNHANDLED ERROR!');
await expect(page.locator('pre')).toContainText('This error originated in');
await expect(page.locator('pre')).toContainText("The latest test that might've caused the error is");
await page.locator('body').click();
// Cleanup
await setForceFailureFlag(UNHANDLED_ERRORS_STORY_PATH, false);
});
test("should run focused test for a component", async ({
page,
browserName,
}) => {
test.skip(browserName !== "chromium", `Skipping tests for ${browserName}`);
// Arrange - Prepare Storybook
await setForceFailureFlag(false);
await setForceFailureFlag(TEST_STORY_PATH, false);
const sbPage = new SbPage(page, expect);
await sbPage.navigateToStory("addons/group/test", "Expected Failure");
@ -364,7 +402,7 @@ test.describe("component testing", () => {
}) => {
test.skip(browserName !== "chromium", `Skipping tests for ${browserName}`);
// Arrange - Prepare Storybook
await setForceFailureFlag(false);
await setForceFailureFlag(TEST_STORY_PATH, false);
const sbPage = new SbPage(page, expect);
await sbPage.navigateToStory("addons/group/test", "Expected Failure");
@ -397,7 +435,7 @@ test.describe("component testing", () => {
}) => {
test.skip(browserName !== "chromium", `Skipping tests for ${browserName}`);
// Arrange - Prepare Storybook
await setForceFailureFlag(false);
await setForceFailureFlag(TEST_STORY_PATH, false);
const sbPage = new SbPage(page, expect);
await sbPage.navigateToStory("example/button", "CSF 3 Primary");
@ -441,5 +479,4 @@ test.describe("component testing", () => {
expect(sbPercentage).toBeGreaterThanOrEqual(0);
expect(sbPercentage).toBeLessThanOrEqual(100);
});
});

View File

@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react';
async function unhandledRejection() {
throwError('I THREW AN UNHANDLED REJECTION!');
}
function unhandledError() {
throwError('I THREW AN UNHANDLED ERROR!');
}
function throwError(message: string) {
throw new Error(message);
}
const meta = {
title: 'Example/UnhandledErrors',
args: {
errorType: null,
forceFailure: false,
},
component: ({ errorType, forceFailure }) => {
if (forceFailure) {
if (errorType === 'rejection') {
setTimeout(unhandledRejection, 0);
} else if (errorType === 'error') {
setTimeout(unhandledError, 0);
}
}
return 'Hello world';
},
} as Meta<{ errorType: 'rejection' | 'error' | null; forceFailure?: boolean }>;
export default meta;
type Story = StoryObj<typeof meta>;
export const UnhandledError: Story = {
args: {
errorType: 'error',
},
};
export const UnhandledRejection: Story = {
args: {
errorType: 'rejection',
},
};
export const Success: Story = {};

View File

@ -30,7 +30,7 @@ export default defineWorkspace([
}]
},
setupFiles: ["./.storybook/vitest.setup.ts"],
environment: "happy-dom",
environment: "jsdom",
},
},
]);