Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Addon Vitest: Add status update prototype #28926

Open
wants to merge 1 commit into
base: next
Choose a base branch
from
Open
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
6 changes: 6 additions & 0 deletions code/addons/vitest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
"import": "./dist/plugin/index.js",
"require": "./dist/plugin/index.cjs"
},
"./reporter": {
"types": "./dist/plugin/reporter.d.ts",
"import": "./dist/plugin/reporter.js",
"require": "./dist/plugin/reporter.cjs"
},
"./internal/global-setup": {
"types": "./dist/plugin/global-setup.d.ts",
"import": "./dist/plugin/global-setup.js",
Expand Down Expand Up @@ -97,6 +102,7 @@
"nodeEntries": [
"./src/preset.ts",
"./src/plugin/index.ts",
"./src/plugin/reporter.ts",
"./src/plugin/global-setup.ts",
"./src/postinstall.ts"
]
Expand Down
67 changes: 65 additions & 2 deletions code/addons/vitest/src/manager.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,68 @@
import { type API, addons } from 'storybook/internal/manager-api';
import { addons } from 'storybook/internal/manager-api';

import type { API_StatusUpdate, API_StatusValue, StoryId } from '@storybook/types';

import { ADDON_ID } from './constants';
import type { AssertionResult, TestReport } from './types';
import { SharedState } from './utils/shared-state';

const statusMap: Record<AssertionResult['status'], API_StatusValue> = {
failed: 'error',
passed: 'success',
pending: 'pending',
};

function processTestReport(report: TestReport, onClick: any) {
const result: API_StatusUpdate = {};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider adding type annotation for onClick parameter


report.testResults.forEach((testResult) => {
testResult.assertionResults.forEach((assertion) => {
const storyId = assertion.meta?.storyId;
if (storyId) {
result[storyId] = {
title: 'Vitest',
status: statusMap[assertion.status],
description:
assertion.failureMessages.length > 0 ? assertion.failureMessages.join('\n') : '',
onClick,
};
}
});
});

return result;
}

addons.register(ADDON_ID, (api) => {
const channel = api.getChannel();

if (!channel) {
return;
}

const testResultsState = SharedState.subscribe<TestReport>('TEST_RESULTS', channel);
const lastStoryIds = new Set<StoryId>();

testResultsState.on('change', async (report) => {
if (!report) {
return;
}

const storiesToClear = Object.fromEntries(Array.from(lastStoryIds).map((id) => [id, null]));

if (Object.keys(storiesToClear).length > 0) {
// Clear old statuses to avoid stale data
await api.experimental_updateStatus(ADDON_ID, storiesToClear);
lastStoryIds.clear();
}

const openInteractionsPanel = () => {
api.setSelectedPanel('storybook/interactions/panel');
api.togglePanel(true);
};

Comment on lines +59 to +62
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider extracting this function outside the change event handler for better performance

const final = processTestReport(report, openInteractionsPanel);

addons.register(ADDON_ID, () => {});
await api.experimental_updateStatus(ADDON_ID, final);
});
});
12 changes: 12 additions & 0 deletions code/addons/vitest/src/plugin/reporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { join } from 'node:path';

import { JsonReporter } from 'vitest/reporters';

export default class StorybookReporter extends JsonReporter {
constructor({ configDir = '.storybook' }: { configDir: string }) {
const outputFile = join(configDir, 'test-results.json');
super({
outputFile,
});
}
}
66 changes: 63 additions & 3 deletions code/addons/vitest/src/preset.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,67 @@
import { watch } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { dirname, join, basename } from 'node:path';
import type { Channel } from 'storybook/internal/channels';
import type { Options } from 'storybook/internal/types';

// eslint-disable-next-line @typescript-eslint/naming-convention
export const experimental_serverChannel = async (channel: Channel, options: Options) => {
return channel;
import type { TestReport } from './types';
import { SharedState } from './utils/shared-state';

async function getTestReport(reportFile: string): Promise<TestReport> {
const data = await readFile(reportFile, 'utf8');
// TODO: Streaming and parsing large files
return JSON.parse(data);
}

const watchTestReportDirectory = async (
reportFile: string | undefined,
onChange: (results: Awaited<ReturnType<typeof getTestReport>>) => Promise<void>
) => {
if (!reportFile) return;

const directory = dirname(reportFile);
const targetFile = basename(reportFile);

const handleFileChange = async (eventType: string, filename: string | null) => {
if (filename && filename === targetFile) {
try {
await onChange(await getTestReport(reportFile));
} catch(err: any) {
if(err.code === 'ENOENT') {
console.log('File got deleted/renamed. What should we do?');
return;
Comment on lines +30 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Implement proper error handling for file deletion/renaming instead of just logging.

}

throw err;
}
}
};

watch(directory, handleFileChange);

try {
const initialResults = await getTestReport(reportFile);
await onChange(initialResults);
} catch(err: any) {
if(err.code === 'ENOENT') {
return;
}

throw err;
}
};

// eslint-disable-next-line @typescript-eslint/naming-convention
export async function experimental_serverChannel(
channel: Channel,
options: Options & { reportFile?: string }
) {
const { reportFile = join(process.cwd(), '.storybook', 'test-results.json') } = options;

const testReportState = SharedState.subscribe<TestReport>('TEST_RESULTS', channel);

watchTestReportDirectory(reportFile, async (results) => {
console.log('Updating test results:', Object.keys(results.testResults).length);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider using a more descriptive logging method, such as console.debug or a custom logger.

testReportState.value = results;
});
}
61 changes: 61 additions & 0 deletions code/addons/vitest/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
interface FailureMessage {
line: number;
column: number;
}

interface Meta {
storyId: string;
}

export interface AssertionResult {
ancestorTitles: string[];
fullName: string;
status: 'passed' | 'failed' | 'pending';
title: string;
duration: number;
failureMessages: string[];
location?: FailureMessage;
meta?: Meta;
}

interface TestResult {
assertionResults: AssertionResult[];
startTime: number;
endTime: number;
status: 'passed' | 'failed' | 'pending';
message: string;
name: string;
}

interface Snapshot {
added: number;
failure: boolean;
filesAdded: number;
filesRemoved: number;
filesRemovedList: any[];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider using a more specific type than any[] for filesRemovedList

filesUnmatched: number;
filesUpdated: number;
matched: number;
total: number;
unchecked: number;
uncheckedKeysByFile: any[];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider using a more specific type than any[] for uncheckedKeysByFile

unmatched: number;
updated: number;
didUpdate: boolean;
}

export interface TestReport {
numTotalTestSuites: number;
numPassedTestSuites: number;
numFailedTestSuites: number;
numPendingTestSuites: number;
numTotalTests: number;
numPassedTests: number;
numFailedTests: number;
numPendingTests: number;
numTodoTests: number;
startTime: number;
success: boolean;
testResults: TestResult[];
snapshot: Snapshot;
}
85 changes: 85 additions & 0 deletions code/addons/vitest/src/utils/shared-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it } from 'vitest';

import { SharedState } from './SharedState';

class MockChannel {
private listeners: Record<string, ((...args: any[]) => void)[]> = {};

on(event: string, listener: (...args: any[]) => void) {
this.listeners[event] = [...(this.listeners[event] ?? []), listener];
}

off(event: string, listener: (...args: any[]) => void) {
this.listeners[event] = (this.listeners[event] ?? []).filter((l) => l !== listener);
}

emit(event: string, ...args: any[]) {
// setTimeout is used to simulate the asynchronous nature of the real channel
(this.listeners[event] || []).forEach((listener) => setTimeout(() => listener(...args)));
}
}

const tick = () => new Promise((resolve) => setTimeout(resolve, 0));

describe('SharedState', () => {
let channel: MockChannel;
let a: SharedState;
let b: SharedState;

beforeEach(() => {
channel = new MockChannel();
a = new SharedState(channel);
b = new SharedState(channel);
});

it('should initialize with an empty object', () => {
expect(a.state).toEqual({});
});

it('should set and get values correctly', () => {
a.set('foo', 'bar');
a.set('baz', 123);

expect(a.get('foo')).toBe('bar');
expect(a.get('baz')).toBe(123);
expect(a.get('qux')).toBeUndefined();
});

it('should remove values correctly', () => {
a.set('foo', 'bar');
a.set('foo', undefined);

expect(a.get('foo')).toBeUndefined();
});

it('should (eventually) share state between instances', async () => {
a.set('foo', 'bar');
await tick(); // setState
expect(b.get('foo')).toBe('bar');
});

it('should (eventually) share state with new instances', async () => {
a.set('foo', 'bar');
const c = new SharedState(channel);

expect(c.get('foo')).toBeUndefined();
await tick(); // getState
await tick(); // setState
expect(c.get('foo')).toBe('bar');
});

it('should not overwrite newer values', async () => {
a.set('foo', 'bar');
b.set('foo', 'baz'); // conflict: "bar" was not yet synced
await tick(); // setState (one side)
await tick(); // setState (other side)

// Neither accepts the other's value
expect(a.get('foo')).toBe('bar');
expect(b.get('foo')).toBe('baz');

b.set('foo', 'baz'); // try again
await tick(); // setState
expect(a.get('foo')).toBe('baz');
});
});
Loading
Loading