Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions code/addons/vitest/src/node/test-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,23 @@ global.fetch = vi.fn().mockResolvedValue({
importPath: 'path/to/another/file',
tags: ['test'],
},
'parent--story': {
type: 'story',
id: 'parent--story',
name: 'Parent story',
title: 'parent/story',
importPath: 'path/to/parent/file',
tags: ['test'],
},
'parent--story:test': {
type: 'story',
id: 'parent--story:test',
name: 'Test name',
title: 'parent/story',
parent: 'parent--story',
importPath: 'path/to/parent/file',
tags: ['test', 'test-fn'],
},
},
} as StoryIndex)
),
Expand Down Expand Up @@ -187,6 +204,51 @@ describe('TestManager', () => {
expect(vitest.runTestSpecifications).toHaveBeenCalledWith(tests.slice(0, 1), true);
});

it('should trigger a single story render test', async () => {
vitest.globTestSpecifications.mockImplementation(() => tests);
const testManager = await TestManager.start(options);

await testManager.handleTriggerRunEvent({
type: 'TRIGGER_RUN',
payload: {
storyIds: ['another--one'],
triggeredBy: 'global',
},
});
// regex should be exact match of the story name
expect(setTestNamePattern).toHaveBeenCalledWith(/^One$/);
});

it('should trigger a single story test', async () => {
vitest.globTestSpecifications.mockImplementation(() => tests);
const testManager = await TestManager.start(options);

await testManager.handleTriggerRunEvent({
type: 'TRIGGER_RUN',
payload: {
storyIds: ['parent--story:test'],
triggeredBy: 'global',
},
});
// regex should be Parent Story Name + Test Name
expect(setTestNamePattern).toHaveBeenCalledWith(/^Parent story Test name$/);
});

it('should trigger all tests of a story', async () => {
vitest.globTestSpecifications.mockImplementation(() => tests);
const testManager = await TestManager.start(options);

await testManager.handleTriggerRunEvent({
type: 'TRIGGER_RUN',
payload: {
storyIds: ['parent--story'],
triggeredBy: 'global',
},
});
// regex should be parent story name with no spaces in between plus a space at the end
expect(setTestNamePattern).toHaveBeenCalledWith(/^Parentstory /);
});

it('should restart Vitest before a test run if coverage is enabled', async () => {
const testManager = await TestManager.start(options);
expect(createVitest).toHaveBeenCalledTimes(1);
Expand Down
63 changes: 41 additions & 22 deletions code/addons/vitest/src/node/vitest-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export class VitestManager {
});
}

private async fetchStories(requestStoryIds?: string[]) {
private async fetchStories(requestStoryIds?: string[]): Promise<StoryIndexEntry[]> {
const indexUrl = this.testManager.store.getState().indexUrl;
if (!indexUrl) {
throw new Error(
Expand Down Expand Up @@ -265,41 +265,60 @@ export class VitestManager {
await this.cancelCurrentRun();

const testSpecifications = await this.getStorybookTestSpecifications();
const stories = await this.fetchStories(runPayload?.storyIds);
const allStories = await this.fetchStories();

const filteredStories = runPayload.storyIds
? allStories.filter((story) => runPayload.storyIds?.includes(story.id))
: allStories;

const isSingleStoryRun = runPayload.storyIds?.length === 1;
if (isSingleStoryRun) {
const selectedStory = stories[0];
const selectedStory = filteredStories.find((story) => story.id === runPayload.storyIds?.[0]);
if (!selectedStory) {
throw new Error(`Story ${runPayload.storyIds?.[0]} not found`);
}

const storyName = selectedStory.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Use case 1: Single story run on a story without tests
let regex: RegExp = new RegExp(`^${storyName}$`);
let regex: RegExp;

if (selectedStory.tags?.includes('test-fn')) {
// in this case the regex pattern should be the story parentName + story.name
// @ts-expect-error TODO: fix this
const parentName = selectedStory.parentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Use case 2: Single story run on a specific story test
regex = new RegExp(`^${parentName} ${storyName}$`);
} else if (selectedStory.tags?.includes('has-tests')) {
// Use case 3: "Single" story run on a story with tests
const isParentStory = allStories.some((story) => selectedStory.id === story.parent);
const hasParentStory = allStories.some((story) => selectedStory.parent === story.id);

if (isParentStory) {
// Use case 1: "Single" story run on a story with tests
// -> run all tests of that story, as storyName is a describe block
/**
* TODO: [test-syntax] discuss. The vitest transformation keeps the export name as is, e.g.
* "PrimaryButton", while the storybook sidebar changes the name to "Primary Button". That's
* why we need to remove spaces from the story name, to match the test name. If we were to
* also beautify the test name, doing a regex wouldn't be precise because there could be two
* describes, for instance: "Primary Button" and "Primary Button Mobile" and both would
* match. The fact that there are no spaces in the test name is what makes "PrimaryButton"
* and "PrimaryButtonMobile" worth well in the regex.
* The vitest transformation keeps the export name as is, e.g. "PrimaryButton", while the
* storybook sidebar changes the name to "Primary Button". That's why we need to remove
* spaces from the story name, to match the test name. If we were to also beautify the test
* name, doing a regex wouldn't be precise because there could be two describes, for
* instance: "Primary Button" and "Primary Button Mobile" and both would match. The fact
* that there are no spaces in the test name is what makes "PrimaryButton" and
* "PrimaryButtonMobile" work well in the regex. As it turns out, this limitation is also
* present in the Vitest VSCode extension and the issue would occur with normal vitest tests
* as well.
*/
regex = new RegExp(`^${storyName.replace(/\s+/g, '')} `);
regex = new RegExp(`^${storyName.replace(/\s+/g, '')} `); // the extra space is intentional!
} else if (hasParentStory) {
// Use case 2: Single story run on a specific story test
// in this case the regex pattern should be the story parentName + story.name
const parentStory = allStories.find((story) => story.id === selectedStory.parent);
if (!parentStory) {
throw new Error(`Parent story not found for story ${selectedStory.id}`);
}

const parentName = parentStory.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
regex = new RegExp(`^${parentName} ${storyName}$`);
} else {
// Use case 3: Single story run on a story without tests
regex = new RegExp(`^${storyName}$`);
}
this.vitest!.setGlobalTestNamePattern(regex);
}

const { filteredTestSpecifications, filteredStoryIds } = this.filterTestSpecifications(
testSpecifications,
stories
filteredStories
);

this.testManager.store.setState((s) => ({
Expand Down
17 changes: 9 additions & 8 deletions code/addons/vitest/src/vitest-plugin/test-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type RunnerTask, type TaskMeta, type TestContext } from 'vitest';

import { type Meta, type Story, isStory, toTestId } from 'storybook/internal/csf';
import { type Meta, type Story, getStoryChildren, isStory, toTestId } from 'storybook/internal/csf';
import type { ComponentAnnotations, ComposedStoryFn, Renderer } from 'storybook/internal/types';

import { server } from '@vitest/browser/context';
Expand Down Expand Up @@ -41,8 +41,12 @@ export const testStory = (
return async (context: TestContext & { story: ComposedStoryFn }) => {
const annotations = getCsfFactoryAnnotations(story, meta);

const storyAnnotations =
isStory(story) && testName ? story.getAllTests()[testName].story.input : annotations.story;
const test =
isStory(story) && testName
? getStoryChildren(story).find((child) => child.input.name === testName)
: undefined;

const storyAnnotations = test ? test.input : annotations.story;

const composedStory = composeStory(
storyAnnotations,
Expand All @@ -63,18 +67,15 @@ export const testStory = (
};

if (testName) {
// TODO: [test-syntax] isn't this done by the csf plugin somehow?
_task.meta.storyId = toTestId(composedStory.id, testName);
} else {
_task.meta.storyId = composedStory.id;
}

await setViewport(composedStory.parameters, composedStory.globals);

if (isStory(story) && testName) {
await composedStory.run(undefined, story.getAllTests()[testName].test);
} else {
await composedStory.run(undefined);
}
await composedStory.run(undefined);

_task.meta.reports = composedStory.reporting.reports;
};
Expand Down
27 changes: 3 additions & 24 deletions code/core/src/core-server/utils/StoryIndexGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import type {
StoryIndexEntry,
StorybookConfigRaw,
Tag,
TestIndexEntry,
} from 'storybook/internal/types';

import { findUp } from 'find-up';
Expand All @@ -35,9 +34,6 @@ import { IndexingError, MultipleIndexingError } from './IndexingError';
import { autoName } from './autoName';
import { type IndexStatsSummary, addStats } from './summarizeStats';

// TODO: [test-syntax] replace line 42 with this once we start working on UI for tests
// type StoryIndexEntryWithExtra = (StoryIndexEntry | TestIndexEntry) & {

// Extended type to keep track of the csf meta id so we know the component id when referencing docs in `extractDocs`
type StoryIndexEntryWithExtra = StoryIndexEntry & {
extra: { metaId?: string; stats: IndexInputStats };
Expand Down Expand Up @@ -443,7 +439,8 @@ export class StoryIndexGenerator {
const id = input.__id ?? toId(input.metaId ?? title, storyNameFromExport(input.exportName));
const tags = combineTags(...projectTags, ...(input.tags ?? []));

const commonMetadata = {
return {
type: 'story',
id,
extra: {
metaId: input.metaId,
Expand All @@ -454,25 +451,7 @@ export class StoryIndexGenerator {
importPath,
componentPath,
tags,
};

// TODO: [test-syntax] Enable this once we start working on UI for tests
if (input.tags?.includes('has-tests')) {
// if (input.type === 'test') {
return {
// type: 'test',
type: 'story',
// @ts-expect-error TODO: discuss this
parentId: input.parentId,
// @ts-expect-error TODO: discuss this
parentName: input.parentName,
...commonMetadata,
};
}

return {
type: 'story',
...commonMetadata,
...(input.type === 'story' && input.parent ? { parent: input.parent } : {}),
};
});

Expand Down
11 changes: 1 addition & 10 deletions code/core/src/csf-tools/CsfFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,11 +906,6 @@ export class CsfFile {
__stats: story.__stats,
};

if (this._storyTests[exportName]) {
// TODO: discuss this later
storyInput.tags = [...(storyInput.tags || []), 'has-tests'];
}

index.push({
...storyInput,
type: 'story',
Expand All @@ -922,11 +917,7 @@ export class CsfFile {
index.push({
...storyInput,
type: 'story',
// TODO: [test-syntax] enable this once we start working on UI for tests
// type: 'test',
// @ts-expect-error TODO: discuss this later
parentId: story.id,
parentName: story.name,
parent: story.id,
name: test.name,
tags: [...storyInput.tags, 'test-fn'],
__id: test.id,
Expand Down
14 changes: 7 additions & 7 deletions code/core/src/csf/csf-factories.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//* @vitest-environment happy-dom */
import { describe, expect, test, vi } from 'vitest';

import { definePreview, definePreviewAddon } from './csf-factories';
import { definePreview, definePreviewAddon, getStoryChildren } from './csf-factories';

interface Addon1Types {
parameters: { foo?: { value: string } };
Expand Down Expand Up @@ -53,11 +53,11 @@ describe('test function', () => {

// register test
MyStory.test(testName, testFn);
const { story: storyTestAnnotations } = MyStory.getAllTests()[testName];
expect(storyTestAnnotations.input.args).toEqual({ label: 'foo' });
const test = getStoryChildren(MyStory).find(({ input }) => input.name === testName)!;
expect(test.input.args).toEqual({ label: 'foo' });

// execute test
await MyStory.run(undefined, testName);
await test.run(undefined, testName);
expect(testFn).toHaveBeenCalled();
});
test('with overrides', async () => {
Expand All @@ -67,11 +67,11 @@ describe('test function', () => {

// register test
MyStory.test(testName, { args: { label: 'bar' } }, testFn);
const { story: storyTestAnnotations } = MyStory.getAllTests()[testName];
expect(storyTestAnnotations.input.args).toEqual({ label: 'bar' });
const test = getStoryChildren(MyStory).find(({ input }) => input.name === testName)!;
expect(test.input.args).toEqual({ label: 'bar' });

// execute test
await MyStory.run(undefined, testName);
await test.run();
expect(testFn).toHaveBeenCalled();
});
});
Loading
Loading