585 lines
25 KiB
TypeScript
Raw Normal View History

/**
* 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 * as fs from 'fs';
import * as path from 'path';
import type { LaunchOptions, BrowserContextOptions, Page, Browser, BrowserContext, Video, APIRequestContext, Tracing } from 'playwright-core';
import type { TestType, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, TestInfo, VideoMode, TraceMode } from '../types/test';
import { rootTestType } from './testType';
import { createGuid, debugMode } from 'playwright-core/lib/utils';
import { removeFolders } from 'playwright-core/lib/utils/fileUtils';
export { expect } from './expect';
export const _baseTest: TestType<{}, {}> = rootTestType.test;
2022-05-03 13:25:56 -08:00
export { addRunnerPlugin as _addRunnerPlugin } from './plugins';
import * as outOfProcess from 'playwright-core/lib/outofprocess';
import type { TestInfoImpl } from './testInfo';
if ((process as any)['__pw_initiator__']) {
const originalStackTraceLimit = Error.stackTraceLimit;
Error.stackTraceLimit = 200;
try {
throw new Error('Requiring @playwright/test second time, \nFirst:\n' + (process as any)['__pw_initiator__'] + '\n\nSecond: ');
} finally {
Error.stackTraceLimit = originalStackTraceLimit;
}
} else {
(process as any)['__pw_initiator__'] = new Error().stack;
}
type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & {
_combinedContextOptions: BrowserContextOptions,
_setupContextOptionsAndArtifacts: void;
_contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>;
};
type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & {
_connectedBrowser: Browser | undefined,
_browserOptions: LaunchOptions;
_artifactsDir: () => string;
_snapshotSuffix: string;
};
export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
defaultBrowserType: [ 'chromium', { scope: 'worker', option: true } ],
browserName: [ ({ defaultBrowserType }, use) => use(defaultBrowserType), { scope: 'worker', option: true } ],
playwright: [async ({ }, use) => {
if (process.env.PW_OUT_OF_PROCESS_DRIVER) {
const impl = await outOfProcess.start({
NODE_OPTIONS: undefined // Hide driver process while debugging.
});
await use(impl.playwright as any);
await impl.stop();
} else {
await use(require('playwright-core'));
}
}, { scope: 'worker' } ],
headless: [ ({ launchOptions }, use) => use(launchOptions.headless ?? true), { scope: 'worker', option: true } ],
channel: [ ({ launchOptions }, use) => use(launchOptions.channel), { scope: 'worker', option: true } ],
launchOptions: [ {}, { scope: 'worker', option: true } ],
connectOptions: [ undefined, { scope: 'worker', option: true } ],
screenshot: [ 'off', { scope: 'worker', option: true } ],
video: [ 'off', { scope: 'worker', option: true } ],
trace: [ 'off', { scope: 'worker', option: true } ],
_artifactsDir: [async ({}, use, workerInfo) => {
let dir: string | undefined;
await use(() => {
if (!dir) {
dir = path.join(workerInfo.project.outputDir, '.playwright-artifacts-' + workerInfo.workerIndex);
fs.mkdirSync(dir, { recursive: true });
}
return dir;
});
if (dir)
await removeFolders([dir]);
}, { scope: 'worker', _title: 'playwright configuration' } as any],
_browserOptions: [async ({ playwright, headless, channel, launchOptions }, use) => {
const options: LaunchOptions = {
handleSIGINT: false,
timeout: 0,
...launchOptions,
};
if (headless !== undefined)
options.headless = headless;
if (channel !== undefined)
options.channel = channel;
for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit])
(browserType as any)._defaultLaunchOptions = options;
await use(options);
for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit])
(browserType as any)._defaultLaunchOptions = undefined;
}, { scope: 'worker', auto: true }],
_connectedBrowser: [async ({ playwright, browserName, channel, headless, connectOptions }, use) => {
if (!connectOptions) {
await use(undefined);
return;
}
if (!['chromium', 'firefox', 'webkit'].includes(browserName))
throw new Error(`Unexpected browserName "${browserName}", must be one of "chromium", "firefox" or "webkit"`);
const browser = await playwright[browserName].connect(connectOptions.wsEndpoint, {
headers: {
'x-playwright-browser': channel || browserName,
'x-playwright-headless': headless ? '1' : '0',
...connectOptions.headers,
},
timeout: connectOptions.timeout ?? 3 * 60 * 1000, // 3 minutes
});
await use(browser);
await browser.close();
}, { scope: 'worker', timeout: 0, _title: 'remote connection' } as any],
browser: [async ({ playwright, browserName, _connectedBrowser }, use) => {
if (_connectedBrowser) {
await use(_connectedBrowser);
return;
}
if (!['chromium', 'firefox', 'webkit'].includes(browserName))
throw new Error(`Unexpected browserName "${browserName}", must be one of "chromium", "firefox" or "webkit"`);
const browser = await playwright[browserName].launch();
await use(browser);
await browser.close();
}, { scope: 'worker', timeout: 0 } ],
acceptDownloads: [ ({ contextOptions }, use) => use(contextOptions.acceptDownloads ?? true), { option: true } ],
bypassCSP: [ ({ contextOptions }, use) => use(contextOptions.bypassCSP), { option: true } ],
colorScheme: [ ({ contextOptions }, use) => use(contextOptions.colorScheme), { option: true } ],
deviceScaleFactor: [ ({ contextOptions }, use) => use(contextOptions.deviceScaleFactor), { option: true } ],
extraHTTPHeaders: [ ({ contextOptions }, use) => use(contextOptions.extraHTTPHeaders), { option: true } ],
geolocation: [ ({ contextOptions }, use) => use(contextOptions.geolocation), { option: true } ],
hasTouch: [ ({ contextOptions }, use) => use(contextOptions.hasTouch), { option: true } ],
httpCredentials: [ ({ contextOptions }, use) => use(contextOptions.httpCredentials), { option: true } ],
ignoreHTTPSErrors: [ ({ contextOptions }, use) => use(contextOptions.ignoreHTTPSErrors), { option: true } ],
isMobile: [ ({ contextOptions }, use) => use(contextOptions.isMobile), { option: true } ],
javaScriptEnabled: [ ({ contextOptions }, use) => use(contextOptions.javaScriptEnabled ?? true), { option: true } ],
locale: [ ({ contextOptions }, use) => use(contextOptions.locale ?? 'en-US'), { option: true } ],
offline: [ ({ contextOptions }, use) => use(contextOptions.offline), { option: true } ],
permissions: [ ({ contextOptions }, use) => use(contextOptions.permissions), { option: true } ],
proxy: [ ({ contextOptions }, use) => use(contextOptions.proxy), { option: true } ],
storageState: [ ({ contextOptions }, use) => use(contextOptions.storageState), { option: true } ],
timezoneId: [ ({ contextOptions }, use) => use(contextOptions.timezoneId), { option: true } ],
userAgent: [ ({ contextOptions }, use) => use(contextOptions.userAgent), { option: true } ],
viewport: [({ contextOptions }, use) => use(contextOptions.viewport === undefined ? { width: 1280, height: 720 } : contextOptions.viewport), { option: true }],
actionTimeout: [ 0, { option: true } ],
navigationTimeout: [ 0, { option: true } ],
baseURL: [ async ({ }, use) => {
await use(process.env.PLAYWRIGHT_TEST_BASE_URL);
}, { option: true } ],
serviceWorkers: [ ({ contextOptions }, use) => use(contextOptions.serviceWorkers ?? 'allow'), { option: true } ],
contextOptions: [ {}, { option: true } ],
_combinedContextOptions: async ({
acceptDownloads,
bypassCSP,
colorScheme,
deviceScaleFactor,
extraHTTPHeaders,
hasTouch,
geolocation,
httpCredentials,
ignoreHTTPSErrors,
isMobile,
javaScriptEnabled,
locale,
offline,
permissions,
proxy,
storageState,
viewport,
timezoneId,
userAgent,
baseURL,
contextOptions,
serviceWorkers,
}, use) => {
const options: BrowserContextOptions = {};
if (acceptDownloads !== undefined)
options.acceptDownloads = acceptDownloads;
if (bypassCSP !== undefined)
options.bypassCSP = bypassCSP;
if (colorScheme !== undefined)
options.colorScheme = colorScheme;
if (deviceScaleFactor !== undefined)
options.deviceScaleFactor = deviceScaleFactor;
if (extraHTTPHeaders !== undefined)
options.extraHTTPHeaders = extraHTTPHeaders;
if (geolocation !== undefined)
options.geolocation = geolocation;
if (hasTouch !== undefined)
options.hasTouch = hasTouch;
if (httpCredentials !== undefined)
options.httpCredentials = httpCredentials;
if (ignoreHTTPSErrors !== undefined)
options.ignoreHTTPSErrors = ignoreHTTPSErrors;
if (isMobile !== undefined)
options.isMobile = isMobile;
if (javaScriptEnabled !== undefined)
options.javaScriptEnabled = javaScriptEnabled;
if (locale !== undefined)
options.locale = locale;
if (offline !== undefined)
options.offline = offline;
if (permissions !== undefined)
options.permissions = permissions;
if (proxy !== undefined)
options.proxy = proxy;
if (storageState !== undefined)
options.storageState = storageState;
if (timezoneId !== undefined)
options.timezoneId = timezoneId;
if (userAgent !== undefined)
options.userAgent = userAgent;
if (viewport !== undefined)
options.viewport = viewport;
if (baseURL !== undefined)
options.baseURL = baseURL;
if (serviceWorkers !== undefined)
options.serviceWorkers = serviceWorkers;
await use({
...contextOptions,
...options,
});
},
_snapshotSuffix: [process.platform, { scope: 'worker' }],
_setupContextOptionsAndArtifacts: [async ({ playwright, _snapshotSuffix, _combinedContextOptions, _browserOptions, _artifactsDir, trace, screenshot, actionTimeout, navigationTimeout }, use, testInfo) => {
testInfo.snapshotSuffix = _snapshotSuffix;
if (debugMode())
testInfo.setTimeout(0);
const traceMode = normalizeTraceMode(trace);
const defaultTraceOptions = { screenshots: true, snapshots: true, sources: true };
const traceOptions = typeof trace === 'string' ? defaultTraceOptions : { ...defaultTraceOptions, ...trace, mode: undefined };
const captureTrace = shouldCaptureTrace(traceMode, testInfo);
const temporaryTraceFiles: string[] = [];
const temporaryScreenshots: string[] = [];
const createdContexts = new Set<BrowserContext>();
const testInfoImpl = testInfo as TestInfoImpl;
const createInstrumentationListener = (context?: BrowserContext) => {
return {
onApiCallBegin: (apiCall: string, stackTrace: ParsedStackTrace | null, userData: any) => {
if (apiCall.startsWith('expect.'))
return { userObject: null };
if (apiCall === 'page.pause') {
testInfo.setTimeout(0);
context?.setDefaultNavigationTimeout(0);
context?.setDefaultTimeout(0);
}
const step = testInfoImpl._addStep({
location: stackTrace?.frames[0] as any,
category: 'pw:api',
title: apiCall,
canHaveChildren: false,
forceNoParent: false
});
userData.userObject = step;
},
onApiCallEnd: (userData: any, error?: Error) => {
const step = userData.userObject;
step?.complete({ error });
},
};
};
const startTracing = async (tracing: Tracing) => {
if (captureTrace) {
const title = [path.relative(testInfo.project.testDir, testInfo.file) + ':' + testInfo.line, ...testInfo.titlePath.slice(1)].join(' ');
if (!(tracing as any)[kTracingStarted]) {
await tracing.start({ ...traceOptions, title });
(tracing as any)[kTracingStarted] = true;
} else {
await tracing.startChunk({ title });
}
} else {
(tracing as any)[kTracingStarted] = false;
await tracing.stop();
}
};
const onDidCreateBrowserContext = async (context: BrowserContext) => {
createdContexts.add(context);
context.setDefaultTimeout(actionTimeout || 0);
context.setDefaultNavigationTimeout(navigationTimeout || actionTimeout || 0);
await startTracing(context.tracing);
const listener = createInstrumentationListener(context);
(context as any)._instrumentation.addListener(listener);
(context.request as any)._instrumentation.addListener(listener);
};
const onDidCreateRequestContext = async (context: APIRequestContext) => {
const tracing = (context as any)._tracing as Tracing;
await startTracing(tracing);
(context as any)._instrumentation.addListener(createInstrumentationListener());
};
const startedCollectingArtifacts = Symbol('startedCollectingArtifacts');
const stopTracing = async (tracing: Tracing) => {
(tracing as any)[startedCollectingArtifacts] = true;
if (captureTrace) {
// Export trace for now. We'll know whether we have to preserve it
// after the test finishes.
const tracePath = path.join(_artifactsDir(), createGuid() + '.zip');
temporaryTraceFiles.push(tracePath);
await tracing.stopChunk({ path: tracePath });
}
};
const screenshottedSymbol = Symbol('screenshotted');
const screenshotPage = async (page: Page) => {
if ((page as any)[screenshottedSymbol])
return;
(page as any)[screenshottedSymbol] = true;
const screenshotPath = path.join(_artifactsDir(), createGuid() + '.png');
temporaryScreenshots.push(screenshotPath);
await page.screenshot({ timeout: 5000, path: screenshotPath }).catch(() => {});
};
const screenshotOnTestFailure = async () => {
const contexts: BrowserContext[] = [];
for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit])
contexts.push(...(browserType as any)._contexts);
await Promise.all(contexts.map(ctx => Promise.all(ctx.pages().map(screenshotPage))));
};
const onWillCloseContext = async (context: BrowserContext) => {
await stopTracing(context.tracing);
if (screenshot === 'on' || screenshot === 'only-on-failure') {
// Capture screenshot for now. We'll know whether we have to preserve them
// after the test finishes.
await Promise.all(context.pages().map(screenshotPage));
}
};
const onWillCloseRequestContext = async (context: APIRequestContext) => {
const tracing = (context as any)._tracing as Tracing;
await stopTracing(tracing);
};
// 1. Setup instrumentation and process existing contexts.
for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit]) {
(browserType as any)._onDidCreateContext = onDidCreateBrowserContext;
(browserType as any)._onWillCloseContext = onWillCloseContext;
(browserType as any)._defaultContextOptions = _combinedContextOptions;
const existingContexts = Array.from((browserType as any)._contexts) as BrowserContext[];
await Promise.all(existingContexts.map(onDidCreateBrowserContext));
}
{
(playwright.request as any)._onDidCreateContext = onDidCreateRequestContext;
(playwright.request as any)._onWillCloseContext = onWillCloseRequestContext;
const existingApiRequests: APIRequestContext[] = Array.from((playwright.request as any)._contexts as Set<APIRequestContext>);
await Promise.all(existingApiRequests.map(onDidCreateRequestContext));
}
if (screenshot === 'on' || screenshot === 'only-on-failure')
testInfoImpl._onTestFailureImmediateCallbacks.set(screenshotOnTestFailure, 'Screenshot on failure');
// 2. Run the test.
await use();
// 3. Determine whether we need the artifacts.
const testFailed = testInfo.status !== testInfo.expectedStatus;
const preserveTrace = captureTrace && (traceMode === 'on' || (testFailed && traceMode === 'retain-on-failure') || (traceMode === 'on-first-retry' && testInfo.retry === 1));
const captureScreenshots = (screenshot === 'on' || (screenshot === 'only-on-failure' && testFailed));
const traceAttachments: string[] = [];
const addTraceAttachment = () => {
const tracePath = testInfo.outputPath(`trace${traceAttachments.length ? '-' + traceAttachments.length : ''}.zip`);
traceAttachments.push(tracePath);
testInfo.attachments.push({ name: 'trace', path: tracePath, contentType: 'application/zip' });
return tracePath;
};
const screenshotAttachments: string[] = [];
const addScreenshotAttachment = () => {
const screenshotPath = testInfo.outputPath(`test-${testFailed ? 'failed' : 'finished'}-${screenshotAttachments.length + 1}.png`);
screenshotAttachments.push(screenshotPath);
testInfo.attachments.push({ name: 'screenshot', path: screenshotPath, contentType: 'image/png' });
return screenshotPath;
};
// 4. Cleanup instrumentation.
const leftoverContexts: BrowserContext[] = [];
for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit]) {
leftoverContexts.push(...(browserType as any)._contexts);
(browserType as any)._onDidCreateContext = undefined;
(browserType as any)._onWillCloseContext = undefined;
(browserType as any)._defaultContextOptions = undefined;
}
leftoverContexts.forEach(context => (context as any)._instrumentation.removeAllListeners());
for (const context of (playwright.request as any)._contexts)
context._instrumentation.removeAllListeners();
const leftoverApiRequests: APIRequestContext[] = Array.from((playwright.request as any)._contexts as Set<APIRequestContext>);
(playwright.request as any)._onDidCreateContext = undefined;
(playwright.request as any)._onWillCloseContext = undefined;
testInfoImpl._onTestFailureImmediateCallbacks.delete(screenshotOnTestFailure);
const stopTraceChunk = async (tracing: Tracing): Promise<boolean> => {
// When we timeout during context.close(), we might end up with context still alive
// but artifacts being already collected. In this case, do not collect artifacts
// for the second time.
if ((tracing as any)[startedCollectingArtifacts])
return false;
if (preserveTrace)
await tracing.stopChunk({ path: addTraceAttachment() });
else if (captureTrace)
await tracing.stopChunk();
return true;
};
// 5. Collect artifacts from any non-closed contexts.
await Promise.all(leftoverContexts.map(async context => {
if (!await stopTraceChunk(context.tracing))
return;
if (captureScreenshots) {
await Promise.all(context.pages().map(async page => {
if ((page as any)[screenshottedSymbol])
return;
await page.screenshot({ timeout: 5000, path: addScreenshotAttachment() }).catch(() => {});
}));
}
}).concat(leftoverApiRequests.map(async context => {
const tracing = (context as any)._tracing as Tracing;
await stopTraceChunk(tracing);
})));
// 6. Either remove or attach temporary traces and screenshots for contexts closed
// before the test has finished.
await Promise.all(temporaryTraceFiles.map(async file => {
if (preserveTrace)
await fs.promises.rename(file, addTraceAttachment()).catch(() => {});
else
await fs.promises.unlink(file).catch(() => {});
}));
await Promise.all(temporaryScreenshots.map(async file => {
if (captureScreenshots)
await fs.promises.rename(file, addScreenshotAttachment()).catch(() => {});
else
await fs.promises.unlink(file).catch(() => {});
}));
}, { auto: 'all-hooks-included', _title: 'playwright configuration' } as any],
_contextFactory: [async ({ browser, video, _artifactsDir }, use, testInfo) => {
const videoMode = normalizeVideoMode(video);
const captureVideo = shouldCaptureVideo(videoMode, testInfo);
const contexts = new Map<BrowserContext, { pages: Page[] }>();
await use(async options => {
const hook = hookType(testInfo);
if (hook)
throw new Error(`"context" and "page" fixtures are not supported in ${hook}. Use browser.newContext() instead.`);
const videoOptions: BrowserContextOptions = captureVideo ? {
recordVideo: {
dir: _artifactsDir(),
size: typeof video === 'string' ? undefined : video.size,
}
} : {};
const context = await browser.newContext({ ...videoOptions, ...options });
const contextData: { pages: Page[] } = { pages: [] };
contexts.set(context, contextData);
context.on('page', page => contextData.pages.push(page));
return context;
});
const prependToError = testInfo.status === 'timedOut' ?
formatPendingCalls((browser as any)._connection.pendingProtocolCalls()) : '';
let counter = 0;
await Promise.all([...contexts.keys()].map(async context => {
await context.close();
const testFailed = testInfo.status !== testInfo.expectedStatus;
const preserveVideo = captureVideo && (videoMode === 'on' || (testFailed && videoMode === 'retain-on-failure') || (videoMode === 'on-first-retry' && testInfo.retry === 1));
if (preserveVideo) {
const { pages } = contexts.get(context)!;
const videos = pages.map(p => p.video()).filter(Boolean) as Video[];
await Promise.all(videos.map(async v => {
try {
const savedPath = testInfo.outputPath(`video${counter ? '-' + counter : ''}.webm`);
++counter;
await v.saveAs(savedPath);
testInfo.attachments.push({ name: 'video', path: savedPath, contentType: 'video/webm' });
} catch (e) {
// Silent catch empty videos.
}
}));
}
}));
if (prependToError)
testInfo.errors.push({ message: prependToError });
}, { scope: 'test', _title: 'context' } as any],
context: async ({ _contextFactory }, use) => {
await use(await _contextFactory());
},
page: async ({ context }, use) => {
await use(await context.newPage());
},
request: async ({ playwright, _combinedContextOptions }, use) => {
const request = await playwright.request.newContext(_combinedContextOptions);
await use(request);
await request.dispose();
}
});
function formatPendingCalls(calls: ParsedStackTrace[]) {
calls = calls.filter(call => !!call.apiName);
if (!calls.length)
return '';
return 'Pending operations:\n' + calls.map(call => {
const frame = call.frames && call.frames[0] ? ' at ' + formatStackFrame(call.frames[0]) : '';
return ` - ${call.apiName}${frame}\n`;
}).join('');
}
function formatStackFrame(frame: StackFrame) {
const file = path.relative(process.cwd(), frame.file) || path.basename(frame.file);
return `${file}:${frame.line || 1}:${frame.column || 1}`;
}
function hookType(testInfo: TestInfo): 'beforeAll' | 'afterAll' | undefined {
if ((testInfo as any)._timeoutManager._runnable?.type === 'beforeAll')
return 'beforeAll';
if ((testInfo as any)._timeoutManager._runnable?.type === 'afterAll')
return 'afterAll';
}
type StackFrame = {
file: string,
line?: number,
column?: number,
function?: string,
};
type ParsedStackTrace = {
frames: StackFrame[];
frameTexts: string[];
apiName: string;
};
export function normalizeVideoMode(video: VideoMode | 'retry-with-video' | { mode: VideoMode }) {
let videoMode = typeof video === 'string' ? video : video.mode;
if (videoMode === 'retry-with-video')
videoMode = 'on-first-retry';
return videoMode;
}
export function shouldCaptureVideo(videoMode: VideoMode, testInfo: TestInfo) {
return (videoMode === 'on' || videoMode === 'retain-on-failure' || (videoMode === 'on-first-retry' && testInfo.retry === 1));
}
export function normalizeTraceMode(trace: TraceMode | 'retry-with-trace' | { mode: TraceMode }) {
let traceMode = typeof trace === 'string' ? trace : trace.mode;
if (traceMode === 'retry-with-trace')
traceMode = 'on-first-retry';
return traceMode;
}
export function shouldCaptureTrace(traceMode: TraceMode, testInfo: TestInfo) {
return traceMode === 'on' || traceMode === 'retain-on-failure' || (traceMode === 'on-first-retry' && testInfo.retry === 1);
}
const kTracingStarted = Symbol('kTracingStarted');
export default test;