/** * Copyright Microsoft Corporation. All rights reserved. * * 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 { jpegjs } from 'playwright-core/lib/utilsBundle'; import { expect, browserTest as it } from '../config/browserTest'; import { ensureSomeFrames, parseTraceRaw } from '../config/utils'; import { VideoPlayer } from './videoPlayer'; type Pixel = { r: number, g: number, b: number, alpha: number }; type PixelPredicate = (pixel: Pixel) => boolean; function isAlmostRed({ r, g, b, alpha }: Pixel): boolean { return r > 185 && g < 70 && b < 70 && alpha === 255; } function isAlmostBlack({ r, g, b, alpha }: Pixel): boolean { return r < 70 && g < 70 && b < 70 && alpha === 255; } function isAlmostGray({ r, g, b, alpha }: Pixel): boolean { return r > 70 && r < 185 && g > 70 && g < 185 && b > 70 && b < 185 && alpha === 255; } function findPixel(pixels: Buffer, pixelPredicate: PixelPredicate): Pixel|undefined { for (let i = 0, n = pixels.length; i < n; i += 4) { const pixel = { r: pixels[i], g: pixels[i + 1], b: pixels[i + 2], alpha: pixels[i + 3], }; if (pixelPredicate(pixel)) return pixel; } return undefined; } function everyPixel(pixels: Buffer, pixelPredicate: PixelPredicate) { const badPixel = findPixel(pixels, pixel => !pixelPredicate(pixel)); return !badPixel; } function expectAll(pixels: Buffer, pixelPredicate: PixelPredicate) { const badPixel = findPixel(pixels, pixel => !pixelPredicate(pixel)); if (!badPixel) return; const rgba = [badPixel.r, badPixel.g, badPixel.b, badPixel.alpha].join(', '); throw new Error([ `Expected all pixels to satisfy ${pixelPredicate.name}, found bad pixel (${rgba})`, `Actual pixels=[${pixels.join(',')}]`, ].join('\n')); } function findVideos(videoDir: string) { const files = fs.readdirSync(videoDir); return files.filter(file => file.endsWith('webm')).map(file => path.join(videoDir, file)); } function expectRedFrames(videoFile: string, size: { width: number, height: number }) { expectFrames(videoFile, size, isAlmostRed); } function expectFrames(videoFile: string, size: { width: number, height: number }, pixelPredicate: PixelPredicate) { const videoPlayer = new VideoPlayer(videoFile); const duration = videoPlayer.duration; expect(duration).toBeGreaterThan(0); expect(videoPlayer.videoWidth).toBe(size.width); expect(videoPlayer.videoHeight).toBe(size.height); { const pixels = videoPlayer.seekLastFrame().data; expectAll(pixels, pixelPredicate); } { const pixels = videoPlayer.seekLastFrame({ x: size.width - 20, y: 10 }).data; expectAll(pixels, pixelPredicate); } } it.skip(({ video }) => video === 'on', 'conflicts with built-in video recording'); it.describe('screencast', () => { it.slow(); it.skip(({ mode }) => mode !== 'default', 'video.path() is not available in remote mode'); it('should not have video by default', async ({ page }) => { expect(page.video()).toBeNull(); }); it('should not throw without recordVideo.dir', async ({ browser }) => { await browser.newContext({ recordVideo: {} }); }); it('should capture static page', async ({ browser, browserName, trace, headless, isWindows }, testInfo) => { // Firefox does not have a mobile variant and has a large minimum size (500 on windows and 450 elsewhere). const size = browserName === 'firefox' ? { width: 500, height: 400 } : { width: 320, height: 240 }; const context = await browser.newContext({ recordVideo: { dir: testInfo.outputPath(''), size }, viewport: size, }); const page = await context.newPage(); await page.evaluate(() => document.body.style.backgroundColor = 'red'); await ensureSomeFrames(page); await context.close(); const videoFile = await page.video().path(); expectRedFrames(videoFile, size); }); it('should continue recording main page after popup closes', async ({ browser, browserName }, testInfo) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30837' }); // Firefox does not have a mobile variant and has a large minimum size (500 on windows and 450 elsewhere). const size = browserName === 'firefox' ? { width: 500, height: 400 } : { width: 320, height: 240 }; const context = await browser.newContext({ recordVideo: { dir: testInfo.outputPath(''), size }, viewport: size, }); const page = await context.newPage(); await page.setContent('clickme'); const [popup] = await Promise.all([ page.waitForEvent('popup'), await page.click('a'), ]); await popup.close(); await page.evaluate(() => { document.body.textContent = ''; // remove link document.body.style.backgroundColor = 'red'; }); await ensureSomeFrames(page); await context.close(); const videoFile = await page.video().path(); expectRedFrames(videoFile, size); }); it('should expose video path', async ({ browser }, testInfo) => { const videosPath = testInfo.outputPath(''); const size = { width: 320, height: 240 }; const context = await browser.newContext({ recordVideo: { dir: videosPath, size }, viewport: size, }); const page = await context.newPage(); await page.evaluate(() => document.body.style.backgroundColor = 'red'); const path = await page.video()!.path(); expect(path).toContain(videosPath); await context.close(); expect(fs.existsSync(path)).toBeTruthy(); }); it('should delete video', async ({ browser }, testInfo) => { const videosPath = testInfo.outputPath(''); const size = { width: 320, height: 240 }; const context = await browser.newContext({ recordVideo: { dir: videosPath, size }, viewport: size, }); const page = await context.newPage(); const deletePromise = page.video().delete(); await page.evaluate(() => document.body.style.backgroundColor = 'red'); await ensureSomeFrames(page); await context.close(); const videoPath = await page.video().path(); await deletePromise; expect(fs.existsSync(videoPath)).toBeFalsy(); }); it('should expose video path blank page', async ({ browser }, testInfo) => { const videosPath = testInfo.outputPath(''); const size = { width: 320, height: 240 }; const context = await browser.newContext({ recordVideo: { dir: videosPath, size }, viewport: size, }); const page = await context.newPage(); const path = await page.video()!.path(); expect(path).toContain(videosPath); await context.close(); expect(fs.existsSync(path)).toBeTruthy(); }); it('should work with weird screen resolution', async ({ browser }, testInfo) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/22069' }); const videosPath = testInfo.outputPath(''); const size = { width: 1904, height: 609 }; const context = await browser.newContext({ recordVideo: { dir: videosPath, size }, viewport: size, }); const page = await context.newPage(); const path = await page.video()!.path(); expect(path).toContain(videosPath); await context.close(); expect(fs.existsSync(path)).toBeTruthy(); }); it('should work with relative path for recordVideo.dir', async ({ browser }, testInfo) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/27086' }); const videosPath = path.relative(process.cwd(), testInfo.outputPath('')); const size = { width: 320, height: 240 }; const context = await browser.newContext({ recordVideo: { dir: videosPath, size }, viewport: size, }); const page = await context.newPage(); const videoPath = await page.video()!.path(); await context.close(); expect(fs.existsSync(videoPath)).toBeTruthy(); }); it('should expose video path blank popup', async ({ browser }, testInfo) => { const videosPath = testInfo.outputPath(''); const size = { width: 320, height: 240 }; const context = await browser.newContext({ recordVideo: { dir: videosPath, size }, viewport: size, }); const page = await context.newPage(); const [popup] = await Promise.all([ page.waitForEvent('popup'), page.evaluate('window.open("about:blank")') ]); const path = await popup.video()!.path(); expect(path).toContain(videosPath); await context.close(); expect(fs.existsSync(path)).toBeTruthy(); }); it('should capture navigation', async ({ browser, browserName, server, trace }, testInfo) => { const context = await browser.newContext({ recordVideo: { dir: testInfo.outputPath(''), size: { width: 1280, height: 720 } }, }); const page = await context.newPage(); await page.goto(server.PREFIX + '/background-color.html#rgb(0,0,0)'); await ensureSomeFrames(page); await page.goto(server.CROSS_PROCESS_PREFIX + '/background-color.html#rgb(100,100,100)'); await ensureSomeFrames(page); await context.close(); const videoFile = await page.video().path(); const videoPlayer = new VideoPlayer(videoFile); const duration = videoPlayer.duration; expect(duration).toBeGreaterThan(0); { // Find a frame with all almost-black pixels. const frame = videoPlayer.findFrame(pixels => everyPixel(pixels, isAlmostBlack)); expect(frame).not.toBeUndefined(); } { const pixels = videoPlayer.seekLastFrame().data; expectAll(pixels, isAlmostGray); } }); it('should capture css transformation', async ({ browser, server, headless, browserName, platform, trace }, testInfo) => { it.fixme(!headless, 'Fails on headed'); it.fixme(browserName === 'webkit' && platform === 'win32'); const size = { width: 600, height: 400 }; // Set viewport equal to screencast frame size to avoid scaling. const context = await browser.newContext({ recordVideo: { dir: testInfo.outputPath(''), size, }, viewport: size, }); const page = await context.newPage(); await page.goto(server.PREFIX + '/rotate-z.html'); await ensureSomeFrames(page); await context.close(); const videoFile = await page.video().path(); const videoPlayer = new VideoPlayer(videoFile); const duration = videoPlayer.duration; expect(duration).toBeGreaterThan(0); { const pixels = videoPlayer.seekLastFrame({ x: 95, y: 45 }).data; expectAll(pixels, isAlmostRed); } }); it('should work for popups', async ({ browser, server, browserName, trace }, testInfo) => { it.fixme(browserName === 'firefox', 'https://github.com/microsoft/playwright/issues/14557'); const videosPath = testInfo.outputPath(''); const size = { width: 600, height: 400 }; const context = await browser.newContext({ recordVideo: { dir: videosPath, size, }, viewport: size, }); const page = await context.newPage(); await page.goto(server.EMPTY_PAGE); const [popup] = await Promise.all([ page.waitForEvent('popup'), page.evaluate(() => { window.open('about:blank'); }), ]); await popup.evaluate(() => document.body.style.backgroundColor = 'red'); await Promise.all([ ensureSomeFrames(page), ensureSomeFrames(popup), ]); await context.close(); const pageVideoFile = await page.video().path(); const popupVideoFile = await popup.video().path(); expect(pageVideoFile).not.toEqual(popupVideoFile); expectRedFrames(popupVideoFile, size); const videoFiles = findVideos(videosPath); expect(videoFiles.length).toBe(2); }); it('should scale frames down to the requested size ', async ({ browser, browserName, server, headless, isHeadlessShell }, testInfo) => { it.fixme(!headless, 'Fails on headed'); it.fixme(browserName === 'chromium' && !isHeadlessShell, 'Chromium (but not headless shell) has a min width issue'); const context = await browser.newContext({ recordVideo: { dir: testInfo.outputPath(''), // Set size to 1/2 of the viewport. size: { width: 320, height: 240 }, }, viewport: { width: 640, height: 480 }, }); const page = await context.newPage(); await page.goto(server.PREFIX + '/checkerboard.html'); // Update the picture to ensure enough frames are generated. await page.$eval('.container', container => { container.firstElementChild.classList.remove('red'); }); await ensureSomeFrames(page); await page.$eval('.container', container => { container.firstElementChild.classList.add('red'); }); await ensureSomeFrames(page); await context.close(); const videoFile = await page.video().path(); const videoPlayer = new VideoPlayer(videoFile); const duration = videoPlayer.duration; expect(duration).toBeGreaterThan(0); { const pixels = videoPlayer.seekLastFrame({ x: 10, y: 10 }).data; expectAll(pixels, isAlmostRed); } { const pixels = videoPlayer.seekLastFrame({ x: 300, y: 10 }).data; expectAll(pixels, isAlmostGray); } { const pixels = videoPlayer.seekLastFrame({ x: 10, y: 200 }).data; expectAll(pixels, isAlmostGray); } { const pixels = videoPlayer.seekLastFrame({ x: 300, y: 200 }).data; expectAll(pixels, isAlmostRed); } }); it('should use viewport scaled down to fit into 800x800 as default size', async ({ browser }, testInfo) => { const size = { width: 1600, height: 1200 }; const context = await browser.newContext({ recordVideo: { dir: testInfo.outputPath(''), }, viewport: size, }); const page = await context.newPage(); await ensureSomeFrames(page); await context.close(); const videoFile = await page.video().path(); const videoPlayer = new VideoPlayer(videoFile); expect(videoPlayer.videoWidth).toBe(800); expect(videoPlayer.videoHeight).toBe(600); }); it('should be 800x450 by default', async ({ browser }, testInfo) => { const context = await browser.newContext({ recordVideo: { dir: testInfo.outputPath(''), }, }); const page = await context.newPage(); await ensureSomeFrames(page); await context.close(); const videoFile = await page.video().path(); const videoPlayer = new VideoPlayer(videoFile); expect(videoPlayer.videoWidth).toBe(800); expect(videoPlayer.videoHeight).toBe(450); }); it('should be 800x600 with null viewport', async ({ browser, headless, browserName }, testInfo) => { it.fixme(browserName === 'firefox' && headless, 'Fails in headless on bots'); const context = await browser.newContext({ recordVideo: { dir: testInfo.outputPath(''), }, viewport: null }); const page = await context.newPage(); await ensureSomeFrames(page); await context.close(); const videoFile = await page.video().path(); const videoPlayer = new VideoPlayer(videoFile); expect(videoPlayer.videoWidth).toBe(800); expect(videoPlayer.videoHeight).toBe(600); }); it('should capture static page in persistent context @smoke', async ({ launchPersistent, browserName, trace, isMac }, testInfo) => { it.skip(browserName === 'webkit' && isMac && process.arch === 'arm64', 'Is only failing on self-hosted github actions runner on M1 mac; not reproducible locally'); const size = { width: 600, height: 400 }; const { context, page } = await launchPersistent({ recordVideo: { dir: testInfo.outputPath(''), size, }, viewport: size, }); await page.evaluate(() => document.body.style.backgroundColor = 'red'); await ensureSomeFrames(page); await context.close(); const videoFile = await page.video().path(); const videoPlayer = new VideoPlayer(videoFile); const duration = videoPlayer.duration; expect(duration).toBeGreaterThan(0); expect(videoPlayer.videoWidth).toBe(600); expect(videoPlayer.videoHeight).toBe(400); { const pixels = videoPlayer.seekLastFrame().data; expectAll(pixels, isAlmostRed); } }); it('should emulate an iphone', async ({ contextFactory, playwright, browserName }, testInfo) => { it.skip(browserName === 'firefox', 'isMobile is not supported in Firefox'); const device = playwright.devices['iPhone 6']; const context = await contextFactory({ ...device, recordVideo: { dir: testInfo.outputPath(''), }, }); const page = await context.newPage(); await ensureSomeFrames(page); await context.close(); const videoFile = await page.video().path(); const videoPlayer = new VideoPlayer(videoFile); expect(videoPlayer.videoWidth).toBe(374); expect(videoPlayer.videoHeight).toBe(666); }); it('should throw on browser close', async ({ browserType }, testInfo) => { const size = { width: 320, height: 240 }; const browser = await browserType.launch(); const context = await browser.newContext({ recordVideo: { dir: testInfo.outputPath(''), size, }, viewport: size, }); const page = await context.newPage(); await ensureSomeFrames(page); await browser.close(); const file = testInfo.outputPath('saved-video-'); const saveResult = await page.video().saveAs(file).catch(e => e); expect(saveResult.message).toContain('browser has been closed'); }); it('should throw if browser dies', async ({ browserType }, testInfo) => { const size = { width: 320, height: 240 }; const browser = await browserType.launch(); const context = await browser.newContext({ recordVideo: { dir: testInfo.outputPath(''), size, }, viewport: size, }); const page = await context.newPage(); await ensureSomeFrames(page); await (browser as any)._channel.killForTests(); const file = testInfo.outputPath('saved-video-'); const saveResult = await page.video().saveAs(file).catch(e => e); expect(saveResult.message).toContain('rowser has been closed'); }); it('should wait for video to finish if page was closed', async ({ browserType }, testInfo) => { const size = { width: 320, height: 240 }; const browser = await browserType.launch(); const videoDir = testInfo.outputPath(''); const context = await browser.newContext({ recordVideo: { dir: videoDir, size, }, viewport: size, }); const page = await context.newPage(); await ensureSomeFrames(page); await page.close(); await context.close(); await browser.close(); const videoFiles = findVideos(videoDir); expect(videoFiles.length).toBe(1); const videoPlayer = new VideoPlayer(videoFiles[0]); expect(videoPlayer.videoWidth).toBe(320); expect(videoPlayer.videoHeight).toBe(240); }); it('should close ffmpeg even if there were no frames', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/39872' } }, async ({ browserType }, testInfo) => { const size = { width: 320, height: 240 }; const browser = await browserType.launch(); const videoDir = testInfo.outputPath(''); const context = await browser.newContext({ recordVideo: { dir: videoDir, size, }, viewport: size, }); const page1 = await context.newPage(); await page1.close(); const page2 = await context.newPage(); await page2.close(); await context.close(); await browser.close(); const videoFiles = findVideos(videoDir); expect(videoFiles.length).toBe(2); }); it('should not create video for internal pages', async ({ browser, server }, testInfo) => { server.setRoute('/empty.html', (req, res) => { res.setHeader('Set-Cookie', 'name=value'); res.end(); }); const videoDir = testInfo.outputPath(''); const context = await browser.newContext({ recordVideo: { dir: videoDir } }); const page = await context.newPage(); await page.goto(server.EMPTY_PAGE); await ensureSomeFrames(page); const cookies = await context.cookies(); expect(cookies.length).toBe(1); await context.storageState(); await context.close(); const files = fs.readdirSync(videoDir); expect(files.length).toBe(1); }); it('should capture full viewport', async ({ browserType, browserName, isWindows, headless, isHeadlessShell }, testInfo) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/22411' }); it.fixme(browserName === 'chromium' && !isHeadlessShell, 'The square is not on the video'); it.fixme(browserName === 'firefox' && isWindows, 'https://github.com/microsoft/playwright/issues/14405'); const size = { width: 600, height: 400 }; const browser = await browserType.launch(); const videoDir = testInfo.outputPath(''); const context = await browser.newContext({ viewport: size, recordVideo: { dir: videoDir, size, }, }); const page = await context.newPage(); await page.setContent(`
`); await ensureSomeFrames(page); await page.close(); await context.close(); await browser.close(); const videoFiles = findVideos(videoDir); expect(videoFiles.length).toBe(1); const videoPlayer = new VideoPlayer(videoFiles[0]); expect(videoPlayer.videoWidth).toBe(size.width); expect(videoPlayer.videoHeight).toBe(size.height); // Bottom right corner should be part of the red border. // However, headed browsers on mac have rounded corners, so offset by 10. const pixels = videoPlayer.seekLastFrame({ x: size.width - 20, y: size.height - 20 }).data; expectAll(pixels, isAlmostRed); }); it('should capture full viewport on hidpi', async ({ browserType, browserName, headless, isWindows, isLinux, isHeadlessShell, channel }, testInfo) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/22411' }); it.fixme(browserName === 'chromium' && !isHeadlessShell, 'The square is not on the video'); it.fixme(browserName === 'firefox' && isWindows, 'https://github.com/microsoft/playwright/issues/14405'); it.fixme(browserName === 'webkit' && !headless && (isLinux || (isWindows && channel === 'webkit-wsl')), 'https://github.com/microsoft/playwright/issues/22617'); const size = { width: 600, height: 400 }; const browser = await browserType.launch(); const videoDir = testInfo.outputPath(''); const context = await browser.newContext({ viewport: size, deviceScaleFactor: 3, recordVideo: { dir: videoDir, size, }, }); const page = await context.newPage(); await page.setContent(``); await ensureSomeFrames(page); await page.close(); await context.close(); await browser.close(); const videoFiles = findVideos(videoDir); expect(videoFiles.length).toBe(1); const videoPlayer = new VideoPlayer(videoFiles[0]); expect(videoPlayer.videoWidth).toBe(size.width); expect(videoPlayer.videoHeight).toBe(size.height); // Bottom right corner should be part of the red border. // However, headed browsers on mac have rounded corners, so offset by 10. const pixels = videoPlayer.seekLastFrame({ x: size.width - 20, y: size.height - 20 }).data; expectAll(pixels, isAlmostRed); }); it('should work with video+trace', async ({ browser, trace, headless, browserName, isHeadlessShell }, testInfo) => { it.skip(trace === 'on'); it.fixme(!headless, 'different trace screencast image size on all browsers'); it.fixme(browserName === 'chromium' && !isHeadlessShell, 'different trace screencast image size'); const size = { width: 500, height: 400 }; const traceFile = testInfo.outputPath('trace.zip'); const context = await browser.newContext({ recordVideo: { dir: testInfo.outputPath(''), size }, viewport: size, }); await context.tracing.start({ screenshots: true }); const page = await context.newPage(); await page.evaluate(() => document.body.style.backgroundColor = 'red'); await ensureSomeFrames(page); await context.tracing.stop({ path: traceFile }); await context.close(); const videoFile = await page.video().path(); expectRedFrames(videoFile, size); const { events, resources } = await parseTraceRaw(traceFile); const frame = events.filter(e => e.type === 'screencast-frame').pop(); const buffer = resources.get('resources/' + frame.sha1); const image = jpegjs.decode(buffer); expect(image.width).toBe(size.width); expect(image.height).toBe(size.height); const offset = size.width * size.height / 2 * 4 + size.width * 4 / 2; // Center should be red. const pixel: Pixel = { r: image.data.readUInt8(offset), g: image.data.readUInt8(offset + 1), b: image.data.readUInt8(offset + 2), alpha: image.data.readUInt8(offset + 3), }; expect(isAlmostRed(pixel)).toBe(true); }); }); it('should saveAs video', async ({ browser }, testInfo) => { it.slow(); const videosPath = testInfo.outputPath(''); const size = { width: 320, height: 240 }; const context = await browser.newContext({ recordVideo: { dir: videosPath, size }, viewport: size, }); const page = await context.newPage(); await page.evaluate(() => document.body.style.backgroundColor = 'red'); await ensureSomeFrames(page); await context.close(); const saveAsPath = testInfo.outputPath('my-video.webm'); await page.video().saveAs(saveAsPath); expect(fs.existsSync(saveAsPath)).toBeTruthy(); });