diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 83913c18dc..2866b81662 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -54,6 +54,7 @@ type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { _optionContextReuseMode: ContextReuseMode, _optionConnectOptions: PlaywrightWorkerOptions['connectOptions'], _reuseContext: boolean, + _pageSnapshot: PageSnapshotOption, }; const playwrightFixtures: Fixtures = ({ @@ -71,6 +72,7 @@ const playwrightFixtures: Fixtures = ({ screenshot: ['off', { scope: 'worker', option: true }], video: ['off', { scope: 'worker', option: true }], trace: ['off', { scope: 'worker', option: true }], + _pageSnapshot: ['off', { scope: 'worker', option: true }], _browserOptions: [async ({ playwright, headless, channel, launchOptions }, use) => { const options: LaunchOptions = { @@ -247,13 +249,13 @@ const playwrightFixtures: Fixtures = ({ } }, { 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 // happens even after some fixtures or hooks time out. // Now that default test timeout is known, we can replace zero with an actual value. 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); const tracingGroupSteps: TestStepInternal[] = []; @@ -452,6 +454,7 @@ const playwrightFixtures: Fixtures = ({ type ScreenshotOption = PlaywrightWorkerOptions['screenshot'] | undefined; type Playwright = PlaywrightWorkerArgs['playwright']; +type PageSnapshotOption = 'off' | 'on' | 'only-on-failure'; function normalizeVideoMode(video: VideoMode | 'retry-with-video' | { mode: VideoMode } | undefined): VideoMode { if (!video) @@ -530,18 +533,24 @@ class ArtifactsRecorder { private _screenshotMode: ScreenshotMode; private _screenshotOptions: { mode: ScreenshotMode } & Pick | undefined; private _temporaryScreenshots: string[] = []; + private _temporaryPageSnapshots: string[] = []; private _temporaryArtifacts: string[] = []; private _reusedContexts = new Set(); private _screenshotOrdinal = 0; + private _pageSnapshotOrdinal = 0; private _screenshottedSymbol: symbol; + private _snapshottedSymbol: 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._artifactsDir = artifactsDir; + this._pageSnapshotMode = pageSnapshot; this._screenshotMode = normalizeScreenshotMode(screenshot); this._screenshotOptions = typeof screenshot === 'string' ? undefined : screenshot; this._screenshottedSymbol = Symbol('screenshotted'); + this._snapshottedSymbol = Symbol('snapshotted'); 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 // overwrite previous screenshots. this._screenshotOrdinal = testInfo.attachments.filter(a => a.name === 'screenshot').length; + this._pageSnapshotOrdinal = testInfo.attachments.filter(a => a.name === 'pageSnapshot').length; // Process existing contexts. for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit]) { @@ -587,11 +597,11 @@ class ArtifactsRecorder { if (this._reusedContexts.has(context)) return; await this._stopTracing(context.tracing); - if (this._screenshotMode === 'on' || this._screenshotMode === 'only-on-failure' || (this._screenshotMode === 'on-first-failure' && this._testInfo.retry === 0)) { - // Capture screenshot for now. We'll know whether we have to preserve them - // after the test finishes. + // Capture for now. We'll know whether we have to preserve them after the test finishes. + if (this._screenshotMode === 'on' || this._screenshotMode === 'only-on-failure' || (this._screenshotMode === 'on-first-failure' && this._testInfo.retry === 0)) 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) { @@ -610,15 +620,25 @@ class ArtifactsRecorder { (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() { if (this._shouldCaptureScreenshotUponFinish()) await this._screenshotOnTestFailure(); + if (this._shouldCapturePageSnapshotUponFinish()) + await this._pageSnapshotOnTestFailure(); } async didFinishTest() { const captureScreenshots = this._shouldCaptureScreenshotUponFinish(); if (captureScreenshots) await this._screenshotOnTestFailure(); + const capturePageSnapshots = this._shouldCapturePageSnapshotUponFinish(); + if (capturePageSnapshots) + await this._pageSnapshotOnTestFailure(); let leftoverContexts: BrowserContext[] = []; 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() { @@ -655,6 +685,14 @@ class ArtifactsRecorder { 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) { if ((page as any)[this._screenshottedSymbol]) return; @@ -677,12 +715,42 @@ class ArtifactsRecorder { this._testInfo.attachments.push({ name: 'screenshot', path: screenshotPath, contentType: 'image/png' }); } - private async _screenshotOnTestFailure() { + private _allPages() { const contexts: BrowserContext[] = []; for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit]) contexts.push(...(browserType as any)._contexts); - const pages = contexts.map(ctx => ctx.pages()).flat(); - await Promise.all(pages.map(page => this._screenshotPage(page, false))); + return contexts.flatMap(ctx => ctx.pages()); + } + + 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) { diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index 694610ecdd..e4faa682ac 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -96,6 +96,7 @@ export interface TestServerInterface { workers?: number | string; updateSnapshots?: 'all' | 'changed' | 'missing' | 'none'; updateSourceMethod?: 'overwrite' | 'patch' | '3way'; + pageSnapshot?: 'off' | 'on' | 'only-on-failure'; reporters?: string[], trace?: 'on' | 'off'; video?: 'on' | 'off'; diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index e4ff3a7a2f..db080728e9 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -311,6 +311,7 @@ export class TestServerDispatcher implements TestServerInterface { ...(params.headed !== undefined ? { headless: !params.headed } : {}), _optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined, _optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined, + _pageSnapshot: params.pageSnapshot, }, ...(params.updateSnapshots ? { updateSnapshots: params.updateSnapshots } : {}), ...(params.updateSourceMethod ? { updateSourceMethod: params.updateSourceMethod } : {}), diff --git a/tests/playwright-test/playwright.artifacts.spec.ts b/tests/playwright-test/playwright.artifacts.spec.ts index 2e3d99766b..0360ca7bce 100644 --- a/tests/playwright-test/playwright.artifacts.spec.ts +++ b/tests/playwright-test/playwright.artifacts.spec.ts @@ -420,3 +420,71 @@ test('should take screenshot when page is closed in afterEach', async ({ runInli expect(result.failed).toBe(1); 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', + ]); +});