chore: replace Zones with AsyncLocalStorage (#30381)
Reference https://github.com/microsoft/playwright/issues/30322
This commit is contained in:
parent
3cea17abb6
commit
73fce8fb98
|
|
@ -19,7 +19,7 @@ import type * as channels from '@protocol/channels';
|
||||||
import { maybeFindValidator, ValidationError, type ValidatorContext } from '../protocol/validator';
|
import { maybeFindValidator, ValidationError, type ValidatorContext } from '../protocol/validator';
|
||||||
import { debugLogger } from '../utils/debugLogger';
|
import { debugLogger } from '../utils/debugLogger';
|
||||||
import type { ExpectZone } from '../utils/stackTrace';
|
import type { ExpectZone } from '../utils/stackTrace';
|
||||||
import { captureRawStack, captureLibraryStackTrace, stringifyStackFrames } from '../utils/stackTrace';
|
import { captureLibraryStackTrace, stringifyStackFrames } from '../utils/stackTrace';
|
||||||
import { isUnderTest } from '../utils';
|
import { isUnderTest } from '../utils';
|
||||||
import { zones } from '../utils/zones';
|
import { zones } from '../utils/zones';
|
||||||
import type { ClientInstrumentation } from './clientInstrumentation';
|
import type { ClientInstrumentation } from './clientInstrumentation';
|
||||||
|
|
@ -161,12 +161,11 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
|
||||||
|
|
||||||
async _wrapApiCall<R>(func: (apiZone: ApiZone) => Promise<R>, isInternal = false): Promise<R> {
|
async _wrapApiCall<R>(func: (apiZone: ApiZone) => Promise<R>, isInternal = false): Promise<R> {
|
||||||
const logger = this._logger;
|
const logger = this._logger;
|
||||||
const stack = captureRawStack();
|
const apiZone = zones.zoneData<ApiZone>('apiZone');
|
||||||
const apiZone = zones.zoneData<ApiZone>('apiZone', stack);
|
|
||||||
if (apiZone)
|
if (apiZone)
|
||||||
return await func(apiZone);
|
return await func(apiZone);
|
||||||
|
|
||||||
const stackTrace = captureLibraryStackTrace(stack);
|
const stackTrace = captureLibraryStackTrace();
|
||||||
let apiName: string | undefined = stackTrace.apiName;
|
let apiName: string | undefined = stackTrace.apiName;
|
||||||
const frames: channels.StackFrame[] = stackTrace.frames;
|
const frames: channels.StackFrame[] = stackTrace.frames;
|
||||||
|
|
||||||
|
|
@ -175,7 +174,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
|
||||||
apiName = undefined;
|
apiName = undefined;
|
||||||
|
|
||||||
// Enclosing zone could have provided the apiName and wallTime.
|
// Enclosing zone could have provided the apiName and wallTime.
|
||||||
const expectZone = zones.zoneData<ExpectZone>('expectZone', stack);
|
const expectZone = zones.zoneData<ExpectZone>('expectZone');
|
||||||
const wallTime = expectZone ? expectZone.wallTime : Date.now();
|
const wallTime = expectZone ? expectZone.wallTime : Date.now();
|
||||||
if (!isInternal && expectZone)
|
if (!isInternal && expectZone)
|
||||||
apiName = expectZone.title;
|
apiName = expectZone.title;
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ import { Tracing } from './tracing';
|
||||||
import { findValidator, ValidationError, type ValidatorContext } from '../protocol/validator';
|
import { findValidator, ValidationError, type ValidatorContext } from '../protocol/validator';
|
||||||
import { createInstrumentation } from './clientInstrumentation';
|
import { createInstrumentation } from './clientInstrumentation';
|
||||||
import type { ClientInstrumentation } from './clientInstrumentation';
|
import type { ClientInstrumentation } from './clientInstrumentation';
|
||||||
import { formatCallLog, rewriteErrorMessage } from '../utils';
|
import { formatCallLog, rewriteErrorMessage, zones } from '../utils';
|
||||||
|
|
||||||
class Root extends ChannelOwner<channels.RootChannel> {
|
class Root extends ChannelOwner<channels.RootChannel> {
|
||||||
constructor(connection: Connection) {
|
constructor(connection: Connection) {
|
||||||
|
|
@ -136,7 +136,9 @@ export class Connection extends EventEmitter {
|
||||||
const metadata: channels.Metadata = { wallTime, apiName, location, internal: !apiName };
|
const metadata: channels.Metadata = { wallTime, apiName, location, internal: !apiName };
|
||||||
if (this._tracingCount && frames && type !== 'LocalUtils')
|
if (this._tracingCount && frames && type !== 'LocalUtils')
|
||||||
this._localUtils?._channel.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {});
|
this._localUtils?._channel.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {});
|
||||||
this.onmessage({ ...message, metadata });
|
// We need to exit zones before calling into the server, otherwise
|
||||||
|
// when we receive events from the server, we would be in an API zone.
|
||||||
|
zones.exitZones(() => this.onmessage({ ...message, metadata }));
|
||||||
return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject, apiName, type, method }));
|
return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject, apiName, type, method }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,8 @@ export function captureRawStack(): RawStack {
|
||||||
return stack.split('\n');
|
return stack.split('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function captureLibraryStackTrace(rawStack?: RawStack): { frames: StackFrame[], apiName: string } {
|
export function captureLibraryStackTrace(): { frames: StackFrame[], apiName: string } {
|
||||||
const stack = rawStack || captureRawStack();
|
const stack = captureRawStack();
|
||||||
|
|
||||||
const isTesting = isUnderTest();
|
const isTesting = isUnderTest();
|
||||||
type ParsedFrame = {
|
type ParsedFrame = {
|
||||||
|
|
|
||||||
|
|
@ -14,88 +14,53 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { RawStack } from './stackTrace';
|
import { AsyncLocalStorage } from 'async_hooks';
|
||||||
import { captureRawStack } from './stackTrace';
|
|
||||||
|
|
||||||
export type ZoneType = 'apiZone' | 'expectZone' | 'stepZone';
|
export type ZoneType = 'apiZone' | 'expectZone' | 'stepZone';
|
||||||
|
|
||||||
class ZoneManager {
|
class ZoneManager {
|
||||||
lastZoneId = 0;
|
private readonly _asyncLocalStorage = new AsyncLocalStorage<Zone<unknown>|undefined>();
|
||||||
readonly _zones = new Map<number, Zone<any>>();
|
|
||||||
|
|
||||||
run<T, R>(type: ZoneType, data: T, func: (data: T) => R): R {
|
run<T, R>(type: ZoneType, data: T, func: (data: T) => R): R {
|
||||||
return new Zone<T>(this, ++this.lastZoneId, type, data).run(func);
|
const previous = this._asyncLocalStorage.getStore();
|
||||||
|
const zone = new Zone(previous, type, data);
|
||||||
|
return this._asyncLocalStorage.run(zone, () => func(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
zoneData<T>(type: ZoneType, rawStack: RawStack): T | null {
|
zoneData<T>(type: ZoneType): T | null {
|
||||||
for (const line of rawStack) {
|
for (let zone = this._asyncLocalStorage.getStore(); zone; zone = zone.previous) {
|
||||||
for (const zoneId of zoneIds(line)) {
|
if (zone.type === type)
|
||||||
const zone = this._zones.get(zoneId);
|
return zone.data as T;
|
||||||
if (zone && zone.type === type)
|
|
||||||
return zone.data;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
preserve<T>(callback: () => Promise<T>): Promise<T> {
|
exitZones<R>(func: () => R): R {
|
||||||
const rawStack = captureRawStack();
|
return this._asyncLocalStorage.run(undefined, func);
|
||||||
const refs: number[] = [];
|
|
||||||
for (const line of rawStack)
|
|
||||||
refs.push(...zoneIds(line));
|
|
||||||
Object.defineProperty(callback, 'name', { value: `__PWZONE__[${refs.join(',')}]-refs` });
|
|
||||||
return callback();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function zoneIds(line: string): number[] {
|
printZones() {
|
||||||
const index = line.indexOf('__PWZONE__[');
|
const zones = [];
|
||||||
if (index === -1)
|
for (let zone = this._asyncLocalStorage.getStore(); zone; zone = zone.previous) {
|
||||||
return [];
|
let str = zone.type;
|
||||||
return line.substring(index + '__PWZONE__['.length, line.indexOf(']', index)).split(',').map(s => +s);
|
if (zone.type === 'apiZone')
|
||||||
|
str += `(${(zone.data as any).apiName})`;
|
||||||
|
zones.push(str);
|
||||||
|
|
||||||
|
}
|
||||||
|
console.log('zones: ', zones.join(' -> '));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Zone<T> {
|
class Zone<T> {
|
||||||
private _manager: ZoneManager;
|
|
||||||
readonly id: number;
|
|
||||||
readonly type: ZoneType;
|
readonly type: ZoneType;
|
||||||
data: T;
|
readonly data: T;
|
||||||
readonly wallTime: number;
|
readonly previous: Zone<unknown> | undefined;
|
||||||
|
|
||||||
constructor(manager: ZoneManager, id: number, type: ZoneType, data: T) {
|
constructor(previous: Zone<unknown> | undefined, type: ZoneType, data: T) {
|
||||||
this._manager = manager;
|
|
||||||
this.id = id;
|
|
||||||
this.type = type;
|
this.type = type;
|
||||||
this.data = data;
|
this.data = data;
|
||||||
this.wallTime = Date.now();
|
this.previous = previous;
|
||||||
}
|
|
||||||
|
|
||||||
run<R>(func: (data: T) => R): R {
|
|
||||||
this._manager._zones.set(this.id, this);
|
|
||||||
Object.defineProperty(func, 'name', { value: `__PWZONE__[${this.id}]-${this.type}` });
|
|
||||||
return runWithFinally(() => func(this.data), () => {
|
|
||||||
this._manager._zones.delete(this.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runWithFinally<R>(func: () => R, finallyFunc: Function): R {
|
|
||||||
try {
|
|
||||||
const result = func();
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
return result.then(r => {
|
|
||||||
finallyFunc();
|
|
||||||
return r;
|
|
||||||
}).catch(e => {
|
|
||||||
finallyFunc();
|
|
||||||
throw e;
|
|
||||||
}) as any;
|
|
||||||
}
|
|
||||||
finallyFunc();
|
|
||||||
return result;
|
|
||||||
} catch (e) {
|
|
||||||
finallyFunc();
|
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -257,8 +257,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
|
||||||
|
|
||||||
// This looks like it is unnecessary, but it isn't - we need to filter
|
// This looks like it is unnecessary, but it isn't - we need to filter
|
||||||
// out all the frames that belong to the test runner from caught runtime errors.
|
// out all the frames that belong to the test runner from caught runtime errors.
|
||||||
const rawStack = captureRawStack();
|
const stackFrames = filteredStackTrace(captureRawStack());
|
||||||
const stackFrames = filteredStackTrace(rawStack);
|
|
||||||
|
|
||||||
// Enclose toPass in a step to maintain async stacks, toPass matcher is always async.
|
// Enclose toPass in a step to maintain async stacks, toPass matcher is always async.
|
||||||
const stepInfo = {
|
const stepInfo = {
|
||||||
|
|
@ -287,7 +286,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
|
||||||
try {
|
try {
|
||||||
const expectZone: ExpectZone | null = matcherName !== 'toPass' ? { title, wallTime } : null;
|
const expectZone: ExpectZone | null = matcherName !== 'toPass' ? { title, wallTime } : null;
|
||||||
const callback = () => matcher.call(target, ...args);
|
const callback = () => matcher.call(target, ...args);
|
||||||
const result = expectZone ? zones.run<ExpectZone, any>('expectZone', expectZone, callback) : zones.preserve(callback);
|
const result = expectZone ? zones.run<ExpectZone, any>('expectZone', expectZone, callback) : callback();
|
||||||
if (result instanceof Promise)
|
if (result instanceof Promise)
|
||||||
return result.then(finalizer).catch(reportStepError);
|
return result.then(finalizer).catch(reportStepError);
|
||||||
finalizer();
|
finalizer();
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import type { Locator, Page } from 'playwright-core';
|
||||||
import type { ExpectScreenshotOptions, Page as PageEx } from 'playwright-core/lib/client/page';
|
import type { ExpectScreenshotOptions, Page as PageEx } from 'playwright-core/lib/client/page';
|
||||||
import { currentTestInfo, currentExpectTimeout } from '../common/globals';
|
import { currentTestInfo, currentExpectTimeout } from '../common/globals';
|
||||||
import type { ImageComparatorOptions, Comparator } from 'playwright-core/lib/utils';
|
import type { ImageComparatorOptions, Comparator } from 'playwright-core/lib/utils';
|
||||||
import { getComparator, sanitizeForFilePath, zones } from 'playwright-core/lib/utils';
|
import { getComparator, sanitizeForFilePath } from 'playwright-core/lib/utils';
|
||||||
import {
|
import {
|
||||||
addSuffixToFilePath,
|
addSuffixToFilePath,
|
||||||
trimLongString, callLogText,
|
trimLongString, callLogText,
|
||||||
|
|
@ -367,19 +367,7 @@ export async function toHaveScreenshot(
|
||||||
if (!helper.snapshotPath.toLowerCase().endsWith('.png'))
|
if (!helper.snapshotPath.toLowerCase().endsWith('.png'))
|
||||||
throw new Error(`Screenshot name "${path.basename(helper.snapshotPath)}" must have '.png' extension`);
|
throw new Error(`Screenshot name "${path.basename(helper.snapshotPath)}" must have '.png' extension`);
|
||||||
expectTypes(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot');
|
expectTypes(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot');
|
||||||
return await zones.preserve(async () => {
|
const style = await loadScreenshotStyles(helper.options.stylePath);
|
||||||
// Loading from filesystem resets zones.
|
|
||||||
const style = await loadScreenshotStyles(helper.options.stylePath);
|
|
||||||
return toHaveScreenshotContinuation.call(this, helper, page, locator, style);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toHaveScreenshotContinuation(
|
|
||||||
this: ExpectMatcherContext,
|
|
||||||
helper: SnapshotHelper,
|
|
||||||
page: PageEx,
|
|
||||||
locator: Locator | undefined,
|
|
||||||
style?: string) {
|
|
||||||
const expectScreenshotOptions: ExpectScreenshotOptions = {
|
const expectScreenshotOptions: ExpectScreenshotOptions = {
|
||||||
locator,
|
locator,
|
||||||
animations: helper.options.animations ?? 'disabled',
|
animations: helper.options.animations ?? 'disabled',
|
||||||
|
|
|
||||||
|
|
@ -246,14 +246,13 @@ export class TestInfoImpl implements TestInfo {
|
||||||
|
|
||||||
_addStep(data: Omit<TestStepInternal, 'complete' | 'stepId' | 'steps'>): TestStepInternal {
|
_addStep(data: Omit<TestStepInternal, 'complete' | 'stepId' | 'steps'>): TestStepInternal {
|
||||||
const stepId = `${data.category}@${++this._lastStepId}`;
|
const stepId = `${data.category}@${++this._lastStepId}`;
|
||||||
const rawStack = captureRawStack();
|
|
||||||
|
|
||||||
let parentStep: TestStepInternal | undefined;
|
let parentStep: TestStepInternal | undefined;
|
||||||
if (data.isStage) {
|
if (data.isStage) {
|
||||||
// Predefined stages form a fixed hierarchy - use the current one as parent.
|
// Predefined stages form a fixed hierarchy - use the current one as parent.
|
||||||
parentStep = this._findLastStageStep();
|
parentStep = this._findLastStageStep();
|
||||||
} else {
|
} else {
|
||||||
parentStep = zones.zoneData<TestStepInternal>('stepZone', rawStack!) || undefined;
|
parentStep = zones.zoneData<TestStepInternal>('stepZone') || undefined;
|
||||||
if (!parentStep && data.category !== 'test.step') {
|
if (!parentStep && data.category !== 'test.step') {
|
||||||
// API steps (but not test.step calls) can be nested by time, instead of by stack.
|
// API steps (but not test.step calls) can be nested by time, instead of by stack.
|
||||||
// However, do not nest chains of route.continue by checking the title.
|
// However, do not nest chains of route.continue by checking the title.
|
||||||
|
|
@ -265,7 +264,7 @@ export class TestInfoImpl implements TestInfo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredStack = filteredStackTrace(rawStack);
|
const filteredStack = filteredStackTrace(captureRawStack());
|
||||||
data.boxedStack = parentStep?.boxedStack;
|
data.boxedStack = parentStep?.boxedStack;
|
||||||
if (!data.boxedStack && data.box) {
|
if (!data.boxedStack && data.box) {
|
||||||
data.boxedStack = filteredStack.slice(1);
|
data.boxedStack = filteredStack.slice(1);
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,15 @@ class Reporter {
|
||||||
this.suite = suite;
|
this.suite = suite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For easier debugging.
|
||||||
|
onStdOut(data) {
|
||||||
|
process.stdout.write(data.toString());
|
||||||
|
}
|
||||||
|
// For easier debugging.
|
||||||
|
onStdErr(data) {
|
||||||
|
process.stderr.write(data.toString());
|
||||||
|
}
|
||||||
|
|
||||||
printStep(step, indent) {
|
printStep(step, indent) {
|
||||||
let location = '';
|
let location = '';
|
||||||
if (step.location)
|
if (step.location)
|
||||||
|
|
@ -867,7 +876,6 @@ test('step inside expect.toPass', async ({ runInlineTest }) => {
|
||||||
}, { reporter: '', workers: 1 });
|
}, { reporter: '', workers: 1 });
|
||||||
|
|
||||||
expect(result.exitCode).toBe(0);
|
expect(result.exitCode).toBe(0);
|
||||||
console.log(result.output);
|
|
||||||
expect(stripAnsi(result.output)).toBe(`
|
expect(stripAnsi(result.output)).toBe(`
|
||||||
hook |Before Hooks
|
hook |Before Hooks
|
||||||
test.step |step 1 @ a.test.ts:4
|
test.step |step 1 @ a.test.ts:4
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue