From 34769c0bf5296fff6143418f8a55c08940c8535c Mon Sep 17 00:00:00 2001 From: Yann Braga Date: Sat, 17 Aug 2024 16:23:33 +0200 Subject: [PATCH] Addon Vitest: Add status update prototype --- code/addons/vitest/package.json | 6 ++ code/addons/vitest/src/manager.tsx | 67 ++++++++++++++- code/addons/vitest/src/plugin/reporter.ts | 12 +++ code/addons/vitest/src/preset.ts | 66 +++++++++++++- code/addons/vitest/src/types.ts | 61 +++++++++++++ .../vitest/src/utils/shared-state.test.ts | 85 +++++++++++++++++++ code/addons/vitest/src/utils/shared-state.ts | 78 +++++++++++++++++ code/vitest.config.ts | 1 + 8 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 code/addons/vitest/src/plugin/reporter.ts create mode 100644 code/addons/vitest/src/types.ts create mode 100644 code/addons/vitest/src/utils/shared-state.test.ts create mode 100644 code/addons/vitest/src/utils/shared-state.ts diff --git a/code/addons/vitest/package.json b/code/addons/vitest/package.json index e318fc7f7f59..6f259dc9760d 100644 --- a/code/addons/vitest/package.json +++ b/code/addons/vitest/package.json @@ -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", @@ -97,6 +102,7 @@ "nodeEntries": [ "./src/preset.ts", "./src/plugin/index.ts", + "./src/plugin/reporter.ts", "./src/plugin/global-setup.ts", "./src/postinstall.ts" ] diff --git a/code/addons/vitest/src/manager.tsx b/code/addons/vitest/src/manager.tsx index 8d706325046a..097bbc761e72 100644 --- a/code/addons/vitest/src/manager.tsx +++ b/code/addons/vitest/src/manager.tsx @@ -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 = { + failed: 'error', + passed: 'success', + pending: 'pending', +}; + +function processTestReport(report: TestReport, onClick: any) { + const result: API_StatusUpdate = {}; + + 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('TEST_RESULTS', channel); + const lastStoryIds = new Set(); + + 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); + }; + + const final = processTestReport(report, openInteractionsPanel); -addons.register(ADDON_ID, () => {}); + await api.experimental_updateStatus(ADDON_ID, final); + }); +}); diff --git a/code/addons/vitest/src/plugin/reporter.ts b/code/addons/vitest/src/plugin/reporter.ts new file mode 100644 index 000000000000..2dd6cf73b43d --- /dev/null +++ b/code/addons/vitest/src/plugin/reporter.ts @@ -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, + }); + } +} diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts index 8b4bc31a9013..d02fb185c947 100644 --- a/code/addons/vitest/src/preset.ts +++ b/code/addons/vitest/src/preset.ts @@ -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 { + 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>) => Promise +) => { + 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; + } + + 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('TEST_RESULTS', channel); + + watchTestReportDirectory(reportFile, async (results) => { + console.log('Updating test results:', Object.keys(results.testResults).length); + testReportState.value = results; + }); +} diff --git a/code/addons/vitest/src/types.ts b/code/addons/vitest/src/types.ts new file mode 100644 index 000000000000..f6e6d0ca942c --- /dev/null +++ b/code/addons/vitest/src/types.ts @@ -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[]; + filesUnmatched: number; + filesUpdated: number; + matched: number; + total: number; + unchecked: number; + uncheckedKeysByFile: any[]; + 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; +} diff --git a/code/addons/vitest/src/utils/shared-state.test.ts b/code/addons/vitest/src/utils/shared-state.test.ts new file mode 100644 index 000000000000..eadec231da0c --- /dev/null +++ b/code/addons/vitest/src/utils/shared-state.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { SharedState } from './SharedState'; + +class MockChannel { + private listeners: Record 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'); + }); +}); \ No newline at end of file diff --git a/code/addons/vitest/src/utils/shared-state.ts b/code/addons/vitest/src/utils/shared-state.ts new file mode 100644 index 000000000000..6225ef8b322c --- /dev/null +++ b/code/addons/vitest/src/utils/shared-state.ts @@ -0,0 +1,78 @@ +/* eslint-disable local-rules/no-uncategorized-errors */ +import type { Channel } from '@storybook/channels'; + +export const GET_VALUE = `experimental_useSharedState_getValue`; +export const SET_VALUE = `experimental_useSharedState_setValue`; + +type ChannelLike = Pick; + +const instances = new Map(); + +export class SharedState { + channel: ChannelLike; + + listeners: ((value: T | undefined) => void)[]; + + state: { [key: string]: { index: number; value: T | undefined } }; + + constructor(channel: ChannelLike) { + this.channel = channel; + this.listeners = []; + this.state = {}; + + this.channel.on(SET_VALUE, (key: string, value: T | undefined, index: number) => { + if (this.state?.[key]?.index >= index) return; + this.state[key] = { index, value }; + }); + + this.channel.on(GET_VALUE, (key: string) => { + const index = this.state[key]?.index ?? 0; + const value = this.state[key]?.value; + this.channel.emit(SET_VALUE, key, value, index); + }); + } + + get(key: string) { + if (!this.state[key]) this.channel.emit(GET_VALUE, key); + return this.state[key]?.value; + } + + set(key: string, value: T | undefined) { + const index = (this.state[key]?.index ?? 0) + 1; + this.state[key] = { index, value }; + this.channel.emit(SET_VALUE, key, value, index); + } + + static subscribe(key: string, channel: ChannelLike) { + const sharedState = instances.get(key) || new SharedState(channel); + + if (!instances.has(key)) { + instances.set(key, sharedState); + sharedState.channel.on(SET_VALUE, (k: string, v: T | undefined) => { + if (k !== key) return; + sharedState.listeners.forEach((listener) => listener(v)); + }); + } + + return { + get value(): T | undefined { + return sharedState.get(key); + }, + + set value(newValue: T | undefined) { + sharedState.set(key, newValue); + }, + + on(event: 'change', callback: (value: T | undefined) => void) { + if (event !== 'change') throw new Error('unsupported event'); + sharedState.listeners.push(callback); + }, + + off(event: 'change', callback: (value: T | undefined) => void) { + if (event !== 'change') throw new Error('unsupported event'); + const index = sharedState.listeners.indexOf(callback); + if (index >= 0) sharedState.listeners.splice(index, 1); + }, + }; + } +} \ No newline at end of file diff --git a/code/vitest.config.ts b/code/vitest.config.ts index cabfb4e8b0ce..9eb108ebaa5b 100644 --- a/code/vitest.config.ts +++ b/code/vitest.config.ts @@ -2,6 +2,7 @@ import { coverageConfigDefaults, defineConfig } from 'vitest/config'; export default defineConfig({ test: { + reporters: ['default', '@storybook/experimental-addon-vitest/reporter'], coverage: { all: false, provider: 'istanbul',