Skip to content

Commit

Permalink
Merge pull request #26198 from storybookjs/jeppe/vue-pw-ct
Browse files Browse the repository at this point in the history
Portable Stories: Add support for Playwright CT in Vue3
  • Loading branch information
yannbf authored Mar 18, 2024
2 parents 875783f + d67ef70 commit f0dfc2a
Show file tree
Hide file tree
Showing 17 changed files with 749 additions and 678 deletions.
26 changes: 15 additions & 11 deletions code/lib/instrumenter/src/instrumenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export class Instrumenter {

// Restore state from the parent window in case the iframe was reloaded.
// @ts-expect-error (TS doesn't know about this global variable)
this.state = global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ || {};
this.state = global.window?.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ || {};

// When called from `start`, isDebugging will be true.
const resetState = ({
Expand Down Expand Up @@ -242,8 +242,10 @@ export class Instrumenter {
const patch = typeof update === 'function' ? update(state) : update;
this.state = { ...this.state, [storyId]: { ...state, ...patch } };
// Track state on the parent window so we can reload the iframe without losing state.
// @ts-expect-error (TS doesn't know about this global variable)
global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ = this.state;
if (global.window?.parent) {
// @ts-expect-error fix this later in d.ts file
global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ = this.state;
}
}

cleanup() {
Expand All @@ -259,8 +261,10 @@ export class Instrumenter {
);
const payload: SyncPayload = { controlStates: controlsDisabled, logItems: [] };
this.channel.emit(EVENTS.SYNC, payload);
// @ts-expect-error (TS doesn't know about this global variable)
global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ = this.state;
if (global.window?.parent) {
// @ts-expect-error fix this later in d.ts file
global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ = this.state;
}
}

getLog(storyId: string): LogItem[] {
Expand Down Expand Up @@ -426,7 +430,7 @@ export class Instrumenter {
const { flags, source } = value;
return { __regexp__: { flags, source } };
}
if (value instanceof global.window.HTMLElement) {
if (value instanceof global.window?.HTMLElement) {
const { prefix, localName, id, classList, innerText } = value;
const classNames = Array.from(classList);
return { __element__: { prefix, localName, id, classNames, innerText } };
Expand Down Expand Up @@ -640,23 +644,23 @@ export function instrument<TObj extends Record<string, any>>(
let forceInstrument = false;
let skipInstrument = false;

if (global.window.location?.search?.includes('instrument=true')) {
if (global.window?.location?.search?.includes('instrument=true')) {
forceInstrument = true;
} else if (global.window.location?.search?.includes('instrument=false')) {
} else if (global.window?.location?.search?.includes('instrument=false')) {
skipInstrument = true;
}

// Don't do any instrumentation if not loaded in an iframe unless it's forced - instrumentation can also be skipped.
if ((global.window.parent === global.window && !forceInstrument) || skipInstrument) {
if ((global.window?.parent === global.window && !forceInstrument) || skipInstrument) {
return obj;
}

// Only create an instance if we don't have one (singleton) yet.
if (!global.window.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__) {
if (global.window && !global.window.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__) {
global.window.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__ = new Instrumenter();
}

const instrumenter: Instrumenter = global.window.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__;
const instrumenter: Instrumenter = global.window?.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__;
return instrumenter.instrument(obj, options);
} catch (e) {
// Access to the parent window might fail due to CORS restrictions.
Expand Down
27 changes: 24 additions & 3 deletions code/lib/preview-api/src/modules/store/csf/portable-stories.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable no-underscore-dangle */
/* eslint-disable @typescript-eslint/naming-convention */
import { isExportStory } from '@storybook/csf';
import dedent from 'ts-dedent';
import type {
Renderer,
Args,
Expand Down Expand Up @@ -181,8 +182,28 @@ export function createPlaywrightTest<TFixture extends { extend: any }>(
): TFixture {
return baseTest.extend({
mount: async ({ mount, page }: any, use: any) => {
await use(async (storyRef: WrappedStoryRef) => {
// load the story in the browser
await use(async (storyRef: WrappedStoryRef, ...restArgs: any) => {
// Playwright CT deals with JSX import references differently than normal imports
// and we can currently only handle JSX import references
if (
!('__pw_type' in storyRef) ||
('__pw_type' in storyRef && storyRef.__pw_type !== 'jsx')
) {
// eslint-disable-next-line local-rules/no-uncategorized-errors
throw new Error(dedent`
Portable stories in Playwright CT only work when referencing JSX elements.
Please use JSX format for your components such as:
instead of:
await mount(MyComponent, { props: { foo: 'bar' } })
do:
await mount(<MyComponent foo="bar"/>)
More info: https://storybook.js.org/docs/api/portable-stories-playwright
`);
}

await page.evaluate(async (wrappedStoryRef: WrappedStoryRef) => {
const unwrappedStoryRef = await globalThis.__pwUnwrapObject?.(wrappedStoryRef);
const story =
Expand All @@ -191,7 +212,7 @@ export function createPlaywrightTest<TFixture extends { extend: any }>(
}, storyRef);

// mount the story
const mountResult = await mount(storyRef);
const mountResult = await mount(storyRef, ...restArgs);

// play the story in the browser
await page.evaluate(async (wrappedStoryRef: WrappedStoryRef) => {
Expand Down
15 changes: 11 additions & 4 deletions code/renderers/vue3/src/portable-stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ import * as defaultProjectAnnotations from './entry-preview';
import type { Meta } from './public-types';
import type { VueRenderer } from './types';

type JSXAble<TElement> = TElement & {
new (...args: any[]): any;
$props: any;
};
type MapToJSXAble<T> = {
[K in keyof T]: JSXAble<T[K]>;
};

/** Function that sets the globalConfig of your Storybook. The global config is the preview module of your .storybook folder.
*
* It should be run a single time, so that your global config (e.g. decorators) is applied to your stories when using `composeStories` or `composeStory`.
Expand Down Expand Up @@ -87,7 +95,7 @@ export function composeStory<TArgs extends Args = Args>(

// typing this as newable means TS allows it to be used as a JSX element
// TODO: we should do the same for composeStories as well
return renderable as unknown as typeof composedStory & { new (...args: any[]): any };
return renderable as unknown as JSXAble<typeof composedStory>;
}

/**
Expand Down Expand Up @@ -122,8 +130,7 @@ export function composeStories<TModule extends Store_CSFExports<VueRenderer, any
// @ts-expect-error Deep down TRenderer['canvasElement'] resolves to canvasElement: unknown but VueRenderer uses WebRenderer where canvasElement is HTMLElement, so the types clash
const composedStories = originalComposeStories(csfExports, projectAnnotations, composeStory);

return composedStories as unknown as Omit<
StoriesWithPartialProps<VueRenderer, TModule>,
keyof Store_CSFExports
return composedStories as unknown as MapToJSXAble<
Omit<StoriesWithPartialProps<VueRenderer, TModule>, keyof Store_CSFExports>
>;
}
8 changes: 6 additions & 2 deletions docs/api/portable-stories-playwright.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const SUPPORTED_RENDERERS = ['react', 'vue'];

<Callout variant="info">

The portable stories API for Playwright CT is experimental. Playwright CT itself is also experimental. Breaking changes might occur on either libraries in upcoming releases.

Portable stories are currently only supported in [React](?renderer=react) and [Vue](?renderer=vue) projects.

</Callout>
Expand Down Expand Up @@ -38,7 +40,7 @@ Normally, Storybok composes a story and its [annotations](#annotations) automati

<Callout variant="info">

If your stories use template-based Vue components, you may need to alias the `vue` module to resolve correctly in the Playwright CT environment. You can do this via the [`ctViteConfig` property](https://playwright.dev/docs/test-components#i-have-a-project-that-already-uses-vite-can-i-reuse-the-config):
If your stories use template-based Vue components, you may need to [alias the `vue` module](https://vuejs.org/guide/scaling-up/tooling#note-on-in-browser-template-compilation) to resolve correctly in the Playwright CT environment. You can do this via the [`ctViteConfig` property](https://playwright.dev/docs/test-components#i-have-a-project-that-already-uses-vite-can-i-reuse-the-config):

<details>
<summary>Example Playwright configuration</summary>
Expand Down Expand Up @@ -160,7 +162,7 @@ The code which you write in your Playwright test file is transformed and orchest
Because of this, you have to compose the stories _in a separate file than your own test file_:

```ts
// Button.portable.ts
// Button.stories.portable.ts
// Replace <your-renderer> with your renderer, e.g. react, vue3
import { composeStories } from '@storybook/<your-renderer>';

Expand All @@ -173,6 +175,8 @@ export default composeStories(stories);

You can then import the composed stories in your Playwright test file, as in the [example above](#createtest).

## createTest

<Callout variant="info">

[Read more about Playwright's component testing](https://playwright.dev/docs/test-components#test-stories).
Expand Down
9 changes: 8 additions & 1 deletion docs/snippets/react/portable-stories-playwright-ct.ts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { createTest } from '@storybook/react/experimental-playwright';
import { test as base } from '@playwright/experimental-ct-react';

import stories from './Button.portable';
import stories from './Button.stories.portable';

const test = createTest(base);

Expand All @@ -12,4 +12,11 @@ test('renders primary button', async ({ mount }) => {
// such as loaders, render, and play function
await mount(<stories.Primary />);
});

test('renders primary button with overriden props', async ({ mount }) => {
// You can pass custom props to your component via JSX
const component = await mount(<stories.Primary label="label from test" />);
await expect(component).toContainText('label from test');
await expect(component.getByRole('button')).toHaveClass(/storybook-button--primary/);
});
```
13 changes: 11 additions & 2 deletions docs/snippets/vue/portable-stories-playwright-ct.ts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@
import { createTest } from '@storybook/vue3/experimental-playwright';
import { test as base } from '@playwright/experimental-ct-vue';

import stories from './Button.portable';
import stories from './Button.stories.portable';

const test = createTest(base);

// 👉 Important: Due to current limitations, you can only reference your stories as JSX elements.

test('renders primary button', async ({ mount }) => {
// The mount function will execute all the necessary steps in the story,
// such as loaders, render, and play function
await mount(stories.Primary);
await mount(<stories.Primary />);
});

test('renders primary button with overriden props', async ({ mount }) => {
// You can pass custom props to your component via JSX
const component = await mount(<stories.Primary label="label from test" />);
await expect(component).toContainText('label from test');
await expect(component.getByRole('button')).toHaveClass(/storybook-button--primary/);
});
```
3 changes: 0 additions & 3 deletions scripts/sandbox/generate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable no-console */
import { join, relative } from 'path';
import type { Options as ExecaOptions } from 'execa';
import pLimit from 'p-limit';
Expand All @@ -15,7 +14,6 @@ import { allTemplates as sandboxTemplates } from '../../code/lib/cli/src/sandbox
import storybookVersions from '../../code/lib/core-common/src/versions';
import { JsPackageManagerFactory } from '../../code/lib/core-common/src/js-package-manager/JsPackageManagerFactory';

// eslint-disable-next-line import/no-cycle
import { localizeYarnConfigFiles, setupYarn } from './utils/yarn';
import type { GeneratorConfig } from './utils/types';
import { getStackblitzUrl, renderTemplate } from './utils/template';
Expand Down Expand Up @@ -62,7 +60,6 @@ const withLocalRegistry = async (packageManager: JsPackageManager, action: () =>
await packageManager.setRegistryURL(prevUrl);

if (error) {
// eslint-disable-next-line no-unsafe-finally
throw error;
}
}
Expand Down
2 changes: 1 addition & 1 deletion scripts/sandbox/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { copy, emptyDir, remove, writeFile } from 'fs-extra';
import { execaCommand } from 'execa';

import { getTemplatesData, renderTemplate } from './utils/template';
// eslint-disable-next-line import/no-cycle

import { commitAllToGit } from './utils/git';
import { REPROS_DIRECTORY } from '../utils/constants';
import { glob } from 'glob';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ export const WithLoader: CSF3Story<{ mockFn: (val: string) => string }> = {
},
loaders: [
async () => {
console.log('loading...')
mockFn.mockReturnValueOnce('mockFn return value');
return {
value: 'loaded data',
Expand All @@ -126,7 +125,6 @@ export const WithLoader: CSF3Story<{ mockFn: (val: string) => string }> = {
],
render: (args, { loaded }) => {
const data = args.mockFn('render');
console.log('rendering...')
return (
<div>
<div data-testid="loaded-data">{loaded.value}</div>
Expand All @@ -135,7 +133,6 @@ export const WithLoader: CSF3Story<{ mockFn: (val: string) => string }> = {
);
},
play: async () => {
console.log('playing...')
expect(mockFn).toHaveBeenCalledWith('render');
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ console.log('preview file is called!');

const preview: Preview = {
// TODO: figure out decorators
// decorators: [
// () => ({
// template: `
// <div data-decorator>
// Decorator
// <br />
// <story />
// </div>
// `
// })
// ],
decorators: [
() => ({
template: `
<div data-decorator>
Global Decorator
<br />
<story />
</div>
`
})
],
globalTypes: {
locale: {
description: 'Locale for components',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export default defineConfig({

/* Port to use for Playwright component endpoint. */
ctPort: 3100,
ctViteConfig: {
resolve: {
alias: {
vue: 'vue/dist/vue.esm-bundler.js',
},
},
}
},

/* Configure projects for major browsers */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,45 @@
import { test as base, expect } from '@playwright/experimental-ct-vue';
import { createTest } from '@storybook/vue3/experimental-playwright';
import { test as base } from '@playwright/experimental-ct-vue';

import stories from './Button.stories.playwright';
import stories, { SingleComposedStory, WithSpanishGlobal } from './Button.stories.portable';

const test = createTest(base);

test.skip('renders primary button', async ({ mount }) => {
// TODO: this is not working, probably the translation that Playwright does not work with portable stories yet
await mount(stories.WithLoader);
test('renders with composeStories (plural)', async ({ mount }) => {
const component = await mount(<stories.CSF3Primary />);
await expect(component).toContainText('Global Decorator');
await expect(component).toContainText('foo'); // default arg for the story
});

test('renders with composeStory (singular)', async ({ mount }) => {
const component = await mount(<SingleComposedStory />);
await expect(component).toContainText('Global Decorator');
await expect(component).toContainText('foo'); // default arg for the story
});

test('renders story with props as second argument', async ({ mount }) => {
const component = await mount(<stories.CSF3Button primary={true} label="label from test" />);
await expect(component).toContainText('label from test');
await expect(component.getByRole('button')).toHaveClass(/storybook-button--primary/);
});

test('renders story with custom render', async ({ mount }) => {
const component = await mount(<stories.CSF3ButtonWithRender />);
await expect(component.getByTestId('custom-render')).toContainText('I am a custom render function');
await expect(component.getByRole('button')).toHaveText('foo');
});

test('renders story with global annotations', async ({ mount }) => {
const component = await mount(<WithSpanishGlobal />);
await expect(component).toContainText('Hola!');
});

test('calls loaders', async ({ mount }) => {
const component = await mount(<stories.WithLoader />);
await expect(component.getByTestId('loaded-data')).toContainText('loaded data');
await expect(component.getByTestId('mock-data')).toContainText('mockFn return value');
});

test('calls play', async ({ mount }) => {
const component = await mount(<stories.CSF3InputFieldFilled />);
await expect(component.getByTestId('input')).toHaveValue('Hello world!');
});

This file was deleted.

Loading

0 comments on commit f0dfc2a

Please sign in to comment.