Files
AI/참고/playwright-main/tests/library/screencast.spec.ts
2026-05-12 19:40:31 +09:00

304 lines
12 KiB
TypeScript

/**
* 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 { expect, browserTest as test } from '../config/browserTest';
import { ensureSomeFrames } from '../config/utils';
import { kTargetClosedErrorMessage } from '../config/errors';
import { VideoPlayer } from './videoPlayer';
test.skip(({ mode }) => mode !== 'default', 'screencast is not available in remote mode');
test.skip(({ video }) => video === 'on', 'conflicts with built-in video recording');
test.slow();
test('screencast.start delivers frames via onFrame callback', async ({ browser, server, trace }) => {
test.skip(trace === 'on', 'trace=on has different screencast image configuration');
const context = await browser.newContext({ viewport: { width: 1000, height: 400 } });
const page = await context.newPage();
const frames: Buffer[] = [];
const size = { width: 500, height: 400 };
await page.screencast.start({ onFrame: ({ data }) => frames.push(data), size });
await page.goto(server.EMPTY_PAGE);
await page.evaluate(() => document.body.style.backgroundColor = 'red');
await ensureSomeFrames(page);
await page.screencast.stop();
expect(frames.length).toBeGreaterThan(0);
for (const frame of frames) {
// Each frame must be a valid JPEG (starts with FF D8)
expect(frame[0]).toBe(0xff);
expect(frame[1]).toBe(0xd8);
const { width, height } = jpegDimensions(frame);
// Frame should be scaled down to fit the maximum size.
expect(width).toBe(500);
expect(height).toBe(200);
}
await context.close();
});
test('onFrame receives viewport size', async ({ browser, server, trace }) => {
test.skip(trace === 'on', 'trace=on has different screencast image configuration');
const context = await browser.newContext({ viewport: { width: 1000, height: 400 } });
const page = await context.newPage();
const frames: { viewportWidth: number, viewportHeight: number }[] = [];
await page.screencast.start({
onFrame: ({ viewportWidth, viewportHeight }) => frames.push({ viewportWidth, viewportHeight }),
size: { width: 500, height: 400 },
});
await page.goto(server.EMPTY_PAGE);
await ensureSomeFrames(page);
await page.screencast.stop();
expect(frames.length).toBeGreaterThan(0);
for (const frame of frames) {
expect(frame.viewportWidth).toBe(1000);
expect(frame.viewportHeight).toBe(400);
}
await context.close();
});
test('start throws if screencast is already started', async ({ browser }) => {
const context = await browser.newContext({ viewport: { width: 500, height: 400 } });
const page = await context.newPage();
await page.screencast.start({ onFrame: () => {} });
await expect(page.screencast.start({ onFrame: () => {} })).rejects.toThrow('Screencast is already started');
await page.screencast.stop();
await context.close();
});
test('start allows restart with different options after stop', async ({ browser, trace }) => {
test.skip(trace === 'on', 'trace=on enables screencast with different options');
const context = await browser.newContext({ viewport: { width: 500, height: 400 } });
const page = await context.newPage();
await page.screencast.start({ onFrame: () => {}, size: { width: 500, height: 400 } });
await page.screencast.stop();
// Different options should succeed once the previous screencast is stopped.
await page.screencast.start({ onFrame: () => {}, size: { width: 320, height: 240 } });
await page.screencast.stop();
await context.close();
});
test('start returns a disposable that stops screencast', async ({ browser, server, trace }) => {
test.skip(trace === 'on', 'trace=on has different screencast image configuration');
const context = await browser.newContext({ viewport: { width: 500, height: 400 } });
const page = await context.newPage();
const frames: Buffer[] = [];
await page.screencast.start({ onFrame: ({ data }) => frames.push(data), size: { width: 500, height: 400 } });
await page.goto(server.EMPTY_PAGE);
await page.evaluate(() => document.body.style.backgroundColor = 'red');
await ensureSomeFrames(page);
await page.screencast.stop();
const frameCountAfterDispose = frames.length;
expect(frameCountAfterDispose).toBeGreaterThan(0);
// No more frames should arrive after dispose.
await page.evaluate(() => document.body.style.backgroundColor = 'blue');
await ensureSomeFrames(page);
expect(frames.length).toBe(frameCountAfterDispose);
await context.close();
});
test('start/stop twice without path creates two files in artifactsDir', async ({ browserType }, testInfo) => {
test.slow();
const artifactsDir = testInfo.outputPath('artifacts');
const browser = await browserType.launch({ artifactsDir });
const size = { width: 800, height: 800 };
const context = await browser.newContext({ viewport: size });
const page = await context.newPage();
await page.screencast.start({ path: testInfo.outputPath('video1.webm'), size });
await page.evaluate(() => document.body.style.backgroundColor = 'red');
await ensureSomeFrames(page);
await page.screencast.stop();
await page.screencast.start({ path: testInfo.outputPath('video2.webm'), size });
await page.evaluate(() => document.body.style.backgroundColor = 'blue');
await ensureSomeFrames(page);
await page.screencast.stop();
const videoFiles = fs.readdirSync(artifactsDir).filter(f => f.endsWith('.webm'));
expect(videoFiles).toHaveLength(2);
await context.close();
await browser.close();
});
test('start should work when recordVideo is set', async ({ browser }, testInfo) => {
test.slow();
const autoDir = testInfo.outputPath('auto');
const manualDir = testInfo.outputPath('manual');
const context = await browser.newContext({
recordVideo: {
dir: autoDir,
},
});
const page = await context.newPage();
await page.screencast.start({ path: path.join(manualDir, 'video.webm') });
await page.evaluate(() => document.body.style.backgroundColor = 'blue');
await ensureSomeFrames(page);
await page.screencast.stop();
const videoFiles1 = fs.readdirSync(manualDir).filter(f => f.endsWith('.webm'));
expect(videoFiles1).toHaveLength(1);
await context.close();
const videoFiles2 = fs.readdirSync(autoDir).filter(f => f.endsWith('.webm'));
expect(videoFiles2).toHaveLength(1);
});
test('start should fail when another recording is in progress', async ({ page, trace }, testInfo) => {
test.skip(trace === 'on', 'trace=on has different screencast image configuration');
await page.screencast.start({ path: testInfo.outputPath('video.webm') });
const error = await page.screencast.start({ path: testInfo.outputPath('video2.webm') }).catch(e => e);
expect(error.message).toContain('Screencast is already started');
});
test('stop should not fail when no recording is in progress', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
// stop() is a no-op when screencast is not started.
await page.screencast.stop();
await context.close();
});
test('start should finish when page is closed', async ({ browser }, testInfo) => {
const context = await browser.newContext();
const page = await context.newPage();
const videoPath = testInfo.outputPath('video.webm');
await page.screencast.start({ path: videoPath, size: { width: 800, height: 800 } });
await page.evaluate(() => document.body.style.backgroundColor = 'red');
await ensureSomeFrames(page);
await page.close();
const error = await page.screencast.stop().catch(e => e);
expect(error.message).toContain(kTargetClosedErrorMessage);
await context.close();
});
test('empty video', async ({ browser }, testInfo) => {
test.slow();
const size = { width: 800, height: 800 };
const context = await browser.newContext({ viewport: size });
const page = await context.newPage();
const videoPath = testInfo.outputPath('empty-video.webm');
await page.screencast.start({ path: videoPath, size });
await page.screencast.stop();
await context.close();
expectFrames(videoPath, size, isAlmostWhite);
});
test('start dispose stops recording', async ({ browser }, testInfo) => {
test.slow();
const size = { width: 800, height: 800 };
const context = await browser.newContext({ viewport: size });
const page = await context.newPage();
const videoPath = testInfo.outputPath('dispose-video.webm');
const disposable = await page.screencast.start({ path: videoPath, size });
await page.evaluate(() => document.body.style.backgroundColor = 'red');
await ensureSomeFrames(page);
await disposable.dispose();
expectRedFrames(videoPath, size);
await context.close();
});
type Pixel = { r: number, g: number, b: number, alpha: number };
type PixelPredicate = (pixel: Pixel) => boolean;
function isAlmostWhite({ r, g, b, alpha }: Pixel): boolean {
return r > 185 && g > 185 && b > 185 && alpha === 255;
}
function isAlmostRed({ r, g, b, alpha }: Pixel): boolean {
return r > 185 && g < 70 && b < 70 && 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 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 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);
}
}
function jpegDimensions(buffer: Buffer): { width: number, height: number } {
let i = 2; // skip SOI marker (FF D8)
while (i < buffer.length - 8) {
if (buffer[i] !== 0xFF)
break;
const marker = buffer[i + 1];
const segmentLength = buffer.readUInt16BE(i + 2);
// SOF markers: C0 (baseline), C2 (progressive), C1, C3, C5-C7, C9-CB, CD-CF
if ((marker >= 0xC0 && marker <= 0xC3) || (marker >= 0xC5 && marker <= 0xC7) ||
(marker >= 0xC9 && marker <= 0xCB) || (marker >= 0xCD && marker <= 0xCF)) {
const height = buffer.readUInt16BE(i + 5);
const width = buffer.readUInt16BE(i + 7);
return { width, height };
}
i += 2 + segmentLength;
}
throw new Error('Could not parse JPEG dimensions');
}