2022-01-28 17:39:42 -08:00
|
|
|
/**
|
|
|
|
* 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 * as mime from 'mime';
|
|
|
|
import path from 'path';
|
2022-04-07 12:55:44 -08:00
|
|
|
import { calculateSha1 } from 'playwright-core/lib/utils';
|
2022-04-05 08:34:51 -07:00
|
|
|
import type { TestError, TestInfo, TestStatus } from '../types/test';
|
|
|
|
import type { FullConfigInternal, FullProjectInternal } from './types';
|
2022-04-06 13:57:14 -08:00
|
|
|
import type { WorkerInitParams } from './ipc';
|
|
|
|
import type { Loader } from './loader';
|
|
|
|
import type { ProjectImpl } from './project';
|
|
|
|
import type { TestCase } from './test';
|
2022-03-17 09:36:03 -07:00
|
|
|
import { TimeoutManager } from './timeoutManager';
|
2022-04-06 13:57:14 -08:00
|
|
|
import type { Annotation, TestStepInternal } from './types';
|
2022-03-08 16:35:14 -08:00
|
|
|
import { addSuffixToFilePath, getContainedPath, monotonicTime, sanitizeForFilePath, serializeError, trimLongString } from './util';
|
|
|
|
|
2022-01-28 17:39:42 -08:00
|
|
|
export class TestInfoImpl implements TestInfo {
|
|
|
|
private _projectImpl: ProjectImpl;
|
|
|
|
private _addStepImpl: (data: Omit<TestStepInternal, 'complete'>) => TestStepInternal;
|
|
|
|
readonly _test: TestCase;
|
2022-03-17 09:36:03 -07:00
|
|
|
readonly _timeoutManager: TimeoutManager;
|
2022-01-28 17:39:42 -08:00
|
|
|
readonly _startTime: number;
|
|
|
|
readonly _startWallTime: number;
|
2022-02-02 19:33:51 -07:00
|
|
|
private _hasHardError: boolean = false;
|
2022-03-10 17:50:26 -07:00
|
|
|
readonly _screenshotsDir: string;
|
2022-01-28 17:39:42 -08:00
|
|
|
|
|
|
|
// ------------ TestInfo fields ------------
|
|
|
|
readonly repeatEachIndex: number;
|
|
|
|
readonly retry: number;
|
|
|
|
readonly workerIndex: number;
|
|
|
|
readonly parallelIndex: number;
|
2022-04-05 08:34:51 -07:00
|
|
|
readonly project: FullProjectInternal;
|
2022-03-28 15:53:42 -07:00
|
|
|
config: FullConfigInternal;
|
2022-01-28 17:39:42 -08:00
|
|
|
readonly title: string;
|
|
|
|
readonly titlePath: string[];
|
|
|
|
readonly file: string;
|
|
|
|
readonly line: number;
|
|
|
|
readonly column: number;
|
|
|
|
readonly fn: Function;
|
|
|
|
expectedStatus: TestStatus;
|
|
|
|
duration: number = 0;
|
2022-03-08 16:35:14 -08:00
|
|
|
readonly annotations: Annotation[] = [];
|
2022-01-28 17:39:42 -08:00
|
|
|
readonly attachments: TestInfo['attachments'] = [];
|
|
|
|
status: TestStatus = 'passed';
|
|
|
|
readonly stdout: TestInfo['stdout'] = [];
|
|
|
|
readonly stderr: TestInfo['stderr'] = [];
|
|
|
|
snapshotSuffix: string = '';
|
|
|
|
readonly outputDir: string;
|
|
|
|
readonly snapshotDir: string;
|
2022-02-02 19:33:51 -07:00
|
|
|
errors: TestError[] = [];
|
2022-04-05 16:47:35 -08:00
|
|
|
currentStep: TestStepInternal | undefined;
|
2022-02-02 19:33:51 -07:00
|
|
|
|
|
|
|
get error(): TestError | undefined {
|
|
|
|
return this.errors.length > 0 ? this.errors[0] : undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
set error(e: TestError | undefined) {
|
|
|
|
if (e === undefined)
|
|
|
|
throw new Error('Cannot assign testInfo.error undefined value!');
|
|
|
|
if (!this.errors.length)
|
|
|
|
this.errors.push(e);
|
|
|
|
else
|
|
|
|
this.errors[0] = e;
|
|
|
|
}
|
2022-01-28 17:39:42 -08:00
|
|
|
|
2022-03-17 09:36:03 -07:00
|
|
|
get timeout(): number {
|
|
|
|
return this._timeoutManager.defaultTimeout();
|
|
|
|
}
|
|
|
|
|
|
|
|
set timeout(timeout: number) {
|
|
|
|
// Ignored.
|
|
|
|
}
|
|
|
|
|
2022-01-28 17:39:42 -08:00
|
|
|
constructor(
|
|
|
|
loader: Loader,
|
|
|
|
workerParams: WorkerInitParams,
|
|
|
|
test: TestCase,
|
|
|
|
retry: number,
|
|
|
|
addStepImpl: (data: Omit<TestStepInternal, 'complete'>) => TestStepInternal,
|
|
|
|
) {
|
|
|
|
this._projectImpl = loader.projects()[workerParams.projectIndex];
|
|
|
|
this._test = test;
|
|
|
|
this._addStepImpl = addStepImpl;
|
|
|
|
this._startTime = monotonicTime();
|
|
|
|
this._startWallTime = Date.now();
|
|
|
|
|
|
|
|
this.repeatEachIndex = workerParams.repeatEachIndex;
|
|
|
|
this.retry = retry;
|
|
|
|
this.workerIndex = workerParams.workerIndex;
|
|
|
|
this.parallelIndex = workerParams.parallelIndex;
|
|
|
|
this.project = this._projectImpl.config;
|
|
|
|
this.config = loader.fullConfig();
|
|
|
|
this.title = test.title;
|
|
|
|
this.titlePath = test.titlePath();
|
|
|
|
this.file = test.location.file;
|
|
|
|
this.line = test.location.line;
|
|
|
|
this.column = test.location.column;
|
|
|
|
this.fn = test.fn;
|
|
|
|
this.expectedStatus = test.expectedStatus;
|
|
|
|
|
2022-03-17 09:36:03 -07:00
|
|
|
this._timeoutManager = new TimeoutManager(this.project.timeout);
|
2022-01-28 17:39:42 -08:00
|
|
|
|
|
|
|
this.outputDir = (() => {
|
|
|
|
const sameName = loader.projects().filter(project => project.config.name === this.project.name);
|
|
|
|
let uniqueProjectNamePathSegment: string;
|
|
|
|
if (sameName.length > 1)
|
|
|
|
uniqueProjectNamePathSegment = this.project.name + (sameName.indexOf(this._projectImpl) + 1);
|
|
|
|
else
|
|
|
|
uniqueProjectNamePathSegment = this.project.name;
|
|
|
|
|
|
|
|
const relativeTestFilePath = path.relative(this.project.testDir, test._requireFile.replace(/\.(spec|test)\.(js|ts|mjs)$/, ''));
|
|
|
|
const sanitizedRelativePath = relativeTestFilePath.replace(process.platform === 'win32' ? new RegExp('\\\\', 'g') : new RegExp('/', 'g'), '-');
|
2022-03-08 16:35:14 -08:00
|
|
|
const fullTitleWithoutSpec = test.titlePath().slice(1).join(' ');
|
2022-01-28 17:39:42 -08:00
|
|
|
|
2022-02-18 15:40:36 -08:00
|
|
|
let testOutputDir = trimLongString(sanitizedRelativePath + '-' + sanitizeForFilePath(fullTitleWithoutSpec));
|
2022-01-28 17:39:42 -08:00
|
|
|
if (uniqueProjectNamePathSegment)
|
|
|
|
testOutputDir += '-' + sanitizeForFilePath(uniqueProjectNamePathSegment);
|
|
|
|
if (this.retry)
|
|
|
|
testOutputDir += '-retry' + this.retry;
|
|
|
|
if (this.repeatEachIndex)
|
|
|
|
testOutputDir += '-repeat' + this.repeatEachIndex;
|
|
|
|
return path.join(this.project.outputDir, testOutputDir);
|
|
|
|
})();
|
|
|
|
|
|
|
|
this.snapshotDir = (() => {
|
|
|
|
const relativeTestFilePath = path.relative(this.project.testDir, test._requireFile);
|
|
|
|
return path.join(this.project.snapshotDir, relativeTestFilePath + '-snapshots');
|
|
|
|
})();
|
2022-03-10 17:50:26 -07:00
|
|
|
this._screenshotsDir = (() => {
|
|
|
|
const relativeTestFilePath = path.relative(this.project.testDir, test._requireFile);
|
2022-04-05 08:34:51 -07:00
|
|
|
return path.join(this.project._screenshotsDir, relativeTestFilePath);
|
2022-03-10 17:50:26 -07:00
|
|
|
})();
|
2022-01-28 17:39:42 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
private _modifier(type: 'skip' | 'fail' | 'fixme' | 'slow', modifierArgs: [arg?: any, description?: string]) {
|
|
|
|
if (typeof modifierArgs[1] === 'function') {
|
|
|
|
throw new Error([
|
|
|
|
'It looks like you are calling test.skip() inside the test and pass a callback.',
|
|
|
|
'Pass a condition instead and optional description instead:',
|
|
|
|
`test('my test', async ({ page, isMobile }) => {`,
|
|
|
|
` test.skip(isMobile, 'This test is not applicable on mobile');`,
|
|
|
|
`});`,
|
|
|
|
].join('\n'));
|
|
|
|
}
|
|
|
|
|
|
|
|
if (modifierArgs.length >= 1 && !modifierArgs[0])
|
|
|
|
return;
|
|
|
|
|
|
|
|
const description = modifierArgs[1];
|
|
|
|
this.annotations.push({ type, description });
|
|
|
|
if (type === 'slow') {
|
2022-03-17 09:36:03 -07:00
|
|
|
this._timeoutManager.slow();
|
2022-01-28 17:39:42 -08:00
|
|
|
} else if (type === 'skip' || type === 'fixme') {
|
|
|
|
this.expectedStatus = 'skipped';
|
|
|
|
throw new SkipError('Test is skipped: ' + (description || ''));
|
|
|
|
} else if (type === 'fail') {
|
|
|
|
if (this.expectedStatus !== 'skipped')
|
|
|
|
this.expectedStatus = 'failed';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async _runWithTimeout(cb: () => Promise<any>): Promise<void> {
|
2022-03-17 09:36:03 -07:00
|
|
|
const timeoutError = await this._timeoutManager.runWithTimeout(cb);
|
|
|
|
// Do not overwrite existing failure upon hook/teardown timeout.
|
|
|
|
if (timeoutError && this.status === 'passed') {
|
|
|
|
this.status = 'timedOut';
|
|
|
|
this.errors.push(timeoutError);
|
2022-01-28 17:39:42 -08:00
|
|
|
}
|
|
|
|
this.duration = monotonicTime() - this._startTime;
|
|
|
|
}
|
|
|
|
|
|
|
|
async _runFn(fn: Function, skips?: 'allowSkips'): Promise<TestError | undefined> {
|
|
|
|
try {
|
|
|
|
await fn();
|
|
|
|
} catch (error) {
|
|
|
|
if (skips === 'allowSkips' && error instanceof SkipError) {
|
|
|
|
if (this.status === 'passed')
|
|
|
|
this.status = 'skipped';
|
|
|
|
} else {
|
|
|
|
const serialized = serializeError(error);
|
2022-02-02 19:33:51 -07:00
|
|
|
this._failWithError(serialized, true /* isHardError */);
|
2022-01-28 17:39:42 -08:00
|
|
|
return serialized;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_addStep(data: Omit<TestStepInternal, 'complete'>) {
|
|
|
|
return this._addStepImpl(data);
|
|
|
|
}
|
|
|
|
|
2022-02-02 19:33:51 -07:00
|
|
|
_failWithError(error: TestError, isHardError: boolean) {
|
|
|
|
// Do not overwrite any previous hard errors.
|
2022-01-28 17:39:42 -08:00
|
|
|
// Some (but not all) scenarios include:
|
|
|
|
// - expect() that fails after uncaught exception.
|
|
|
|
// - fail after the timeout, e.g. due to fixture teardown.
|
2022-02-02 19:33:51 -07:00
|
|
|
if (isHardError && this._hasHardError)
|
|
|
|
return;
|
|
|
|
if (isHardError)
|
|
|
|
this._hasHardError = true;
|
2022-01-28 17:39:42 -08:00
|
|
|
if (this.status === 'passed')
|
|
|
|
this.status = 'failed';
|
2022-02-02 19:33:51 -07:00
|
|
|
this.errors.push(error);
|
2022-01-28 17:39:42 -08:00
|
|
|
}
|
|
|
|
|
2022-03-17 19:33:01 -07:00
|
|
|
async _runAsStep<T>(cb: () => Promise<T>, stepInfo: Omit<TestStepInternal, 'complete'>): Promise<T> {
|
|
|
|
const step = this._addStep(stepInfo);
|
|
|
|
try {
|
|
|
|
const result = await cb();
|
2022-03-30 20:52:00 -08:00
|
|
|
step.complete({});
|
2022-03-17 19:33:01 -07:00
|
|
|
return result;
|
|
|
|
} catch (e) {
|
2022-03-30 20:52:00 -08:00
|
|
|
step.complete({ error: e instanceof SkipError ? undefined : serializeError(e) });
|
2022-03-17 19:33:01 -07:00
|
|
|
throw e;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-01-28 17:39:42 -08:00
|
|
|
// ------------ TestInfo methods ------------
|
|
|
|
|
|
|
|
async attach(name: string, options: { path?: string, body?: string | Buffer, contentType?: string } = {}) {
|
|
|
|
if ((options.path !== undefined ? 1 : 0) + (options.body !== undefined ? 1 : 0) !== 1)
|
|
|
|
throw new Error(`Exactly one of "path" and "body" must be specified`);
|
|
|
|
if (options.path !== undefined) {
|
|
|
|
const hash = calculateSha1(options.path);
|
|
|
|
const dest = this.outputPath('attachments', hash + path.extname(options.path));
|
|
|
|
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
|
|
|
|
await fs.promises.copyFile(options.path, dest);
|
|
|
|
const contentType = options.contentType ?? (mime.getType(path.basename(options.path)) || 'application/octet-stream');
|
|
|
|
this.attachments.push({ name, contentType, path: dest });
|
|
|
|
} else {
|
|
|
|
const contentType = options.contentType ?? (typeof options.body === 'string' ? 'text/plain' : 'application/octet-stream');
|
|
|
|
this.attachments.push({ name, contentType, body: typeof options.body === 'string' ? Buffer.from(options.body) : options.body });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
outputPath(...pathSegments: string[]){
|
|
|
|
fs.mkdirSync(this.outputDir, { recursive: true });
|
|
|
|
const joinedPath = path.join(...pathSegments);
|
|
|
|
const outputPath = getContainedPath(this.outputDir, joinedPath);
|
|
|
|
if (outputPath)
|
|
|
|
return outputPath;
|
|
|
|
throw new Error(`The outputPath is not allowed outside of the parent directory. Please fix the defined path.\n\n\toutputPath: ${joinedPath}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
snapshotPath(...pathSegments: string[]) {
|
|
|
|
let suffix = '';
|
|
|
|
const projectNamePathSegment = sanitizeForFilePath(this.project.name);
|
|
|
|
if (projectNamePathSegment)
|
|
|
|
suffix += '-' + projectNamePathSegment;
|
|
|
|
if (this.snapshotSuffix)
|
|
|
|
suffix += '-' + this.snapshotSuffix;
|
|
|
|
const subPath = addSuffixToFilePath(path.join(...pathSegments), suffix);
|
|
|
|
const snapshotPath = getContainedPath(this.snapshotDir, subPath);
|
|
|
|
if (snapshotPath)
|
|
|
|
return snapshotPath;
|
|
|
|
throw new Error(`The snapshotPath is not allowed outside of the parent directory. Please fix the defined path.\n\n\tsnapshotPath: ${subPath}`);
|
|
|
|
}
|
|
|
|
|
2022-03-10 17:50:26 -07:00
|
|
|
_screenshotPath(...pathSegments: string[]) {
|
|
|
|
const subPath = path.join(...pathSegments);
|
|
|
|
const screenshotPath = getContainedPath(this._screenshotsDir, subPath);
|
|
|
|
if (screenshotPath)
|
|
|
|
return screenshotPath;
|
|
|
|
throw new Error(`Screenshot name "${subPath}" should not point outside of the parent directory.`);
|
|
|
|
}
|
|
|
|
|
2022-01-28 17:39:42 -08:00
|
|
|
skip(...args: [arg?: any, description?: string]) {
|
|
|
|
this._modifier('skip', args);
|
|
|
|
}
|
|
|
|
|
|
|
|
fixme(...args: [arg?: any, description?: string]) {
|
|
|
|
this._modifier('fixme', args);
|
|
|
|
}
|
|
|
|
|
|
|
|
fail(...args: [arg?: any, description?: string]) {
|
|
|
|
this._modifier('fail', args);
|
|
|
|
}
|
|
|
|
|
|
|
|
slow(...args: [arg?: any, description?: string]) {
|
|
|
|
this._modifier('slow', args);
|
|
|
|
}
|
|
|
|
|
|
|
|
setTimeout(timeout: number) {
|
2022-03-17 09:36:03 -07:00
|
|
|
this._timeoutManager.setTimeout(timeout);
|
2022-01-28 17:39:42 -08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class SkipError extends Error {
|
|
|
|
}
|