Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/src/test-components-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ This pattern is the heart of the methodology:

### Per-test props

When a scenario is genuinely parametric — a boundary-value sweep, a text matrix — pass plain serializable props as the second argument to `mount`. The gallery hands them to the story as its props:
When a scenario benefits from parameterizing, pass plain serializable props as the second argument to `mount`. The gallery hands them to the story as its props:

```js title="src/components/Button.story.tsx"
import { Button } from './Button';
Expand Down Expand Up @@ -256,7 +256,7 @@ await expect(await mount('Button/Primary')).toHaveScreenshot('primary.png');
await expect(await mount('Button/Disabled')).toHaveScreenshot('disabled.png');
```

Screenshot the returned root locator, not the page, to avoid asserting on browser chrome.
Screenshot the returned root locator, not the page, to avoid asserting on anything extra you might put in the gallery.

### Handling network requests

Expand Down
2 changes: 2 additions & 0 deletions packages/html-reporter/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import path from 'path';
import url from 'url';
import { devices, defineConfig } from '@playwright/test';

process.env.PWTEST_UNDER_TEST = '1';

const dirname = path.dirname(url.fileURLToPath(import.meta.url));
const outputDir = path.join(dirname, '..', '..', 'test-results');

Expand Down
10 changes: 8 additions & 2 deletions packages/html-reporter/playwright/gallery/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,17 @@
<title>Component Gallery</title>
<style>
html, body { margin: 0; padding: 0; }
#root { position: fixed; inset: 0; }
body { display: flex; flex-direction: column; height: 100vh; }
#picker { flex: none; margin: 4px; align-self: flex-start; }
#wrapper { flex: auto; position: relative; }
#root { position: absolute; top: 0; right: 0; bottom: 0; left: 0; }
</style>
</head>
<body>
<div id="root"></div>
<select id="picker">
<option>Select a story…</option>
</select>
<div id="wrapper"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
39 changes: 36 additions & 3 deletions packages/html-reporter/playwright/gallery/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
import '../../src/theme.css';
import { SearchParamsProvider } from '../../src/links';

const stories = import.meta.glob('../../src/**/*.story.{tsx,jsx}');
const storyId = (file: string) => file.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, '');
Expand All @@ -29,20 +30,52 @@ async function resolveStory(id: string): Promise<React.ComponentType<any> | unde
return mod?.[name] ?? mod?.default;
}

const rootElement = document.getElementById('root')!;
const wrapperElement = document.getElementById('wrapper')!;
let root: Root | undefined;

(window as any).mount = async ({ story, props }: { story: string, props?: Record<string, any> }) => {
const Story = await resolveStory(story);
if (!Story)
throw new Error(`Unknown story: ${story}`);
// Reuse the root so that update() reconciles and preserves state.
root ??= createRoot(rootElement);
root ??= createRoot(wrapperElement);
// flushSync so that a render error rejects the promise instead of being swallowed.
flushSync(() => root!.render(<Story {...props} />));
flushSync(() => root!.render(
<SearchParamsProvider>
<div id="root">
<Story {...props} />
</div>
</SearchParamsProvider>
));
};

(window as any).unmount = async () => {
root?.unmount();
root = undefined;
};

async function listStories(): Promise<string[]> {
const lists = await Promise.all(Object.entries(stories).map(async ([file, loadModule]) => {
const mod = await loadModule() as Record<string, any>;
return Object.keys(mod).filter(name => typeof mod[name] === 'function').map(name => `${storyId(file)}/${name}`);
}));
return lists.flat().sort();
}

const pickerElement = document.getElementById('picker') as HTMLSelectElement;
let pickerPopulated = false;
async function populatePicker() {
if (pickerPopulated)
return;
pickerPopulated = true;
for (const id of await listStories())
pickerElement.add(new Option(id, id));
}
// Populate on mouseenter/focus rather than on click, because an already-open
// select popup does not refresh when options are added.
pickerElement.addEventListener('mouseenter', () => void populatePicker());
pickerElement.addEventListener('focus', () => void populatePicker());
pickerElement.addEventListener('change', () => {
if (pickerElement.value)
void (window as any).mount({ story: pickerElement.value });
});
3 changes: 3 additions & 0 deletions packages/html-reporter/src/DEPS.list
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@

[testCaseView.spec.ts]
***

[testFileView.spec.ts]
***
5 changes: 2 additions & 3 deletions packages/html-reporter/src/headerView.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

import * as React from 'react';
import { GlobalFilterView } from './headerView';
import { SearchParamsProvider } from './links';

const stats = {
total: 100,
Expand All @@ -29,8 +28,8 @@ const stats = {

export const Default = () => {
const [filterText, setFilterText] = React.useState('');
return <SearchParamsProvider>
return <>
<GlobalFilterView stats={stats} filterText={filterText} setFilterText={setFilterText} />
<form hidden><input data-testid='filter-text' readOnly value={filterText} /></form>
</SearchParamsProvider>;
</>;
};
170 changes: 170 additions & 0 deletions packages/html-reporter/src/sampleReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* 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 type { LoadedReport } from './loadedReport';
import type { HTMLReport, TestCase, TestCaseSummary, TestFileSummary, TestResult } from './types';

const passedResult: TestResult = {
retry: 0,
workerIndex: 0,
startTime: new Date(0).toUTCString(),
duration: 100,
errors: [],
steps: [{
title: 'Outer step',
startTime: new Date(100).toUTCString(),
duration: 10,
location: { file: 'test.spec.ts', line: 62, column: 0 },
count: 1,
steps: [{
title: 'Inner step',
startTime: new Date(200).toUTCString(),
duration: 10,
location: { file: 'test.spec.ts', line: 82, column: 0 },
steps: [],
attachments: [],
count: 1,
}],
attachments: [],
}],
annotations: [
{ type: 'annotation', description: 'Annotation text' },
{ type: 'annotation', description: 'Another annotation text' },
{ type: '_annotation', description: 'Hidden annotation' },
],
attachments: [],
status: 'passed',
};

const failedResult: TestResult = {
...passedResult,
errors: [{ message: 'Error message' }],
status: 'failed',
};

export const basicTest: TestCase = {
testId: 'basic-test',
title: 'My test',
path: [],
projectName: 'chromium',
location: { file: 'test.spec.ts', line: 42, column: 0 },
annotations: passedResult.annotations,
tags: [],
outcome: 'expected',
duration: 200,
ok: true,
results: [passedResult],
};

export const annotationLinksTest: TestCase = {
...basicTest,
testId: 'annotation-links-test',
title: 'Test with annotation links',
duration: 10,
annotations: [],
results: [{
...passedResult,
annotations: [
{ type: 'more info', description: 'read https://playwright.dev/docs/intro and https://playwright.dev/docs/api/class-playwright' },
{ type: 'related issues', description: 'https://github.com/microsoft/playwright/issues/23180, https://github.com/microsoft/playwright/issues/23181' },
]
}]
};

const resultWithAttachments: TestResult = {
...passedResult,
steps: [{
title: 'Outer step',
startTime: new Date(100).toUTCString(),
duration: 10,
location: { file: 'test.spec.ts', line: 62, column: 0 },
count: 1,
steps: [],
attachments: [1],
}],
attachments: [{
name: 'first attachment',
body: 'The body with https://playwright.dev/docs/intro link and https://github.com/microsoft/playwright/issues/31284.',
contentType: 'text/plain'
}, {
name: 'attachment with inline link https://github.com/microsoft/playwright/issues/31284',
contentType: 'text/plain'
}],
annotations: [],
};

export const attachmentLinksTest: TestCase = {
...basicTest,
testId: 'attachment-links-test',
title: 'Test with attachment links',
path: ['group'],
duration: 10,
annotations: [],
results: [resultWithAttachments]
};

export const nextTest: TestCaseSummary = {
...attachmentLinksTest,
testId: 'next-test',
title: 'next test',
path: [],
};

export const twoAttemptsTest: TestCase = {
...basicTest,
testId: 'two-attempts-test',
title: 'Test with two attempts',
outcome: 'flaky',
results: [
{ ...failedResult, duration: 50 },
{ ...passedResult, duration: 150 },
],
};

export const webkitTest: TestCase = {
...basicTest,
testId: 'webkit-test',
title: 'Failing webkit test',
projectName: 'webkit',
outcome: 'unexpected',
ok: false,
annotations: [],
results: [failedResult],
};

export const testFile: TestFileSummary = {
fileId: 'file-id',
fileName: 'test.spec.ts',
tests: [basicTest, annotationLinksTest, attachmentLinksTest, nextTest, twoAttemptsTest, webkitTest],
stats: { total: 6, expected: 4, unexpected: 1, flaky: 1, skipped: 0, ok: false },
};

export const report: HTMLReport = {
metadata: {},
files: [testFile],
stats: testFile.stats,
projectNames: ['chromium', 'webkit'],
startTime: 0,
duration: 200,
machines: [],
errors: [],
options: {},
};

export const loadedReport: LoadedReport = {
json: () => report,
entry: async () => undefined,
};
8 changes: 4 additions & 4 deletions packages/html-reporter/src/testCaseView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,24 +96,24 @@ test('should correctly render prev and next', async ({ mount }) => {
- text: group
- link "« previous"
- link "next »"
- text: "My test test.spec.ts:42 10ms chromium"
- text: "Test with attachment links test.spec.ts:42 10ms chromium"
`);
});

test('total duration is selected run duration', async ({ mount, page }) => {
const component = await mount<typeof TwoAttempts>('testCaseView/TwoAttempts');
await expect(component).toMatchAriaSnapshot(`
- text: "My test test.spec.ts:42 200ms chromium"
- text: "Test with two attempts test.spec.ts:42 200ms chromium"
- tablist:
- tab "Run 50ms"
- 'tab "Retry #1 150ms"'
`);
await page.getByRole('tab', { name: 'Run' }).click();
await expect(component).toMatchAriaSnapshot(`
- text: "My test test.spec.ts:42 200ms chromium"
- text: "Test with two attempts test.spec.ts:42 200ms chromium"
`);
await page.getByRole('tab', { name: 'Retry' }).click();
await expect(component).toMatchAriaSnapshot(`
- text: "My test test.spec.ts:42 200ms chromium"
- text: "Test with two attempts test.spec.ts:42 200ms chromium"
`);
});
Loading
Loading