-
-
Notifications
You must be signed in to change notification settings - Fork 9.3k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 = {}; | ||
|
||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
}); | ||
}); |
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, | ||
}); | ||
} | ||
} |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
}); | ||
} |
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[]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: Consider using a more specific type than |
||
filesUnmatched: number; | ||
filesUpdated: number; | ||
matched: number; | ||
total: number; | ||
unchecked: number; | ||
uncheckedKeysByFile: any[]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: Consider using a more specific type than |
||
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; | ||
} |
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'); | ||
}); | ||
}); |
There was a problem hiding this comment.
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