first impl

This commit is contained in:
Simon Knott 2025-01-31 10:15:28 +01:00
parent 4fa1d39c80
commit 238c23f237
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
4 changed files with 148 additions and 10 deletions

View file

@ -54,6 +54,7 @@ type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & {
_optionContextReuseMode: ContextReuseMode, _optionContextReuseMode: ContextReuseMode,
_optionConnectOptions: PlaywrightWorkerOptions['connectOptions'], _optionConnectOptions: PlaywrightWorkerOptions['connectOptions'],
_reuseContext: boolean, _reuseContext: boolean,
_pageSnapshot: PageSnapshotOption,
}; };
const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
@ -71,6 +72,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
screenshot: ['off', { scope: 'worker', option: true }], screenshot: ['off', { scope: 'worker', option: true }],
video: ['off', { scope: 'worker', option: true }], video: ['off', { scope: 'worker', option: true }],
trace: ['off', { scope: 'worker', option: true }], trace: ['off', { scope: 'worker', option: true }],
_pageSnapshot: ['off', { scope: 'worker', option: true }],
_browserOptions: [async ({ playwright, headless, channel, launchOptions }, use) => { _browserOptions: [async ({ playwright, headless, channel, launchOptions }, use) => {
const options: LaunchOptions = { const options: LaunchOptions = {
@ -247,13 +249,13 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
} }
}, { auto: 'all-hooks-included', title: 'context configuration', box: true } as any], }, { auto: 'all-hooks-included', title: 'context configuration', box: true } as any],
_setupArtifacts: [async ({ playwright, screenshot }, use, testInfo) => { _setupArtifacts: [async ({ playwright, screenshot, _pageSnapshot }, use, testInfo) => {
// This fixture has a separate zero-timeout slot to ensure that artifact collection // This fixture has a separate zero-timeout slot to ensure that artifact collection
// happens even after some fixtures or hooks time out. // happens even after some fixtures or hooks time out.
// Now that default test timeout is known, we can replace zero with an actual value. // Now that default test timeout is known, we can replace zero with an actual value.
testInfo.setTimeout(testInfo.project.timeout); testInfo.setTimeout(testInfo.project.timeout);
const artifactsRecorder = new ArtifactsRecorder(playwright, tracing().artifactsDir(), screenshot); const artifactsRecorder = new ArtifactsRecorder(playwright, tracing().artifactsDir(), screenshot, _pageSnapshot);
await artifactsRecorder.willStartTest(testInfo as TestInfoImpl); await artifactsRecorder.willStartTest(testInfo as TestInfoImpl);
const tracingGroupSteps: TestStepInternal[] = []; const tracingGroupSteps: TestStepInternal[] = [];
@ -452,6 +454,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
type ScreenshotOption = PlaywrightWorkerOptions['screenshot'] | undefined; type ScreenshotOption = PlaywrightWorkerOptions['screenshot'] | undefined;
type Playwright = PlaywrightWorkerArgs['playwright']; type Playwright = PlaywrightWorkerArgs['playwright'];
type PageSnapshotOption = 'off' | 'on' | 'only-on-failure';
function normalizeVideoMode(video: VideoMode | 'retry-with-video' | { mode: VideoMode } | undefined): VideoMode { function normalizeVideoMode(video: VideoMode | 'retry-with-video' | { mode: VideoMode } | undefined): VideoMode {
if (!video) if (!video)
@ -530,18 +533,24 @@ class ArtifactsRecorder {
private _screenshotMode: ScreenshotMode; private _screenshotMode: ScreenshotMode;
private _screenshotOptions: { mode: ScreenshotMode } & Pick<playwrightLibrary.PageScreenshotOptions, 'fullPage' | 'omitBackground'> | undefined; private _screenshotOptions: { mode: ScreenshotMode } & Pick<playwrightLibrary.PageScreenshotOptions, 'fullPage' | 'omitBackground'> | undefined;
private _temporaryScreenshots: string[] = []; private _temporaryScreenshots: string[] = [];
private _temporaryPageSnapshots: string[] = [];
private _temporaryArtifacts: string[] = []; private _temporaryArtifacts: string[] = [];
private _reusedContexts = new Set<BrowserContext>(); private _reusedContexts = new Set<BrowserContext>();
private _screenshotOrdinal = 0; private _screenshotOrdinal = 0;
private _pageSnapshotOrdinal = 0;
private _screenshottedSymbol: symbol; private _screenshottedSymbol: symbol;
private _snapshottedSymbol: symbol;
private _startedCollectingArtifacts: symbol; private _startedCollectingArtifacts: symbol;
private _pageSnapshotMode: PageSnapshotOption;
constructor(playwright: Playwright, artifactsDir: string, screenshot: ScreenshotOption) { constructor(playwright: Playwright, artifactsDir: string, screenshot: ScreenshotOption, pageSnapshot: PageSnapshotOption) {
this._playwright = playwright; this._playwright = playwright;
this._artifactsDir = artifactsDir; this._artifactsDir = artifactsDir;
this._pageSnapshotMode = pageSnapshot;
this._screenshotMode = normalizeScreenshotMode(screenshot); this._screenshotMode = normalizeScreenshotMode(screenshot);
this._screenshotOptions = typeof screenshot === 'string' ? undefined : screenshot; this._screenshotOptions = typeof screenshot === 'string' ? undefined : screenshot;
this._screenshottedSymbol = Symbol('screenshotted'); this._screenshottedSymbol = Symbol('screenshotted');
this._snapshottedSymbol = Symbol('snapshotted');
this._startedCollectingArtifacts = Symbol('startedCollectingArtifacts'); this._startedCollectingArtifacts = Symbol('startedCollectingArtifacts');
} }
@ -558,6 +567,7 @@ class ArtifactsRecorder {
// Since beforeAll(s), test and afterAll(s) reuse the same TestInfo, make sure we do not // Since beforeAll(s), test and afterAll(s) reuse the same TestInfo, make sure we do not
// overwrite previous screenshots. // overwrite previous screenshots.
this._screenshotOrdinal = testInfo.attachments.filter(a => a.name === 'screenshot').length; this._screenshotOrdinal = testInfo.attachments.filter(a => a.name === 'screenshot').length;
this._pageSnapshotOrdinal = testInfo.attachments.filter(a => a.name === 'pageSnapshot').length;
// Process existing contexts. // Process existing contexts.
for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit]) { for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit]) {
@ -587,11 +597,11 @@ class ArtifactsRecorder {
if (this._reusedContexts.has(context)) if (this._reusedContexts.has(context))
return; return;
await this._stopTracing(context.tracing); await this._stopTracing(context.tracing);
if (this._screenshotMode === 'on' || this._screenshotMode === 'only-on-failure' || (this._screenshotMode === 'on-first-failure' && this._testInfo.retry === 0)) { // Capture for now. We'll know whether we have to preserve them after the test finishes.
// Capture screenshot for now. We'll know whether we have to preserve them if (this._screenshotMode === 'on' || this._screenshotMode === 'only-on-failure' || (this._screenshotMode === 'on-first-failure' && this._testInfo.retry === 0))
// after the test finishes.
await Promise.all(context.pages().map(page => this._screenshotPage(page, true))); await Promise.all(context.pages().map(page => this._screenshotPage(page, true)));
} if (this._pageSnapshotMode === 'on' || this._pageSnapshotMode === 'only-on-failure')
await Promise.all(context.pages().map(page => this._snapshotPage(page, true)));
} }
async didCreateRequestContext(context: APIRequestContext) { async didCreateRequestContext(context: APIRequestContext) {
@ -610,15 +620,25 @@ class ArtifactsRecorder {
(this._screenshotMode === 'on-first-failure' && this._testInfo._isFailure() && this._testInfo.retry === 0); (this._screenshotMode === 'on-first-failure' && this._testInfo._isFailure() && this._testInfo.retry === 0);
} }
private _shouldCapturePageSnapshotUponFinish() {
return this._pageSnapshotMode === 'on' ||
(this._pageSnapshotMode === 'only-on-failure' && this._testInfo._isFailure());
}
async didFinishTestFunction() { async didFinishTestFunction() {
if (this._shouldCaptureScreenshotUponFinish()) if (this._shouldCaptureScreenshotUponFinish())
await this._screenshotOnTestFailure(); await this._screenshotOnTestFailure();
if (this._shouldCapturePageSnapshotUponFinish())
await this._pageSnapshotOnTestFailure();
} }
async didFinishTest() { async didFinishTest() {
const captureScreenshots = this._shouldCaptureScreenshotUponFinish(); const captureScreenshots = this._shouldCaptureScreenshotUponFinish();
if (captureScreenshots) if (captureScreenshots)
await this._screenshotOnTestFailure(); await this._screenshotOnTestFailure();
const capturePageSnapshots = this._shouldCapturePageSnapshotUponFinish();
if (capturePageSnapshots)
await this._pageSnapshotOnTestFailure();
let leftoverContexts: BrowserContext[] = []; let leftoverContexts: BrowserContext[] = [];
for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit]) for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit])
@ -645,6 +665,16 @@ class ArtifactsRecorder {
} }
} }
} }
if (this._shouldCapturePageSnapshotUponFinish()) {
for (const file of this._temporaryPageSnapshots) {
try {
const path = this._createPageSnapshotAttachmentPath();
await fs.promises.rename(file, path);
this._attachPageSnapshot(path);
} catch {
}
}
}
} }
private _createScreenshotAttachmentPath() { private _createScreenshotAttachmentPath() {
@ -655,6 +685,14 @@ class ArtifactsRecorder {
return screenshotPath; return screenshotPath;
} }
private _createPageSnapshotAttachmentPath() {
const testFailed = this._testInfo._isFailure();
const index = this._pageSnapshotOrdinal + 1;
++this._pageSnapshotOrdinal;
const path = this._testInfo.outputPath(`test-${testFailed ? 'failed' : 'finished'}-${index}.ariasnapshot`);
return path;
}
private async _screenshotPage(page: Page, temporary: boolean) { private async _screenshotPage(page: Page, temporary: boolean) {
if ((page as any)[this._screenshottedSymbol]) if ((page as any)[this._screenshottedSymbol])
return; return;
@ -677,12 +715,42 @@ class ArtifactsRecorder {
this._testInfo.attachments.push({ name: 'screenshot', path: screenshotPath, contentType: 'image/png' }); this._testInfo.attachments.push({ name: 'screenshot', path: screenshotPath, contentType: 'image/png' });
} }
private async _screenshotOnTestFailure() { private _allPages() {
const contexts: BrowserContext[] = []; const contexts: BrowserContext[] = [];
for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit]) for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit])
contexts.push(...(browserType as any)._contexts); contexts.push(...(browserType as any)._contexts);
const pages = contexts.map(ctx => ctx.pages()).flat(); return contexts.flatMap(ctx => ctx.pages());
await Promise.all(pages.map(page => this._screenshotPage(page, false))); }
private async _screenshotOnTestFailure() {
await Promise.all(this._allPages().map(page => this._screenshotPage(page, false)));
}
private async _snapshotPage(page: Page, temporary: boolean) {
if ((page as any)[this._snapshottedSymbol])
return;
(page as any)[this._snapshottedSymbol] = true;
try {
const ariaSnapshot = await page.locator('body').ariaSnapshot();
const path = temporary ? this._createTemporaryArtifact(createGuid() + '.ariasnapshot') : this._createPageSnapshotAttachmentPath();
await fs.promises.writeFile(path, ariaSnapshot);
if (temporary)
this._temporaryPageSnapshots.push(path);
else
this._attachPageSnapshot(path);
} catch (error) {
// snapshot may fail, just ignore.
}
}
private _attachPageSnapshot(path: string) {
this._testInfo.attachments.push({ name: 'pageSnapshot', path, contentType: 'text/plain' });
}
private async _pageSnapshotOnTestFailure() {
await Promise.all(this._allPages().map(page => this._snapshotPage(page, false)));
} }
private async _startTraceChunkOnContextCreation(tracing: Tracing) { private async _startTraceChunkOnContextCreation(tracing: Tracing) {

View file

@ -96,6 +96,7 @@ export interface TestServerInterface {
workers?: number | string; workers?: number | string;
updateSnapshots?: 'all' | 'changed' | 'missing' | 'none'; updateSnapshots?: 'all' | 'changed' | 'missing' | 'none';
updateSourceMethod?: 'overwrite' | 'patch' | '3way'; updateSourceMethod?: 'overwrite' | 'patch' | '3way';
pageSnapshot?: 'off' | 'on' | 'only-on-failure';
reporters?: string[], reporters?: string[],
trace?: 'on' | 'off'; trace?: 'on' | 'off';
video?: 'on' | 'off'; video?: 'on' | 'off';

View file

@ -311,6 +311,7 @@ export class TestServerDispatcher implements TestServerInterface {
...(params.headed !== undefined ? { headless: !params.headed } : {}), ...(params.headed !== undefined ? { headless: !params.headed } : {}),
_optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined, _optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined,
_optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined, _optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined,
_pageSnapshot: params.pageSnapshot,
}, },
...(params.updateSnapshots ? { updateSnapshots: params.updateSnapshots } : {}), ...(params.updateSnapshots ? { updateSnapshots: params.updateSnapshots } : {}),
...(params.updateSourceMethod ? { updateSourceMethod: params.updateSourceMethod } : {}), ...(params.updateSourceMethod ? { updateSourceMethod: params.updateSourceMethod } : {}),

View file

@ -420,3 +420,71 @@ test('should take screenshot when page is closed in afterEach', async ({ runInli
expect(result.failed).toBe(1); expect(result.failed).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-fails', 'test-failed-1.png'))).toBeTruthy(); expect(fs.existsSync(testInfo.outputPath('test-results', 'a-fails', 'test-failed-1.png'))).toBeTruthy();
}); });
test('should work with _pageSnapshot: on', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...testFiles,
'playwright.config.ts': `
module.exports = { use: { _pageSnapshot: 'on' } };
`,
}, { workers: 1 });
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(5);
expect(result.failed).toBe(5);
expect(listFiles(testInfo.outputPath('test-results'))).toEqual([
'.last-run.json',
'artifacts-failing',
' test-failed-1.ariasnapshot',
'artifacts-own-context-failing',
' test-failed-1.ariasnapshot',
'artifacts-own-context-passing',
' test-finished-1.ariasnapshot',
'artifacts-passing',
' test-finished-1.ariasnapshot',
'artifacts-persistent-failing',
' test-failed-1.ariasnapshot',
'artifacts-persistent-passing',
' test-finished-1.ariasnapshot',
'artifacts-shared-shared-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
'artifacts-shared-shared-passing',
' test-finished-1.ariasnapshot',
' test-finished-2.ariasnapshot',
'artifacts-two-contexts',
' test-finished-1.ariasnapshot',
' test-finished-2.ariasnapshot',
'artifacts-two-contexts-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
]);
});
test('should work with _pageSnapshot: only-on-failure', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...testFiles,
'playwright.config.ts': `
module.exports = { use: { _pageSnapshot: 'only-on-failure' } };
`,
}, { workers: 1 });
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(5);
expect(result.failed).toBe(5);
expect(listFiles(testInfo.outputPath('test-results'))).toEqual([
'.last-run.json',
'artifacts-failing',
' test-failed-1.ariasnapshot',
'artifacts-own-context-failing',
' test-failed-1.ariasnapshot',
'artifacts-persistent-failing',
' test-failed-1.ariasnapshot',
'artifacts-shared-shared-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
'artifacts-two-contexts-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
]);
});