chore: make stacks rendering live in ui mode (#21728)

Co-authored-by: Max Schmitt <max@schmitt.mx>
This commit is contained in:
Pavel Feldman 2023-03-16 18:17:07 -07:00 committed by GitHub
parent f37f38e553
commit ecd0f927f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 186 additions and 53 deletions

View file

@ -71,7 +71,7 @@ export class Connection extends EventEmitter {
private _localUtils?: LocalUtils;
// Some connections allow resolving in-process dispatchers.
toImpl: ((client: ChannelOwner) => any) | undefined;
private _stackCollectors = new Set<channels.ClientSideCallMetadata[]>();
private _tracingCount = 0;
constructor(localUtils?: LocalUtils) {
super();
@ -103,12 +103,11 @@ export class Connection extends EventEmitter {
return this._objects.get(guid)!;
}
startCollectingCallMetadata(collector: channels.ClientSideCallMetadata[]) {
this._stackCollectors.add(collector);
}
stopCollectingCallMetadata(collector: channels.ClientSideCallMetadata[]) {
this._stackCollectors.delete(collector);
async setIsTracing(isTracing: boolean) {
if (isTracing)
this._tracingCount++;
else
this._tracingCount--;
}
async sendMessageToServer(object: ChannelOwner, type: string, method: string, params: any, stackTrace: ParsedStackTrace | null, wallTime: number | undefined): Promise<any> {
@ -121,12 +120,11 @@ export class Connection extends EventEmitter {
const converted = { id, guid, method, params };
// Do not include metadata in debug logs to avoid noise.
debugLogger.log('channel:command', converted);
for (const collector of this._stackCollectors)
collector.push({ stack: frames, id: id });
const location = frames[0] ? { file: frames[0].file, line: frames[0].line, column: frames[0].column } : undefined;
const metadata: channels.Metadata = { wallTime, apiName, location, internal: !apiName };
this.onmessage({ ...converted, metadata });
if (this._tracingCount && frames && type !== 'LocalUtils')
this._localUtils?._channel.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {});
return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject, stackTrace, type, method }));
}

View file

@ -21,8 +21,9 @@ import { ChannelOwner } from './channelOwner';
export class Tracing extends ChannelOwner<channels.TracingChannel> implements api.Tracing {
private _includeSources = false;
private _metadataCollector: channels.ClientSideCallMetadata[] = [];
_tracesDir: string | undefined;
private _stacksId: string | undefined;
private _isTracing = false;
static from(channel: channels.TracingChannel): Tracing {
return (channel as any)._object;
@ -34,18 +35,26 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
async start(options: { name?: string, title?: string, snapshots?: boolean, screenshots?: boolean, sources?: boolean } = {}) {
this._includeSources = !!options.sources;
await this._wrapApiCall(async () => {
const traceName = await this._wrapApiCall(async () => {
await this._channel.tracingStart(options);
await this._channel.tracingStartChunk({ name: options.name, title: options.title });
const response = await this._channel.tracingStartChunk({ name: options.name, title: options.title });
return response.traceName;
});
this._metadataCollector = [];
this._connection.startCollectingCallMetadata(this._metadataCollector);
await this._startCollectingStacks(traceName);
}
async startChunk(options: { name?: string, title?: string } = {}) {
await this._channel.tracingStartChunk(options);
this._metadataCollector = [];
this._connection.startCollectingCallMetadata(this._metadataCollector);
const { traceName } = await this._channel.tracingStartChunk(options);
await this._startCollectingStacks(traceName);
}
private async _startCollectingStacks(traceName: string) {
if (!this._isTracing) {
this._isTracing = true;
this._connection.setIsTracing(true);
}
const result = await this._connection.localUtils()._channel.tracingStarted({ tracesDir: this._tracesDir, traceName });
this._stacksId = result.stacksId;
}
async stopChunk(options: { path?: string } = {}) {
@ -60,12 +69,16 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
}
private async _doStopChunk(filePath: string | undefined) {
this._connection.stopCollectingCallMetadata(this._metadataCollector);
const metadata = this._metadataCollector;
this._metadataCollector = [];
if (this._isTracing) {
this._isTracing = false;
this._connection.setIsTracing(false);
}
if (!filePath) {
await this._channel.tracingStopChunk({ mode: 'discard' });
// Not interested in artifacts.
await this._channel.tracingStopChunk({ mode: 'discard' });
if (this._stacksId)
await this._connection.localUtils()._channel.traceDiscarded({ stacksId: this._stacksId });
return;
}
@ -73,23 +86,24 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
if (isLocal) {
const result = await this._channel.tracingStopChunk({ mode: 'entries' });
await this._connection.localUtils()._channel.zip({ zipFile: filePath, entries: result.entries!, metadata, mode: 'write', includeSources: this._includeSources });
await this._connection.localUtils()._channel.zip({ zipFile: filePath, entries: result.entries!, mode: 'write', stacksId: this._stacksId, includeSources: this._includeSources });
return;
}
const result = await this._channel.tracingStopChunk({ mode: 'archive' });
// The artifact may be missing if the browser closed while stopping tracing.
if (!result.artifact)
if (!result.artifact) {
if (this._stacksId)
await this._connection.localUtils()._channel.traceDiscarded({ stacksId: this._stacksId });
return;
}
// Save trace to the final local file.
const artifact = Artifact.from(result.artifact);
await artifact.saveAs(filePath);
await artifact.delete();
// Add local sources to the remote trace if necessary.
if (metadata.length)
await this._connection.localUtils()._channel.zip({ zipFile: filePath, entries: [], metadata, mode: 'append', includeSources: this._includeSources });
await this._connection.localUtils()._channel.zip({ zipFile: filePath, entries: [], mode: 'append', stacksId: this._stacksId, includeSources: this._includeSources });
}
}

View file

@ -220,8 +220,8 @@ scheme.LocalUtilsInitializer = tOptional(tObject({}));
scheme.LocalUtilsZipParams = tObject({
zipFile: tString,
entries: tArray(tType('NameValue')),
stacksId: tOptional(tString),
mode: tEnum(['write', 'append']),
metadata: tArray(tType('ClientSideCallMetadata')),
includeSources: tBoolean,
});
scheme.LocalUtilsZipResult = tOptional(tObject({}));
@ -268,6 +268,21 @@ scheme.LocalUtilsConnectParams = tObject({
scheme.LocalUtilsConnectResult = tObject({
pipe: tChannel(['JsonPipe']),
});
scheme.LocalUtilsTracingStartedParams = tObject({
tracesDir: tOptional(tString),
traceName: tString,
});
scheme.LocalUtilsTracingStartedResult = tObject({
stacksId: tString,
});
scheme.LocalUtilsAddStackToTracingNoReplyParams = tObject({
callData: tType('ClientSideCallMetadata'),
});
scheme.LocalUtilsAddStackToTracingNoReplyResult = tOptional(tObject({}));
scheme.LocalUtilsTraceDiscardedParams = tObject({
stacksId: tString,
});
scheme.LocalUtilsTraceDiscardedResult = tOptional(tObject({}));
scheme.RootInitializer = tOptional(tObject({}));
scheme.RootInitializeParams = tObject({
sdkLanguage: tEnum(['javascript', 'python', 'java', 'csharp']),
@ -2095,7 +2110,9 @@ scheme.TracingTracingStartChunkParams = tObject({
name: tOptional(tString),
title: tOptional(tString),
});
scheme.TracingTracingStartChunkResult = tOptional(tObject({}));
scheme.TracingTracingStartChunkResult = tObject({
traceName: tString,
});
scheme.TracingTracingStopChunkParams = tObject({
mode: tEnum(['archive', 'discard', 'entries']),
});

View file

@ -17,9 +17,10 @@
import type EventEmitter from 'events';
import fs from 'fs';
import path from 'path';
import os from 'os';
import type * as channels from '@protocol/channels';
import { ManualPromise } from '../../utils/manualPromise';
import { assert, calculateSha1, createGuid } from '../../utils';
import { assert, calculateSha1, createGuid, removeFolders } from '../../utils';
import type { RootDispatcher } from './dispatcher';
import { Dispatcher } from './dispatcher';
import { yazl, yauzl } from '../../zipBundle';
@ -42,7 +43,13 @@ import { serializeClientSideCallMetadata } from '../../utils';
export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel {
_type_LocalUtils: boolean;
private _harBakends = new Map<string, HarBackend>();
private _harBackends = new Map<string, HarBackend>();
private _stackSessions = new Map<string, {
file: string,
writer: Promise<void>,
tmpDir: string | undefined,
callStacks: channels.ClientSideCallMetadata[]
}>();
constructor(scope: RootDispatcher, playwright: Playwright) {
const localUtils = new SdkObject(playwright, 'localUtils', 'localUtils');
@ -67,12 +74,21 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
addFile(entry.value, entry.name);
// Add stacks and the sources.
zipFile.addBuffer(Buffer.from(JSON.stringify(serializeClientSideCallMetadata(params.metadata))), 'trace.stacks');
const stackSession = params.stacksId ? this._stackSessions.get(params.stacksId) : undefined;
if (stackSession?.callStacks.length) {
await stackSession.writer;
if (process.env.PW_LIVE_TRACE_STACKS) {
zipFile.addFile(stackSession.file, 'trace.stacks');
} else {
const buffer = Buffer.from(JSON.stringify(serializeClientSideCallMetadata(stackSession.callStacks)));
zipFile.addBuffer(buffer, 'trace.stacks');
}
}
// Collect sources from stacks.
if (params.includeSources) {
const sourceFiles = new Set<string>();
for (const { stack } of params.metadata) {
for (const { stack } of stackSession?.callStacks || []) {
if (!stack)
continue;
for (const { file } of stack)
@ -88,7 +104,9 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
zipFile.end(undefined, () => {
zipFile.outputStream.pipe(fs.createWriteStream(params.zipFile)).on('close', () => promise.resolve());
});
return promise;
await promise;
await this._deleteStackSession(params.stacksId);
return;
}
// File already exists. Repack and add new entries.
@ -121,7 +139,8 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
});
});
});
return promise;
await promise;
await this._deleteStackSession(params.stacksId);
}
async harOpen(params: channels.LocalUtilsHarOpenParams, metadata: CallMetadata): Promise<channels.LocalUtilsHarOpenResult> {
@ -139,21 +158,21 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
const harFile = JSON.parse(await fs.promises.readFile(params.file, 'utf-8')) as har.HARFile;
harBackend = new HarBackend(harFile, path.dirname(params.file), null);
}
this._harBakends.set(harBackend.id, harBackend);
this._harBackends.set(harBackend.id, harBackend);
return { harId: harBackend.id };
}
async harLookup(params: channels.LocalUtilsHarLookupParams, metadata: CallMetadata): Promise<channels.LocalUtilsHarLookupResult> {
const harBackend = this._harBakends.get(params.harId);
const harBackend = this._harBackends.get(params.harId);
if (!harBackend)
return { action: 'error', message: `Internal error: har was not opened` };
return await harBackend.lookup(params.url, params.method, params.headers, params.postData, params.isNavigationRequest);
}
async harClose(params: channels.LocalUtilsHarCloseParams, metadata: CallMetadata): Promise<void> {
const harBackend = this._harBakends.get(params.harId);
const harBackend = this._harBackends.get(params.harId);
if (harBackend) {
this._harBakends.delete(harBackend.id);
this._harBackends.delete(harBackend.id);
harBackend.dispose();
}
}
@ -213,6 +232,40 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
}, params.timeout || 0);
}
async tracingStarted(params: channels.LocalUtilsTracingStartedParams, metadata?: CallMetadata | undefined): Promise<channels.LocalUtilsTracingStartedResult> {
let tmpDir = undefined;
if (!params.tracesDir)
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-tracing-'));
const traceStacksFile = path.join(params.tracesDir || tmpDir!, params.traceName + '.stacks');
this._stackSessions.set(traceStacksFile, { callStacks: [], file: traceStacksFile, writer: Promise.resolve(), tmpDir });
return { stacksId: traceStacksFile };
}
async traceDiscarded(params: channels.LocalUtilsTraceDiscardedParams, metadata?: CallMetadata | undefined): Promise<void> {
await this._deleteStackSession(params.stacksId);
}
async addStackToTracingNoReply(params: channels.LocalUtilsAddStackToTracingNoReplyParams, metadata?: CallMetadata | undefined): Promise<void> {
for (const session of this._stackSessions.values()) {
session.callStacks.push(params.callData);
if (process.env.PW_LIVE_TRACE_STACKS) {
session.writer = session.writer.then(() => {
const buffer = Buffer.from(JSON.stringify(serializeClientSideCallMetadata(session.callStacks)));
return fs.promises.writeFile(session.file, buffer);
});
}
}
}
private async _deleteStackSession(stacksId?: string) {
const session = stacksId ? this._stackSessions.get(stacksId) : undefined;
if (!session)
return;
await session.writer;
if (session.tmpDir)
await removeFolders([session.tmpDir]);
this._stackSessions.delete(stacksId!);
}
}
const redirectStatus = [301, 302, 303, 307, 308];

View file

@ -38,7 +38,7 @@ export class TracingDispatcher extends Dispatcher<Tracing, channels.TracingChann
}
async tracingStartChunk(params: channels.TracingTracingStartChunkParams): Promise<channels.TracingTracingStartChunkResult> {
await this._object.startChunk(params);
return await this._object.startChunk(params);
}
async tracingStopChunk(params: channels.TracingTracingStopChunkParams): Promise<channels.TracingTracingStopChunkResult> {

View file

@ -58,7 +58,7 @@ type RecordingState = {
traceFile: string,
tracesDir: string,
resourcesDir: string,
filesCount: number,
chunkOrdinal: number,
networkSha1s: Set<string>,
traceSha1s: Set<string>,
recording: boolean;
@ -132,7 +132,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
// and conflict.
const traceName = options.name || createGuid();
// Init the state synchronously.
this._state = { options, traceName, traceFile: '', networkFile: '', tracesDir: '', resourcesDir: '', filesCount: 0, traceSha1s: new Set(), networkSha1s: new Set(), recording: false };
this._state = { options, traceName, traceFile: '', networkFile: '', tracesDir: '', resourcesDir: '', chunkOrdinal: 0, traceSha1s: new Set(), networkSha1s: new Set(), recording: false };
const state = this._state;
state.tracesDir = await this._createTracesDirIfNeeded();
@ -144,7 +144,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
this._harTracer.start();
}
async startChunk(options: { name?: string, title?: string } = {}) {
async startChunk(options: { name?: string, title?: string } = {}): Promise<{ traceName: string }> {
if (this._state && this._state.recording)
await this.stopChunk({ mode: 'discard' });
@ -154,13 +154,14 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
throw new Error('Cannot start a trace chunk while stopping');
const state = this._state;
const suffix = state.filesCount ? `-${state.filesCount}` : ``;
state.filesCount++;
const suffix = state.chunkOrdinal ? `-${state.chunkOrdinal}` : ``;
state.chunkOrdinal++;
state.traceFile = path.join(state.tracesDir, `${state.traceName}${suffix}.trace`);
state.recording = true;
if (options.name && options.name !== this._state.traceName)
this._changeTraceName(this._state, options.name);
this._appendTraceOperation(async () => {
await mkdirIfNeeded(state.traceFile);
await fs.promises.appendFile(state.traceFile, JSON.stringify({ ...this._contextCreatedEvent, title: options.title, wallTime: Date.now() }) + '\n');
@ -171,6 +172,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
this._startScreencast();
if (state.options.snapshots)
await this._snapshotter?.start();
return { traceName: state.traceName };
}
private _startScreencast() {
@ -194,6 +196,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
private async _changeTraceName(state: RecordingState, name: string) {
await this._appendTraceOperation(async () => {
const oldNetworkFile = state.networkFile;
state.traceName = name;
state.traceFile = path.join(state.tracesDir, name + '.trace');
state.networkFile = path.join(state.tracesDir, name + '.network');
// Network file survives across chunks, so make a copy with the new name.
@ -258,7 +261,8 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
return {};
// Network file survives across chunks, make a snapshot before returning the resulting entries.
const networkFile = path.join(state.networkFile, '..', createGuid());
const suffix = state.chunkOrdinal ? `-${state.chunkOrdinal}` : ``;
const networkFile = path.join(state.tracesDir, state.traceName + `${suffix}.network`);
await fs.promises.copyFile(state.networkFile, networkFile);
const entries: NameValue[] = [];

View file

@ -18,7 +18,7 @@ import * as fs from 'fs';
import * as path from 'path';
import type { APIRequestContext, BrowserContext, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core';
import * as playwrightLibrary from 'playwright-core';
import { createGuid, debugMode, removeFolders, addInternalStackPrefix, mergeTraceFiles, saveTraceFile } from 'playwright-core/lib/utils';
import { createGuid, debugMode, addInternalStackPrefix, mergeTraceFiles, saveTraceFile, removeFolders } from 'playwright-core/lib/utils';
import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, TraceMode, VideoMode } from '../types/test';
import type { TestInfoImpl } from './worker/testInfo';
import { rootTestType } from './common/testType';

View file

@ -39,6 +39,7 @@ class UIMode {
constructor(config: FullConfigInternal) {
this._config = config;
process.env.PW_LIVE_TRACE_STACKS = '1';
config._internal.configCLIOverrides.forbidOnly = false;
config._internal.configCLIOverrides.globalTimeout = 0;
config._internal.configCLIOverrides.repeatEach = 0;

View file

@ -402,16 +402,19 @@ export interface LocalUtilsChannel extends LocalUtilsEventTarget, Channel {
harClose(params: LocalUtilsHarCloseParams, metadata?: CallMetadata): Promise<LocalUtilsHarCloseResult>;
harUnzip(params: LocalUtilsHarUnzipParams, metadata?: CallMetadata): Promise<LocalUtilsHarUnzipResult>;
connect(params: LocalUtilsConnectParams, metadata?: CallMetadata): Promise<LocalUtilsConnectResult>;
tracingStarted(params: LocalUtilsTracingStartedParams, metadata?: CallMetadata): Promise<LocalUtilsTracingStartedResult>;
addStackToTracingNoReply(params: LocalUtilsAddStackToTracingNoReplyParams, metadata?: CallMetadata): Promise<LocalUtilsAddStackToTracingNoReplyResult>;
traceDiscarded(params: LocalUtilsTraceDiscardedParams, metadata?: CallMetadata): Promise<LocalUtilsTraceDiscardedResult>;
}
export type LocalUtilsZipParams = {
zipFile: string,
entries: NameValue[],
stacksId?: string,
mode: 'write' | 'append',
metadata: ClientSideCallMetadata[],
includeSources: boolean,
};
export type LocalUtilsZipOptions = {
stacksId?: string,
};
export type LocalUtilsZipResult = void;
export type LocalUtilsHarOpenParams = {
@ -476,6 +479,30 @@ export type LocalUtilsConnectOptions = {
export type LocalUtilsConnectResult = {
pipe: JsonPipeChannel,
};
export type LocalUtilsTracingStartedParams = {
tracesDir?: string,
traceName: string,
};
export type LocalUtilsTracingStartedOptions = {
tracesDir?: string,
};
export type LocalUtilsTracingStartedResult = {
stacksId: string,
};
export type LocalUtilsAddStackToTracingNoReplyParams = {
callData: ClientSideCallMetadata,
};
export type LocalUtilsAddStackToTracingNoReplyOptions = {
};
export type LocalUtilsAddStackToTracingNoReplyResult = void;
export type LocalUtilsTraceDiscardedParams = {
stacksId: string,
};
export type LocalUtilsTraceDiscardedOptions = {
};
export type LocalUtilsTraceDiscardedResult = void;
export interface LocalUtilsEvents {
}
@ -3756,7 +3783,9 @@ export type TracingTracingStartChunkOptions = {
name?: string,
title?: string,
};
export type TracingTracingStartChunkResult = void;
export type TracingTracingStartChunkResult = {
traceName: string,
};
export type TracingTracingStopChunkParams = {
mode: 'archive' | 'discard' | 'entries',
};

View file

@ -500,14 +500,12 @@ LocalUtils:
entries:
type: array
items: NameValue
stacksId: string?
mode:
type: enum
literals:
- write
- append
metadata:
type: array
items: ClientSideCallMetadata
includeSources: boolean
harOpen:
@ -563,6 +561,21 @@ LocalUtils:
returns:
pipe: JsonPipe
tracingStarted:
parameters:
tracesDir: string?
traceName: string
returns:
stacksId: string
addStackToTracingNoReply:
parameters:
callData: ClientSideCallMetadata
traceDiscarded:
parameters:
stacksId: string
Root:
type: interface
@ -2927,6 +2940,8 @@ Tracing:
parameters:
name: string?
title: string?
returns:
traceName: string
tracingStopChunk:
parameters:

View file

@ -18,6 +18,8 @@ import { test, expect } from './playwright-test-fixtures';
import { parseTrace } from '../config/utils';
import fs from 'fs';
test.describe.configure({ mode: 'parallel' });
test('should stop tracing with trace: on-first-retry, when not retrying', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'playwright.config.ts': `