diff --git a/eslint.config.mjs b/eslint.config.mjs index 900c53d868258..8eda40f3a5675 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -56,7 +56,7 @@ const ignores = [ "packages/playwright-core/src/tools/skills/", "packages/html-reporter/bundle.ts", "packages/html-reporter/playwright.config.ts", - "packages/html-reporter/playwright/*", + "packages/html-reporter/tests/*", "packages/html-reporter/vite.config.ts", "test-results/", "tests/assets/", diff --git a/packages/html-reporter/playwright.config.ts b/packages/html-reporter/playwright.config.ts index 22b4ac5f4c73d..149c44f4eaa67 100644 --- a/packages/html-reporter/playwright.config.ts +++ b/packages/html-reporter/playwright.config.ts @@ -38,7 +38,7 @@ export default defineConfig({ ], tag: process.env.PW_TAG, use: { - baseURL: 'http://localhost:3101/playwright/gallery/index.html', + baseURL: 'http://localhost:3101/tests/index.html', serviceWorkers: 'block', reuseContext: true, trace: 'on-first-retry', @@ -49,7 +49,7 @@ export default defineConfig({ }], webServer: { command: 'npx vite --port 3101 --strictPort', - url: 'http://localhost:3101/playwright/gallery/index.html', + url: 'http://localhost:3101/tests/index.html', reuseExistingServer: !process.env.CI, }, }); diff --git a/packages/html-reporter/src/chip.spec.ts b/packages/html-reporter/src/chip.spec.ts index c48a9b79b635d..45d9df57ebcd0 100644 --- a/packages/html-reporter/src/chip.spec.ts +++ b/packages/html-reporter/src/chip.spec.ts @@ -16,12 +16,10 @@ import { expect, test } from '@playwright/test'; -import type { Auto, AutoCollapsed, NotExpandable, Stateful, WithBody } from './chip.story'; - test.use({ viewport: { width: 500, height: 500 } }); test('expand collapse', async ({ mount }) => { - const component = await mount('chip/Auto'); + const component = await mount('html-reporter/chip/Auto'); await expect(component.getByText('Chip body')).toBeVisible(); await component.getByText('Title').click(); await expect(component.getByText('Chip body')).not.toBeVisible(); @@ -31,13 +29,13 @@ test('expand collapse', async ({ mount }) => { test('render long title', async ({ mount }) => { const title = 'Extremely long title. '.repeat(10); - const component = await mount('chip/Auto', { header: title }); + const component = await mount('html-reporter/chip/Auto', { header: title }); await expect(component).toContainText('Extremely long title.'); await expect(component.getByText('Extremely long title.')).toHaveAttribute('title', title); }); test('setExpanded is called', async ({ mount }) => { - const component = await mount('chip/Stateful'); + const component = await mount('html-reporter/chip/Stateful'); await component.getByText('Title').click(); await expect(component.getByTestId('expanded')).toHaveValue('true'); await component.getByText('Title').click(); @@ -45,13 +43,13 @@ test('setExpanded is called', async ({ mount }) => { }); test('body render prop is rendered', async ({ mount }) => { - const component = await mount('chip/WithBody'); + const component = await mount('html-reporter/chip/WithBody'); await expect(component.getByText('Body from render prop')).toBeVisible(); await expect(component.getByText('Chip children')).toBeVisible(); }); test('chip without setExpanded is a heading', async ({ mount }) => { - const component = await mount('chip/NotExpandable'); + const component = await mount('html-reporter/chip/NotExpandable'); await expect(component.getByRole('button')).toHaveCount(0); await expect(component).toMatchAriaSnapshot(` - heading "Title" [level=2] @@ -60,7 +58,7 @@ test('chip without setExpanded is a heading', async ({ mount }) => { }); test('expand collapse with the keyboard', async ({ mount, page }) => { - const component = await mount('chip/AutoCollapsed'); + const component = await mount('html-reporter/chip/AutoCollapsed'); const header = component.getByRole('button', { name: 'Title' }); await header.focus(); await expect(header).toBeFocused(); @@ -71,7 +69,7 @@ test('expand collapse with the keyboard', async ({ mount, page }) => { }); test('setExpanded should work', async ({ mount }) => { - const component = await mount('chip/AutoCollapsed'); + const component = await mount('html-reporter/chip/AutoCollapsed'); await component.getByText('Title').click(); await expect(component).toMatchAriaSnapshot(` - button "Title" [expanded] diff --git a/packages/html-reporter/src/headerView.spec.ts b/packages/html-reporter/src/headerView.spec.ts index 429aba31b3bdf..19e6815272b2c 100644 --- a/packages/html-reporter/src/headerView.spec.ts +++ b/packages/html-reporter/src/headerView.spec.ts @@ -16,12 +16,10 @@ import { expect, test } from '@playwright/test'; -import type { Default } from './headerView.story'; - test.use({ viewport: { width: 720, height: 200 } }); test('should render counters', async ({ mount }) => { - const component = await mount('headerView/Default'); + const component = await mount('html-reporter/headerView/Default'); await expect(component.locator('a', { hasText: 'All' }).locator('.counter')).toHaveText('90'); await expect(component.locator('a', { hasText: 'Passed' }).locator('.counter')).toHaveText('42'); await expect(component.locator('a', { hasText: 'Failed' }).locator('.counter')).toHaveText('31'); @@ -38,7 +36,7 @@ test('should render counters', async ({ mount }) => { }); test('should open settings with keyboard', async ({ mount }) => { - const component = await mount('headerView/Default'); + const component = await mount('html-reporter/headerView/Default'); const settings = component.getByRole('button', { name: 'Settings' }); const dialog = component.getByTestId('settings-dialog'); await settings.focus(); @@ -51,7 +49,7 @@ test('should open settings with keyboard', async ({ mount }) => { }); test('should toggle filters', async ({ page, mount }) => { - const component = await mount('headerView/Default'); + const component = await mount('html-reporter/headerView/Default'); const filterText = component.getByTestId('filter-text'); await component.locator('a', { hasText: 'All' }).click(); await expect(filterText).toHaveValue(''); diff --git a/packages/html-reporter/src/testCaseView.spec.ts b/packages/html-reporter/src/testCaseView.spec.ts index e4f44e7ac4864..0c6ddda94dcda 100644 --- a/packages/html-reporter/src/testCaseView.spec.ts +++ b/packages/html-reporter/src/testCaseView.spec.ts @@ -16,12 +16,10 @@ import { expect, test } from '@playwright/test'; -import type { AnnotationLinks, AttachmentLinks, Default, PrevNext, TwoAttempts } from './testCaseView.story'; - test.use({ viewport: { width: 800, height: 600 } }); test('should render test case', async ({ mount }) => { - const component = await mount('testCaseView/Default'); + const component = await mount('html-reporter/testCaseView/Default'); await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible(); await expect(component.getByText('Hidden annotation')).toBeHidden(); await component.getByText('Annotations').click(); @@ -37,7 +35,7 @@ test('should render test case', async ({ mount }) => { test('should render copy buttons for annotations', async ({ mount, page, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']); - const component = await mount('testCaseView/Default'); + const component = await mount('html-reporter/testCaseView/Default'); await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible(); await component.getByText('Annotation text', { exact: false }).first().hover(); await expect(component.locator('.test-case-annotation').getByLabel('Copy to clipboard').first()).toBeVisible(); @@ -48,7 +46,7 @@ test('should render copy buttons for annotations', async ({ mount, page, context }); test('should correctly render links in annotations', async ({ mount }) => { - const component = await mount('testCaseView/AnnotationLinks'); + const component = await mount('html-reporter/testCaseView/AnnotationLinks'); const firstLink = component.getByText('https://playwright.dev/docs/intro').first(); await expect(firstLink).toBeVisible(); @@ -68,7 +66,7 @@ test('should correctly render links in annotations', async ({ mount }) => { }); test('should correctly render links in attachments', async ({ mount }) => { - const component = await mount('testCaseView/AttachmentLinks'); + const component = await mount('html-reporter/testCaseView/AttachmentLinks'); await component.getByText('first attachment').click(); const body = component.getByText('The body with https://playwright.dev/docs/intro link'); await expect(body).toBeVisible(); @@ -81,7 +79,7 @@ test('should correctly render links in attachments', async ({ mount }) => { }); test('should correctly render links in attachment name', async ({ mount }) => { - const component = await mount('testCaseView/AttachmentLinks'); + const component = await mount('html-reporter/testCaseView/AttachmentLinks'); const link = component.getByText('attachment with inline link').locator('a'); await expect(link).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/31284'); await expect(link).toHaveText('https://github.com/microsoft/playwright/issues/31284'); @@ -91,7 +89,7 @@ test('should correctly render links in attachment name', async ({ mount }) => { }); test('should correctly render prev and next', async ({ mount }) => { - const component = await mount('testCaseView/PrevNext'); + const component = await mount('html-reporter/testCaseView/PrevNext'); await expect(component).toMatchAriaSnapshot(` - text: group - link "« previous" @@ -101,7 +99,7 @@ test('should correctly render prev and next', async ({ mount }) => { }); test('total duration is selected run duration', async ({ mount, page }) => { - const component = await mount('testCaseView/TwoAttempts'); + const component = await mount('html-reporter/testCaseView/TwoAttempts'); await expect(component).toMatchAriaSnapshot(` - text: "Test with two attempts test.spec.ts:42 200ms chromium" - tablist: diff --git a/packages/html-reporter/src/testFileView.spec.ts b/packages/html-reporter/src/testFileView.spec.ts index 55e9d88439107..28949ec810075 100644 --- a/packages/html-reporter/src/testFileView.spec.ts +++ b/packages/html-reporter/src/testFileView.spec.ts @@ -16,12 +16,10 @@ import { expect, test } from '@playwright/test'; -import type { Default } from './testFileView.story'; - test.use({ viewport: { width: 800, height: 600 } }); test('should render project links', async ({ mount, page }) => { - const component = await mount('testFileView/Default'); + const component = await mount('html-reporter/testFileView/Default'); await expect(component.locator('.label', { hasText: 'chromium' })).toHaveCount(5); await expect(component.locator('.label', { hasText: 'webkit' })).toHaveCount(1); const webkitLabel = component.locator('.label', { hasText: 'webkit' }); diff --git a/packages/html-reporter/playwright/gallery/index.html b/packages/html-reporter/tests/index.html similarity index 100% rename from packages/html-reporter/playwright/gallery/index.html rename to packages/html-reporter/tests/index.html diff --git a/packages/html-reporter/playwright/gallery/main.tsx b/packages/html-reporter/tests/main.tsx similarity index 90% rename from packages/html-reporter/playwright/gallery/main.tsx rename to packages/html-reporter/tests/main.tsx index 467a9ecb2e3e4..764468d8561d0 100644 --- a/packages/html-reporter/playwright/gallery/main.tsx +++ b/packages/html-reporter/tests/main.tsx @@ -16,11 +16,12 @@ import { flushSync } from 'react-dom'; import { createRoot, type Root } from 'react-dom/client'; -import '../../src/theme.css'; -import { SearchParamsProvider } from '../../src/links'; +import '../src/theme.css'; +import { SearchParamsProvider } from '../src/links'; +import packageJSON from '../package.json'; -const stories = import.meta.glob('../../src/**/*.story.{tsx,jsx}'); -const storyId = (file: string) => file.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, ''); +const stories = import.meta.glob('../src/**/*.story.{tsx,jsx}'); +const storyId = (file: string) => packageJSON.name + '/' + file.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, ''); async function resolveStory(id: string): Promise | undefined> { const sep = id.lastIndexOf('/'); diff --git a/packages/html-reporter/tests/stories.d.ts b/packages/html-reporter/tests/stories.d.ts new file mode 100644 index 0000000000000..1eb6972795779 --- /dev/null +++ b/packages/html-reporter/tests/stories.d.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// This file is generated by storyTypes.ts, do not edit manually. + +type StoriesOf = { [K in keyof Mod & string as `${Prefix}/${K}`]: Mod[K] }; + +declare module '@playwright/test' { + interface Stories extends + StoriesOf<'html-reporter/chip', typeof import('../src/chip.story')>, + StoriesOf<'html-reporter/headerView', typeof import('../src/headerView.story')>, + StoriesOf<'html-reporter/testCaseView', typeof import('../src/testCaseView.story')>, + StoriesOf<'html-reporter/testFileView', typeof import('../src/testFileView.story')> {} +} + +export {}; diff --git a/packages/html-reporter/tests/storyTypes.ts b/packages/html-reporter/tests/storyTypes.ts new file mode 100644 index 0000000000000..e0b1791cd4877 --- /dev/null +++ b/packages/html-reporter/tests/storyTypes.ts @@ -0,0 +1,95 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import path from 'path'; +import type { Plugin } from 'vite'; + +const storyFile = /\.story\.(tsx|jsx)$/; +const license = `/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */`; + +// Story ids follow the gallery convention in main.tsx. +export function storyTypes(options: { prefix: string, src: string, outFile: string }): Plugin { + const generate = () => { + const content = render(listStoryFiles(options.src), options); + if (fs.existsSync(options.outFile) && fs.readFileSync(options.outFile, 'utf8') === content) + return; + fs.writeFileSync(options.outFile, content); + }; + return { + name: 'playwright-story-types', + buildStart() { + this.addWatchFile(options.src); + generate(); + }, + configureServer(server) { + const onFile = (file: string) => { + if (storyFile.test(file)) + generate(); + }; + server.watcher.on('add', onFile); + server.watcher.on('unlink', onFile); + }, + }; +} + +function listStoryFiles(dir: string): string[] { + const result: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) + result.push(...listStoryFiles(file)); + else if (storyFile.test(entry.name)) + result.push(file); + } + return result.sort(); +} + +function render(files: string[], { prefix, src, outFile }: { prefix: string, src: string, outFile: string }): string { + const outDir = path.dirname(outFile); + const posix = (p: string) => p.split(path.sep).join('/'); + const entries = files.map(file => { + const id = prefix + '/' + posix(path.relative(src, file)).replace(storyFile, ''); + const specifier = posix(path.relative(outDir, file)).replace(/\.\w+$/, ''); + return ` StoriesOf<'${id}', typeof import('${specifier.startsWith('.') ? specifier : './' + specifier}')>`; + }); + const stories = entries.length ? `interface Stories extends\n${entries.join(',\n')} {}` : 'interface Stories {}'; + return `${license} +// This file is generated by ${posix(path.relative(outDir, __filename))}, do not edit manually. + +type StoriesOf = { [K in keyof Mod & string as \`\${Prefix}/\${K}\`]: Mod[K] }; + +declare module '@playwright/test' { + ${stories} +} + +export {}; +`; +} diff --git a/packages/html-reporter/vite.config.ts b/packages/html-reporter/vite.config.ts index 767608dd639ea..93ec7ddde5153 100644 --- a/packages/html-reporter/vite.config.ts +++ b/packages/html-reporter/vite.config.ts @@ -17,6 +17,8 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { bundle } from './bundle'; +import { storyTypes } from './tests/storyTypes'; +import packageJSON from './package.json'; import path from 'path'; // https://vitejs.dev/config/ @@ -24,7 +26,8 @@ export default defineConfig({ base: '', plugins: [ react(), - bundle() + bundle(), + storyTypes({ prefix: packageJSON.name, src: path.resolve(__dirname, 'src'), outFile: path.resolve(__dirname, 'tests/stories.d.ts') }), ], resolve: { alias: { diff --git a/packages/playwright-core/src/tools/skills/playwright-component-testing/SKILL.md b/packages/playwright-core/src/tools/skills/playwright-component-testing/SKILL.md index 3780d2c1203d2..60b97bf8604e3 100644 --- a/packages/playwright-core/src/tools/skills/playwright-component-testing/SKILL.md +++ b/packages/playwright-core/src/tools/skills/playwright-component-testing/SKILL.md @@ -45,7 +45,7 @@ Everything the component needs must be set up *inside the story* (it runs in the ## Conventions -- Story id: path under `src/` without the `.story.*` extension, plus the export name — `src/components/Button.story.tsx` export `Primary` → `components/Button/Primary`. Any unique suffix works too: `mount('Button/Primary')`. A `.story.vue` single-file component is one story, addressable by its path alone (its `default` export). +- Story id: path under `src/` without the `.story.*` extension, plus the export name — `src/components/Button.story.tsx` export `Primary` → `components/Button/Primary`. Any unique suffix works too: `mount('Button/Primary')`. A `.story.vue` single-file component is one story, addressable by its path alone (its `default` export). With gallery types (`references/typing.md`) ids are prefixed with the package name: `acme-ui/components/Button/Primary`. - One export per scenario. Prefer a new story export over parameterizing an existing one — stories are greppable, reviewable documentation of component states. ## Testing patterns @@ -89,15 +89,7 @@ export const WithTitle = ({ title = 'Default' }: { title?: string }) => const component = await mount('components/Button/WithTitle', { title: 'Hello' }); ``` -`mount` is generic over the story: pass the story type as a template argument to type-check the props (and `update()`): - -```ts -import type { WithTitle } from './Button.story'; - -const component = await mount('components/Button/WithTitle', { title: 'Hello' }); -``` - -This works for React and Vue stories alike; Vue stories must additionally declare the props at runtime — see the `Typed props` sections in `references/react.md` / `references/vue.md`. +Props are type-checked in two optional ways, see `references/typing.md`: pass the story type as a template argument (`mount('components/Button/WithTitle', { title: 'Hello' })`, no setup), or generate gallery types with a small Vite plugin so the id itself is typed (`mount('acme-ui/components/Button/WithTitle', { title: 'Hello' })`, with autocomplete and rename safety). Vue stories must additionally declare the props at runtime — see the `Typed props` sections in `references/react.md` / `references/vue.md`. ### Prop transitions with `update()` @@ -133,11 +125,12 @@ Open your gallery URL (`baseURL`) in a browser and call `await window.mount({ st ## Decision points -- **Monorepos / non-`src` layouts**: change the glob and the id derivation in your gallery (`references/gallery-spec.md`) to match. +- **Monorepos / non-`src` layouts**: change the glob and the id derivation in your gallery (`references/gallery-spec.md`) to match, and prefix ids with the package name (`references/typing.md`). - **Global providers** (theme, i18n, store, router): create a shared `decorator` helper next to the gallery and wrap components in stories; see `references/react.md` / `references/vue.md`. ## References - `references/gallery-spec.md` — the gallery endpoint contract to implement (**start here**). +- `references/typing.md` — optional typing for `mount`: explicit story types vs generated gallery types, with the Vite plugin. - `references/react.md` — React walkthrough: providers, StrictMode, CSS. - `references/vue.md` — Vue walkthrough: `.story.ts` and `.story.vue` stories, plugins. - `references/migration.md` — migrating off `@playwright/experimental-ct-react` / `-vue`. diff --git a/packages/playwright-core/src/tools/skills/playwright-component-testing/references/migration.md b/packages/playwright-core/src/tools/skills/playwright-component-testing/references/migration.md index d88c85b9be294..8e4c4fa8aae68 100644 --- a/packages/playwright-core/src/tools/skills/playwright-component-testing/references/migration.md +++ b/packages/playwright-core/src/tools/skills/playwright-component-testing/references/migration.md @@ -49,6 +49,7 @@ pinned to Playwright 1.62 and upgrade once the last spec is ported. - **Story ids are strings.** Renaming or moving a story breaks specs at runtime, not compile time — and the suffix-matching resolution can silently match a different story after a rename. + Gallery types (`typing.md`) turn registered ids into a compile-time check. - **Per-test JSX is gone.** Any test that built a different JSX tree per test (children matrices, inline wrappers) becomes one story export per composition. diff --git a/packages/playwright-core/src/tools/skills/playwright-component-testing/references/react.md b/packages/playwright-core/src/tools/skills/playwright-component-testing/references/react.md index 4b879478c6a37..d5351ab8dcd73 100644 --- a/packages/playwright-core/src/tools/skills/playwright-component-testing/references/react.md +++ b/packages/playwright-core/src/tools/skills/playwright-component-testing/references/react.md @@ -57,6 +57,8 @@ import type { WithTitle } from './Button.story'; const component = await mount('components/Button/WithTitle', { title: 'Hello' }); ``` +Alternatively, generate gallery types so the id itself is typed and no type import is needed: `mount('acme-ui/components/Button/WithTitle', { title: 'Hello' })` — see `references/typing.md`. + ## CSS - Global stylesheets: import them in your gallery entry (`playwright/gallery/main.tsx`, e.g. `import '../../src/index.css'`), mirroring the app's own entry point. diff --git a/packages/playwright-core/src/tools/skills/playwright-component-testing/references/typing.md b/packages/playwright-core/src/tools/skills/playwright-component-testing/references/typing.md new file mode 100644 index 0000000000000..e3119ee08e687 --- /dev/null +++ b/packages/playwright-core/src/tools/skills/playwright-component-testing/references/typing.md @@ -0,0 +1,173 @@ +# Typing `mount()` + +`mount(storyId, props?)` accepts any string id and any serializable props. Two optional layers +add type checking on top; both are supported and can be mixed in one project. + +| | Explicit story type | Gallery types | +|---|---|---| +| Call | `mount('components/Button/WithTitle', { title })` | `mount('acme-ui/components/Button/WithTitle', { title })` | +| Checks | props and `update()` | props, `update()`, and the id itself (autocomplete, rename safety) | +| Needs | `import type { WithTitle } from './Button.story'` in the spec | a generated `stories.d.ts` kept in sync by a gallery plugin | +| Id grammar | any suffix the gallery resolves | full id, prefixed with the package name | + +## Explicit story type + +Pass the story type as a template argument. Nothing to set up; works in every project. + +```ts +import type { WithTitle } from './Button.story'; + +const component = await mount('components/Button/WithTitle', { title: 'Hello' }); +await component.update({ title: 'Again' }); +``` + +Use `import type` so the story (and with it React/Vue and CSS imports) is never loaded into the +Node test process. Function components, class components and `defineComponent` stories all infer +their props; see the `Typed props` sections in `react.md` / `vue.md` for framework details. + +## Gallery types + +`@playwright/test` exports an empty `interface Stories {}`. When a story id is a key of `Stories`, +`mount` types the props from that entry; any other string keeps untyped props, so adoption can be +partial. A tiny Vite plugin next to the gallery generates `stories.d.ts`, which fills `Stories` in +via module augmentation: + +```ts +// playwright/gallery/stories.d.ts (generated) +type StoriesOf = { [K in keyof Mod & string as `${Prefix}/${K}`]: Mod[K] }; + +declare module '@playwright/test' { + interface Stories extends + StoriesOf<'acme-ui/components/Button', typeof import('../../src/components/Button.story')>, + StoriesOf<'acme-ui/components/Expandable', typeof import('../../src/components/Expandable.story')> {} +} + +export {}; +``` + +One line per story file; export names and prop types come from the story module itself, so the +file only changes when a story file is added, removed or moved. Specs then need no type imports: + +```ts +const component = await mount('acme-ui/components/Button/WithTitle', { title: 'Hello' }); +// ^ autocompletes registered ids ^ checked against WithTitle's props +``` + +### Namespace ids with the package name + +`Stories` is merged per TypeScript program. In a monorepo whose packages share one `tsconfig`, +two packages both registering `components/Button/Default` with different props fail with +`TS2320: Interface 'Stories' cannot simultaneously extend types ...` at the `Stories` declaration, +and even distinct ids leak into each other's autocomplete. Prefix every id with the package name, +`//`, from the start, so the convention holds when a project +becomes a package. Read the name from `package.json` in both places that derive ids, so they +cannot drift: + +```ts +// playwright/gallery/main.tsx +import packageJSON from '../../package.json'; + +const id = (f: string) => packageJSON.name + '/' + f.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, ''); +``` + +Suffix resolution keeps working at runtime, so `mount('Button/Primary')` still renders; only the +full prefixed id is typed. + +### The plugin + +Framework-agnostic: it only lists story files. Adjust `storyFile` to match the gallery's glob. + +```ts +// playwright/gallery/storyTypes.ts +import fs from 'fs'; +import path from 'path'; +import type { Plugin } from 'vite'; + +const storyFile = /\.story\.(tsx|jsx)$/; + +export function storyTypes(options: { prefix: string, src: string, outFile: string }): Plugin { + const generate = () => { + const content = render(listStoryFiles(options.src), options); + if (fs.existsSync(options.outFile) && fs.readFileSync(options.outFile, 'utf8') === content) + return; + fs.writeFileSync(options.outFile, content); + }; + return { + name: 'story-types', + buildStart() { + this.addWatchFile(options.src); + generate(); + }, + configureServer(server) { + const onFile = (file: string) => { + if (storyFile.test(file)) + generate(); + }; + server.watcher.on('add', onFile); + server.watcher.on('unlink', onFile); + }, + }; +} + +function listStoryFiles(dir: string): string[] { + const result: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) + result.push(...listStoryFiles(file)); + else if (storyFile.test(entry.name)) + result.push(file); + } + return result.sort(); +} + +function render(files: string[], { prefix, src, outFile }: { prefix: string, src: string, outFile: string }): string { + const outDir = path.dirname(outFile); + const posix = (p: string) => p.split(path.sep).join('/'); + const entries = files.map(file => { + const id = prefix + '/' + posix(path.relative(src, file)).replace(storyFile, ''); + const specifier = posix(path.relative(outDir, file)).replace(/\.\w+$/, ''); + return ` StoriesOf<'${id}', typeof import('${specifier.startsWith('.') ? specifier : './' + specifier}')>`; + }); + const stories = entries.length ? `interface Stories extends\n${entries.join(',\n')} {}` : 'interface Stories {}'; + return `// Generated by storyTypes.ts, do not edit. + +type StoriesOf = { [K in keyof Mod & string as \`\${Prefix}/\${K}\`]: Mod[K] }; + +declare module '@playwright/test' { + ${stories} +} + +export {}; +`; +} +``` + +Register it on whichever Vite server serves the gallery (the app's `vite.config.ts`, or the +standalone `playwright/vite.config.ts`): + +```ts +import packageJSON from './package.json'; +import { storyTypes } from './playwright/gallery/storyTypes'; + +export default defineConfig({ + plugins: [ + react(), + storyTypes({ + prefix: packageJSON.name, + src: path.resolve(__dirname, 'src'), + outFile: path.resolve(__dirname, 'playwright/gallery/stories.d.ts'), + }), + ], +}); +``` + +The file is regenerated on every dev-server start and build, and while the server runs, whenever +a story file appears or disappears. Its content is deterministic, so **commit it**: type-checking +in CI or an editor must not depend on a running dev server. Nothing imports the file, so list it +explicitly in the project's `tsconfig` (`"files": ["playwright/gallery/stories.d.ts"]`) so that +`tsc` and editors load it. + +For `.story.vue` single-file components, map the `default` export to the bare path in +`StoriesOf` (`K extends 'default' ? Prefix : \`${Prefix}/${K}\``) and rely on `vue-tsc` for the +SFC's types; a story without inferable props types as `unknown` props, which accepts anything. diff --git a/packages/playwright-core/src/tools/skills/playwright-component-testing/references/vue.md b/packages/playwright-core/src/tools/skills/playwright-component-testing/references/vue.md index fd9ff6d678fde..959b993e14e2b 100644 --- a/packages/playwright-core/src/tools/skills/playwright-component-testing/references/vue.md +++ b/packages/playwright-core/src/tools/skills/playwright-component-testing/references/vue.md @@ -68,6 +68,8 @@ import type { WithTitle } from './Button.story'; const component = await mount('components/Button/WithTitle', { title: 'Hello' }); ``` +Alternatively, generate gallery types so the id itself is typed and no type import is needed: `mount('acme-ui/components/Button/WithTitle', { title: 'Hello' })` — see `references/typing.md`. + Options-API stories (`defineComponent({ props: { ... } })`) infer props the same way. For `.story.vue` SFC stories, prop types are only inferable when the setup generates SFC types (Volar/vue-tsc); otherwise pass the props type directly: `mount<{ title?: string }>('components/Button.primary', { title: 'Hello' })`. ## CSS diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 43a7bf775c586..040450342836f 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -7974,11 +7974,17 @@ export interface PlaywrightWorkerArgs { browser: Browser; } +export interface Stories {} + type StoryProps = Story extends (props: infer Props) => any ? Props : Story extends new (...args: any[]) => { $props: infer Props } ? Props : Story extends new (props: infer Props, ...args: any[]) => any ? Props : Story; +type StoryId = keyof Stories | (string & {}); +type StoryPropsFor = Id extends keyof Stories ? StoryProps : Record; +// Explicit mount() wins over the id lookup; the indexed access keeps Story from being inferred from props. +type MountProps = [Story] extends [never] ? StoryPropsFor : StoryProps<[Story][Story extends any ? 0 : never]>; /** * Playwright Test is based on the concept of the [test fixtures](https://playwright.dev/docs/test-fixtures). Test fixtures are used to @@ -8112,7 +8118,7 @@ export interface PlaywrightTestArgs { * exported story name, for example `'components/Button/Primary'`. * @param props Optional plain, serializable props passed to the story. */ - mount: >(storyId: string, props?: StoryProps) => Promise): Promise, unmount(): Promise }>; + mount: (storyId: Id, props?: MountProps) => Promise): Promise, unmount(): Promise }>; } type ExcludeProps = { diff --git a/tsconfig.json b/tsconfig.json index 0eaa08c705cc2..6526645a8fbd5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,7 @@ "skipLibCheck": true, }, "compileOnSave": true, + "files": ["packages/html-reporter/tests/stories.d.ts"], "include": ["packages"], "exclude": [ "packages/*/lib", diff --git a/utils/generate_types/index.js b/utils/generate_types/index.js index 253ab30f1c9f1..3a734e0c6015c 100644 --- a/utils/generate_types/index.js +++ b/utils/generate_types/index.js @@ -588,6 +588,7 @@ class TypesGenerator { 'PlaywrightWorkerOptions.defaultBrowserType', 'PlaywrightWorkerOptions.reuseContext', 'Project', + 'Stories', ]), doNotExportClassNames: assertionClasses, }); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index c65d5899e61ca..e7de7655c4c52 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -317,17 +317,23 @@ export interface PlaywrightWorkerArgs { browser: Browser; } +export interface Stories {} + type StoryProps = Story extends (props: infer Props) => any ? Props : Story extends new (...args: any[]) => { $props: infer Props } ? Props : Story extends new (props: infer Props, ...args: any[]) => any ? Props : Story; +type StoryId = keyof Stories | (string & {}); +type StoryPropsFor = Id extends keyof Stories ? StoryProps : Record; +// Explicit mount() wins over the id lookup; the indexed access keeps Story from being inferred from props. +type MountProps = [Story] extends [never] ? StoryPropsFor : StoryProps<[Story][Story extends any ? 0 : never]>; export interface PlaywrightTestArgs { context: BrowserContext; page: Page; request: APIRequestContext; - mount: >(storyId: string, props?: StoryProps) => Promise): Promise, unmount(): Promise }>; + mount: (storyId: Id, props?: MountProps) => Promise): Promise, unmount(): Promise }>; } type ExcludeProps = {