Skip to content
Draft
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
10 changes: 8 additions & 2 deletions .github/actions/setup-playwright/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ runs:
const workspaceRoot = process.env.GITHUB_WORKSPACE || process.cwd();
const workspaceFilePath = path.join(workspaceRoot, 'pnpm-workspace.yaml');
const workspaceConfig = fs.readFileSync(workspaceFilePath, 'utf8');
const normalizedWorkspaceConfig = workspaceConfig.replace(/\\r\\n/g, '\\n');

const playwrightVersion = workspaceConfig.match(
const playwrightVersion = normalizedWorkspaceConfig.match(
/(?:^|\\n)catalog:\\n(?: {2}[^\\n]*\\n)*? {2}playwright:\\s*([^#\\s]+)\\s*(?:#.*)?(?:\\n|$)/,
)?.[1];

Expand Down Expand Up @@ -57,4 +58,9 @@ runs:
- name: Install Playwright Dependencies
shell: bash
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps --only-shell
run: |
if [ "$RUNNER_OS" = "Linux" ]; then
pnpm exec playwright install --with-deps --only-shell
else
pnpm exec playwright install --only-shell
fi
11 changes: 8 additions & 3 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,12 @@ jobs:
run: pnpm turbo run build-storybook

test:
name: Test
runs-on: ubuntu-latest
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
permissions:
id-token: write # used to upload artifacts to codecov
steps:
Expand All @@ -63,6 +67,7 @@ jobs:
run: pnpm turbo run test:ci

- name: Upload coverage to Codecov
if: matrix.os == 'ubuntu-latest'
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
with:
use_oidc: true
Expand All @@ -72,7 +77,7 @@ jobs:

- name: Upload test results to Codecov
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
if: always()
if: always() && matrix.os == 'ubuntu-latest'
with:
use_oidc: true
fail_ci_if_error: true
Expand Down
144 changes: 72 additions & 72 deletions apps/internal-storybook/pnpm-lock.yaml

Large diffs are not rendered by default.

63 changes: 54 additions & 9 deletions apps/internal-storybook/tests/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { fileURLToPath } from 'node:url';
import { x } from 'tinyexec';

export const STORYBOOK_DIR = new URL('..', import.meta.url).pathname;
export const STORYBOOK_DIR = fileURLToPath(new URL('..', import.meta.url));
type StorybookProcess = ReturnType<typeof x>;

export function createMCPRequestBody(method: string, params: any = {}, id: number = 1) {
return {
Expand All @@ -20,32 +22,56 @@ export async function parseMCPResponse(response: Response) {

export async function waitForMcpEndpoint(
endpoint: string,
options: { maxAttempts?: number; interval?: number; acceptStatuses?: number[] } = {},
options: {
maxAttempts?: number;
interval?: number;
acceptStatuses?: number[];
storybookProcess?: StorybookProcess | null;
} = {},
): Promise<void> {
const { maxAttempts = 120, interval = 500, acceptStatuses = [] } = options;
const { maxAttempts = 120, interval = 500, acceptStatuses = [], storybookProcess } = options;
const { promise, resolve, reject } = Promise.withResolvers<void>();
let attempts = 0;
let lastStatus: number | null = null;
let lastErrorMessage: string | null = null;

const intervalId = setInterval(async () => {
attempts++;
try {
const storybookPid = storybookProcess?.process?.pid;
const storybookExitCode = storybookProcess?.process?.exitCode;
if (storybookPid && storybookExitCode !== null) {
clearInterval(intervalId);
reject(
new Error(
`Storybook exited before MCP became ready (pid=${storybookPid}, exitCode=${storybookExitCode})`,
),
);
return;
}

const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createMCPRequestBody('tools/list')),
});
lastStatus = response.status;
if (response.ok || acceptStatuses.includes(response.status)) {
clearInterval(intervalId);
resolve();
return;
}
} catch {
// Server not ready yet
} catch (error) {
lastErrorMessage = error instanceof Error ? error.message : String(error);
}

if (attempts >= maxAttempts) {
clearInterval(intervalId);
reject(new Error('MCP endpoint failed to start within the timeout period'));
reject(
new Error(
`MCP endpoint failed to start in time (attempts=${attempts}, lastStatus=${lastStatus ?? 'none'}, lastError=${lastErrorMessage ?? 'none'})`,
),
);
}
}, interval);

Expand Down Expand Up @@ -80,8 +106,27 @@ export async function stopStorybook(storybookProcess: ReturnType<typeof x> | nul
if (!storybookProcess || !storybookProcess.process) {
return;
}
const kill = Promise.withResolvers<void>();
storybookProcess.process.on('exit', kill.resolve);
const processToStop = storybookProcess.process;
if (processToStop.exitCode !== null || !processToStop.pid) {
return;
}

const waitForExit = Promise.withResolvers<void>();
processToStop.once('exit', () => waitForExit.resolve());

storybookProcess.kill('SIGTERM');
await kill.promise;
const timeout = setTimeout(async () => {
try {
if (process.platform === 'win32') {
await x('taskkill', ['/pid', String(processToStop.pid), '/t', '/f']);
} else {
processToStop.kill('SIGKILL');
}
} catch {
// Process may already be gone.
}
}, 5_000);

await waitForExit.promise;
clearTimeout(timeout);
}
11 changes: 8 additions & 3 deletions apps/internal-storybook/tests/mcp-composition-auth.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
const PORT = 6008;
const MCP_ENDPOINT = `http://localhost:${PORT}/mcp`;
const WELL_KNOWN_ENDPOINT = `http://localhost:${PORT}/.well-known/oauth-protected-resource`;
const STARTUP_TIMEOUT = 30_000;
const STARTUP_TIMEOUT = 45_000;
const SHUTDOWN_TIMEOUT = 30_000;

let storybookProcess: ReturnType<typeof x> | null = null;

Expand All @@ -35,13 +36,17 @@ describe('MCP Composition Auth E2E Tests', () => {
beforeAll(async () => {
await killPort(PORT);
storybookProcess = startStorybook('.storybook-composition-auth', PORT);
await waitForMcpEndpoint(MCP_ENDPOINT, { acceptStatuses: [401] });
await waitForMcpEndpoint(MCP_ENDPOINT, {
maxAttempts: 80,
acceptStatuses: [401],
storybookProcess,
});
}, STARTUP_TIMEOUT);

afterAll(async () => {
await stopStorybook(storybookProcess);
storybookProcess = null;
});
}, SHUTDOWN_TIMEOUT);

describe('OAuth Discovery', () => {
it('should expose .well-known/oauth-protected-resource for private refs', async () => {
Expand Down
10 changes: 7 additions & 3 deletions apps/internal-storybook/tests/mcp-composition.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@

const PORT = 6007;
const MCP_ENDPOINT = `http://localhost:${PORT}/mcp`;
const STARTUP_TIMEOUT = 30_000;
const STARTUP_TIMEOUT = 45_000;
const SHUTDOWN_TIMEOUT = 30_000;

let storybookProcess: ReturnType<typeof x> | null = null;

Expand All @@ -33,13 +34,16 @@
beforeAll(async () => {
await killPort(PORT);
storybookProcess = startStorybook('.storybook-composition', PORT);
await waitForMcpEndpoint(MCP_ENDPOINT);
await waitForMcpEndpoint(MCP_ENDPOINT, {
maxAttempts: 80,
storybookProcess,
});
}, STARTUP_TIMEOUT);

afterAll(async () => {
await stopStorybook(storybookProcess);
storybookProcess = null;
});
}, SHUTDOWN_TIMEOUT);

describe('Multi-Source Documentation', () => {
it('should list documentation from both local and remote sources', async () => {
Expand Down Expand Up @@ -188,7 +192,7 @@
expect(text).toContain('example-button');
});

it('should silently exclude refs that have no manifest', async () => {

Check failure on line 195 in apps/internal-storybook/tests/mcp-composition.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Test (windows-latest)

tests/mcp-composition.e2e.test.ts > MCP Composition E2E Tests > Multi-Source Documentation > should silently exclude refs that have no manifest

Error: Test timed out in 30000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ tests/mcp-composition.e2e.test.ts:195:3
const response = await mcpRequest('tools/call', {
name: 'list-all-documentation',
arguments: {},
Expand All @@ -201,7 +205,7 @@
expect(text).not.toContain('no-manifest');
});

it('should require storybookId in multi-source mode', async () => {

Check failure on line 208 in apps/internal-storybook/tests/mcp-composition.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Test (windows-latest)

tests/mcp-composition.e2e.test.ts > MCP Composition E2E Tests > Multi-Source Documentation > should require storybookId in multi-source mode

Error: Test timed out in 30000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ tests/mcp-composition.e2e.test.ts:208:3
const response = await mcpRequest('tools/call', {
name: 'get-documentation',
arguments: {
Expand All @@ -224,7 +228,7 @@
});

describe('Public Refs (No Auth)', () => {
it('should not require authentication for public refs', async () => {

Check failure on line 231 in apps/internal-storybook/tests/mcp-composition.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Test (windows-latest)

tests/mcp-composition.e2e.test.ts > MCP Composition E2E Tests > Public Refs (No Auth) > should not require authentication for public refs

Error: Test timed out in 30000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ tests/mcp-composition.e2e.test.ts:231:3
// The .well-known endpoint should return "Not found" for public refs
const response = await fetch(`http://localhost:${PORT}/.well-known/oauth-protected-resource`);
const text = await response.text();
Expand All @@ -235,7 +239,7 @@
});

describe('Tools Schema', () => {
it('should include storybookId parameter in get-documentation schema', async () => {

Check failure on line 242 in apps/internal-storybook/tests/mcp-composition.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Test (windows-latest)

tests/mcp-composition.e2e.test.ts > MCP Composition E2E Tests > Tools Schema > should include storybookId parameter in get-documentation schema

Error: Test timed out in 30000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ tests/mcp-composition.e2e.test.ts:242:3
const response = await mcpRequest('tools/list');

const getDocTool = response.result.tools.find((t: any) => t.name === 'get-documentation');
Expand Down
18 changes: 9 additions & 9 deletions apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { x } from 'tinyexec';
import {
STORYBOOK_DIR,
createMCPRequestBody,
parseMCPResponse,
waitForMcpEndpoint,
killPort,
startStorybook,
stopStorybook,
} from './helpers';

const PORT = 6006;
const MCP_ENDPOINT = `http://localhost:${PORT}/mcp`;
const STARTUP_TIMEOUT = 30_000;
const STARTUP_TIMEOUT = 45_000;
const SHUTDOWN_TIMEOUT = 30_000;

let storybookProcess: ReturnType<typeof x> | null = null;

Expand All @@ -32,19 +33,18 @@
describe('MCP Endpoint E2E Tests', () => {
beforeAll(async () => {
await killPort(PORT);
storybookProcess = x('pnpm', ['storybook'], {
nodeOptions: {
cwd: STORYBOOK_DIR,
},
});
storybookProcess = startStorybook('.storybook', PORT);

await waitForMcpEndpoint(MCP_ENDPOINT);
await waitForMcpEndpoint(MCP_ENDPOINT, {
maxAttempts: 80,
storybookProcess,
});
}, STARTUP_TIMEOUT);

afterAll(async () => {
await stopStorybook(storybookProcess);
storybookProcess = null;
});
}, SHUTDOWN_TIMEOUT);

describe('Session Initialization', () => {
it('should successfully initialize an MCP session', async () => {
Expand Down Expand Up @@ -651,7 +651,7 @@
});

describe('Tool: get-documentation', () => {
it('should return documentation for a specific component', async () => {

Check failure on line 654 in apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Test (windows-latest)

tests/mcp-endpoint.e2e.test.ts > MCP Endpoint E2E Tests > Tool: get-documentation > should return documentation for a specific component

Error: Test timed out in 30000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ tests/mcp-endpoint.e2e.test.ts:654:3
// First, get the list to find a valid component ID
const listResponse = await mcpRequest('tools/call', {
name: 'list-all-documentation',
Expand Down Expand Up @@ -690,7 +690,7 @@
expect(text).toContain('<Canvas of={ButtonStories.Primary} />');
});

it('should return error for non-existent component', async () => {

Check failure on line 693 in apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Test (windows-latest)

tests/mcp-endpoint.e2e.test.ts > MCP Endpoint E2E Tests > Tool: get-documentation > should return error for non-existent component

Error: Test timed out in 30000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ tests/mcp-endpoint.e2e.test.ts:693:3
const response = await mcpRequest('tools/call', {
name: 'get-documentation',
arguments: {
Expand All @@ -713,7 +713,7 @@
});

describe('Tool: run-story-tests', () => {
it('should run all tests when stories are omitted', async () => {

Check failure on line 716 in apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Test (windows-latest)

tests/mcp-endpoint.e2e.test.ts > MCP Endpoint E2E Tests > Tool: run-story-tests > should run all tests when stories are omitted

Error: Test timed out in 30000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ tests/mcp-endpoint.e2e.test.ts:716:3
const response = await mcpRequest('tools/call', {
name: 'run-story-tests',
arguments: {},
Expand All @@ -725,7 +725,7 @@
expect(text).toContain('page--logged-out');
});

it('should run tests for a story and report accessibility violations', async () => {

Check failure on line 728 in apps/internal-storybook/tests/mcp-endpoint.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Test (windows-latest)

tests/mcp-endpoint.e2e.test.ts > MCP Endpoint E2E Tests > Tool: run-story-tests > should run tests for a story and report accessibility violations

Error: Test timed out in 30000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ tests/mcp-endpoint.e2e.test.ts:728:3
const cwd = process.cwd();
const storyPath = cwd.endsWith('/apps/internal-storybook')
? `${cwd}/stories/components/Button.stories.ts`
Expand Down
7 changes: 5 additions & 2 deletions apps/internal-storybook/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import { defineConfig } from 'vitest/config';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';

const isWindows = process.platform === 'win32';
const defaultTimeout = isWindows ? 30_000 : 15_000;

// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default defineConfig({
test: {
testTimeout: 15_000,
hookTimeout: 15_000,
testTimeout: defaultTimeout,
hookTimeout: defaultTimeout,
projects: [
// E2E tests project (for MCP endpoint testing)
{
Expand Down
Loading
Loading