mirror of
https://github.com/microsoft/playwright.git
synced 2024-08-16 23:50:33 +03:00
feat(cli): bring in trace viewer (#4920)
This commit is contained in:
parent
54c06a1b45
commit
2e05feac25
@ -5,6 +5,9 @@ module.exports = {
|
||||
ecmaVersion: 9,
|
||||
sourceType: 'module',
|
||||
},
|
||||
extends: [
|
||||
'plugin:react-hooks/recommended'
|
||||
],
|
||||
|
||||
/**
|
||||
* ESLint rules
|
||||
|
807
package-lock.json
generated
807
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -58,22 +58,32 @@
|
||||
"@types/progress": "^2.0.3",
|
||||
"@types/proper-lockfile": "^4.1.1",
|
||||
"@types/proxy-from-env": "^1.0.1",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@types/resize-observer-browser": "^0.1.4",
|
||||
"@types/rimraf": "^3.0.0",
|
||||
"@types/ws": "7.2.6",
|
||||
"@typescript-eslint/eslint-plugin": "^3.10.1",
|
||||
"@typescript-eslint/parser": "^3.10.1",
|
||||
"chokidar": "^3.5.0",
|
||||
"css-loader": "^4.3.0",
|
||||
"colors": "^1.4.0",
|
||||
"commonmark": "^0.29.1",
|
||||
"cross-env": "^7.0.2",
|
||||
"electron": "^9.2.1",
|
||||
"eslint": "^7.7.0",
|
||||
"eslint-plugin-notice": "^0.9.10",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"file-loader": "^6.1.0",
|
||||
"folio": "=0.3.16",
|
||||
"formidable": "^1.2.2",
|
||||
"html-webpack-plugin": "^4.4.1",
|
||||
"ncp": "^2.0.0",
|
||||
"node-stream-zip": "^1.11.3",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"socksv5": "0.0.6",
|
||||
"style-loader": "^1.2.1",
|
||||
"ts-loader": "^8.0.3",
|
||||
"typescript": "^4.0.2",
|
||||
"webpack": "^4.44.2",
|
||||
|
@ -30,6 +30,7 @@ import { PythonLanguageGenerator } from './codegen/languages/python';
|
||||
import { CSharpLanguageGenerator } from './codegen/languages/csharp';
|
||||
import { RecorderController } from './codegen/recorderController';
|
||||
import { runServer, printApiJson, installBrowsers } from './driver';
|
||||
import { showTraceViewer } from './traceViewer/traceViewer';
|
||||
import type { Browser, BrowserContext, Page, BrowserType, BrowserContextOptions, LaunchOptions } from '../..';
|
||||
import * as playwright from '../..';
|
||||
|
||||
@ -136,6 +137,22 @@ program
|
||||
});
|
||||
});
|
||||
|
||||
if (process.env.PWTRACE) {
|
||||
program
|
||||
.command('show-trace <trace>')
|
||||
.description('Show trace viewer')
|
||||
.option('--resources <dir>', 'Directory with the shared trace artifacts')
|
||||
.action(function(trace, command) {
|
||||
showTraceViewer(command.resources, trace);
|
||||
}).on('--help', function() {
|
||||
console.log('');
|
||||
console.log('Examples:');
|
||||
console.log('');
|
||||
console.log(' $ show-trace --resources=resources trace/file.trace');
|
||||
console.log(' $ show-trace trace/directory');
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[2] === 'run-driver')
|
||||
runServer();
|
||||
else if (process.argv[2] === 'print-api-json')
|
||||
|
@ -20,6 +20,7 @@ const InlineSource = require('../../server/injected/webpack-inline-source-plugin
|
||||
module.exports = {
|
||||
entry: path.join(__dirname, 'recorder.ts'),
|
||||
devtool: 'source-map',
|
||||
mode: 'development',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
|
136
src/cli/traceViewer/screenshotGenerator.ts
Normal file
136
src/cli/traceViewer/screenshotGenerator.ts
Normal file
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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 * as playwright from '../../..';
|
||||
import * as util from 'util';
|
||||
import { SnapshotRouter } from './snapshotRouter';
|
||||
import { actionById, ActionEntry, ContextEntry, TraceModel } from './traceModel';
|
||||
import type { PageSnapshot } from '../../trace/traceTypes';
|
||||
|
||||
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
|
||||
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
|
||||
|
||||
export class ScreenshotGenerator {
|
||||
private _traceStorageDir: string;
|
||||
private _browserPromise: Promise<playwright.Browser> | undefined;
|
||||
private _traceModel: TraceModel;
|
||||
private _rendering = new Map<ActionEntry, Promise<Buffer | undefined>>();
|
||||
|
||||
constructor(traceStorageDir: string, traceModel: TraceModel) {
|
||||
this._traceStorageDir = traceStorageDir;
|
||||
this._traceModel = traceModel;
|
||||
}
|
||||
|
||||
async generateScreenshot(actionId: string): Promise<Buffer | undefined> {
|
||||
const { context, action } = actionById(this._traceModel, actionId);
|
||||
if (!action.action.snapshot)
|
||||
return;
|
||||
const imageFileName = path.join(this._traceStorageDir, action.action.snapshot.sha1 + '-thumbnail.png');
|
||||
|
||||
let body: Buffer | undefined;
|
||||
try {
|
||||
body = await fsReadFileAsync(imageFileName);
|
||||
} catch (e) {
|
||||
if (!this._rendering.has(action)) {
|
||||
this._rendering.set(action, this._render(context, action, imageFileName).then(body => {
|
||||
this._rendering.delete(action);
|
||||
return body;
|
||||
}));
|
||||
}
|
||||
body = await this._rendering.get(action)!;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private _browser() {
|
||||
if (!this._browserPromise)
|
||||
this._browserPromise = playwright.chromium.launch();
|
||||
return this._browserPromise;
|
||||
}
|
||||
|
||||
private async _render(contextEntry: ContextEntry, actionEntry: ActionEntry, imageFileName: string): Promise<Buffer | undefined> {
|
||||
const { action } = actionEntry;
|
||||
const browser = await this._browser();
|
||||
const page = await browser.newPage({
|
||||
viewport: contextEntry.created.viewportSize,
|
||||
deviceScaleFactor: contextEntry.created.deviceScaleFactor
|
||||
});
|
||||
|
||||
try {
|
||||
const snapshotPath = path.join(this._traceStorageDir, action.snapshot!.sha1);
|
||||
let snapshot;
|
||||
try {
|
||||
snapshot = await fsReadFileAsync(snapshotPath, 'utf8');
|
||||
} catch (e) {
|
||||
console.log(`Unable to read snapshot at ${snapshotPath}`); // eslint-disable-line no-console
|
||||
return;
|
||||
}
|
||||
const snapshotObject = JSON.parse(snapshot) as PageSnapshot;
|
||||
const snapshotRouter = new SnapshotRouter(this._traceStorageDir);
|
||||
snapshotRouter.selectSnapshot(snapshotObject, contextEntry);
|
||||
page.route('**/*', route => snapshotRouter.route(route));
|
||||
const url = snapshotObject.frames[0].url;
|
||||
console.log('Generating screenshot for ' + action.action, snapshotObject.frames[0].url); // eslint-disable-line no-console
|
||||
await page.goto(url);
|
||||
|
||||
let clip: any = undefined;
|
||||
const element = await page.$(action.selector || '*[__playwright_target__]');
|
||||
if (element) {
|
||||
await element.evaluate(e => {
|
||||
e.style.backgroundColor = '#ff69b460';
|
||||
});
|
||||
|
||||
clip = await element.boundingBox() || undefined;
|
||||
if (clip) {
|
||||
const thumbnailSize = {
|
||||
width: 400,
|
||||
height: 200
|
||||
};
|
||||
const insets = {
|
||||
width: 60,
|
||||
height: 30
|
||||
};
|
||||
clip.width = Math.min(thumbnailSize.width, clip.width);
|
||||
clip.height = Math.min(thumbnailSize.height, clip.height);
|
||||
if (clip.width < thumbnailSize.width) {
|
||||
clip.x -= (thumbnailSize.width - clip.width) / 2;
|
||||
clip.x = Math.max(0, clip.x);
|
||||
clip.width = thumbnailSize.width;
|
||||
} else {
|
||||
clip.x = Math.max(0, clip.x - insets.width);
|
||||
}
|
||||
if (clip.height < thumbnailSize.height) {
|
||||
clip.y -= (thumbnailSize.height - clip.height) / 2;
|
||||
clip.y = Math.max(0, clip.y);
|
||||
clip.height = thumbnailSize.height;
|
||||
} else {
|
||||
clip.y = Math.max(0, clip.y - insets.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const imageData = await page.screenshot({ clip });
|
||||
await fsWriteFileAsync(imageFileName, imageData);
|
||||
return imageData;
|
||||
} catch (e) {
|
||||
console.log(e); // eslint-disable-line no-console
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
}
|
122
src/cli/traceViewer/snapshotRouter.ts
Normal file
122
src/cli/traceViewer/snapshotRouter.ts
Normal file
@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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 * as util from 'util';
|
||||
import type { Route } from '../../..';
|
||||
import type { FrameSnapshot, NetworkResourceTraceEvent, PageSnapshot } from '../../trace/traceTypes';
|
||||
import { ContextEntry } from './traceModel';
|
||||
|
||||
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
|
||||
|
||||
export class SnapshotRouter {
|
||||
private _contextEntry: ContextEntry | undefined;
|
||||
private _unknownUrls = new Set<string>();
|
||||
private _traceStorageDir: string;
|
||||
private _frameBySrc = new Map<string, FrameSnapshot>();
|
||||
|
||||
constructor(traceStorageDir: string) {
|
||||
this._traceStorageDir = traceStorageDir;
|
||||
}
|
||||
|
||||
selectSnapshot(snapshot: PageSnapshot, contextEntry: ContextEntry) {
|
||||
this._frameBySrc.clear();
|
||||
this._contextEntry = contextEntry;
|
||||
for (const frameSnapshot of snapshot.frames)
|
||||
this._frameBySrc.set(frameSnapshot.url, frameSnapshot);
|
||||
}
|
||||
|
||||
async route(route: Route) {
|
||||
const url = route.request().url();
|
||||
if (this._frameBySrc.has(url)) {
|
||||
const frameSnapshot = this._frameBySrc.get(url)!;
|
||||
route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: Buffer.from(frameSnapshot.html),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const frameSrc = route.request().frame().url();
|
||||
const frameSnapshot = this._frameBySrc.get(frameSrc);
|
||||
if (!frameSnapshot)
|
||||
return this._routeUnknown(route);
|
||||
|
||||
// Find a matching resource from the same context, preferrably from the same frame.
|
||||
// Note: resources are stored without hash, but page may reference them with hash.
|
||||
let resource: NetworkResourceTraceEvent | null = null;
|
||||
const resourcesWithUrl = this._contextEntry!.resourcesByUrl.get(removeHash(url)) || [];
|
||||
for (const resourceEvent of resourcesWithUrl) {
|
||||
if (resource && resourceEvent.frameId !== frameSnapshot.frameId)
|
||||
continue;
|
||||
resource = resourceEvent;
|
||||
if (resourceEvent.frameId === frameSnapshot.frameId)
|
||||
break;
|
||||
}
|
||||
if (!resource)
|
||||
return this._routeUnknown(route);
|
||||
|
||||
// This particular frame might have a resource content override, for example when
|
||||
// stylesheet is modified using CSSOM.
|
||||
const resourceOverride = frameSnapshot.resourceOverrides.find(o => o.url === url);
|
||||
const overrideSha1 = resourceOverride ? resourceOverride.sha1 : undefined;
|
||||
const resourceData = await this._readResource(resource, overrideSha1);
|
||||
if (!resourceData)
|
||||
return this._routeUnknown(route);
|
||||
const headers: { [key: string]: string } = {};
|
||||
for (const { name, value } of resourceData.headers)
|
||||
headers[name] = value;
|
||||
headers['Access-Control-Allow-Origin'] = '*';
|
||||
route.fulfill({
|
||||
contentType: resourceData.contentType,
|
||||
body: resourceData.body,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
private _routeUnknown(route: Route) {
|
||||
const url = route.request().url();
|
||||
if (!this._unknownUrls.has(url)) {
|
||||
console.log(`Request to unknown url: ${url}`); /* eslint-disable-line no-console */
|
||||
this._unknownUrls.add(url);
|
||||
}
|
||||
route.abort();
|
||||
}
|
||||
|
||||
private async _readResource(event: NetworkResourceTraceEvent, overrideSha1: string | undefined) {
|
||||
try {
|
||||
const body = await fsReadFileAsync(path.join(this._traceStorageDir, overrideSha1 || event.sha1));
|
||||
return {
|
||||
contentType: event.contentType,
|
||||
body,
|
||||
headers: event.responseHeaders,
|
||||
};
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeHash(url: string) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
u.hash = '';
|
||||
return u.toString();
|
||||
} catch (e) {
|
||||
return url;
|
||||
}
|
||||
}
|
154
src/cli/traceViewer/traceModel.ts
Normal file
154
src/cli/traceViewer/traceModel.ts
Normal file
@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 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 trace from '../../trace/traceTypes';
|
||||
|
||||
export type TraceModel = {
|
||||
contexts: ContextEntry[];
|
||||
}
|
||||
|
||||
export type ContextEntry = {
|
||||
name: string;
|
||||
filePath: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
created: trace.ContextCreatedTraceEvent;
|
||||
destroyed: trace.ContextDestroyedTraceEvent;
|
||||
pages: PageEntry[];
|
||||
resourcesByUrl: Map<string, trace.NetworkResourceTraceEvent[]>;
|
||||
}
|
||||
|
||||
export type VideoEntry = {
|
||||
video: trace.PageVideoTraceEvent;
|
||||
videoId: string;
|
||||
};
|
||||
|
||||
export type PageEntry = {
|
||||
created: trace.PageCreatedTraceEvent;
|
||||
destroyed: trace.PageDestroyedTraceEvent;
|
||||
video?: VideoEntry;
|
||||
actions: ActionEntry[];
|
||||
resources: trace.NetworkResourceTraceEvent[];
|
||||
}
|
||||
|
||||
export type ActionEntry = {
|
||||
actionId: string;
|
||||
action: trace.ActionTraceEvent;
|
||||
resources: trace.NetworkResourceTraceEvent[];
|
||||
};
|
||||
|
||||
export type VideoMetaInfo = {
|
||||
frames: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
|
||||
export function readTraceFile(events: trace.TraceEvent[], traceModel: TraceModel, filePath: string) {
|
||||
const contextEntries = new Map<string, ContextEntry>();
|
||||
const pageEntries = new Map<string, PageEntry>();
|
||||
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
case 'context-created': {
|
||||
contextEntries.set(event.contextId, {
|
||||
filePath,
|
||||
name: filePath.substring(filePath.lastIndexOf('/') + 1),
|
||||
startTime: Number.MAX_VALUE,
|
||||
endTime: Number.MIN_VALUE,
|
||||
created: event,
|
||||
destroyed: undefined as any,
|
||||
pages: [],
|
||||
resourcesByUrl: new Map(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'context-destroyed': {
|
||||
contextEntries.get(event.contextId)!.destroyed = event;
|
||||
break;
|
||||
}
|
||||
case 'page-created': {
|
||||
const pageEntry: PageEntry = {
|
||||
created: event,
|
||||
destroyed: undefined as any,
|
||||
actions: [],
|
||||
resources: [],
|
||||
};
|
||||
pageEntries.set(event.pageId, pageEntry);
|
||||
contextEntries.get(event.contextId)!.pages.push(pageEntry);
|
||||
break;
|
||||
}
|
||||
case 'page-destroyed': {
|
||||
pageEntries.get(event.pageId)!.destroyed = event;
|
||||
break;
|
||||
}
|
||||
case 'page-video': {
|
||||
const pageEntry = pageEntries.get(event.pageId)!;
|
||||
pageEntry.video = { video: event, videoId: event.contextId + '/' + event.pageId };
|
||||
break;
|
||||
}
|
||||
case 'action': {
|
||||
const pageEntry = pageEntries.get(event.pageId!)!;
|
||||
const action: ActionEntry = {
|
||||
actionId: event.contextId + '/' + event.pageId + '/' + pageEntry.actions.length,
|
||||
action: event,
|
||||
resources: pageEntry.resources,
|
||||
};
|
||||
pageEntry.resources = [];
|
||||
pageEntry.actions.push(action);
|
||||
break;
|
||||
}
|
||||
case 'resource': {
|
||||
const contextEntry = contextEntries.get(event.contextId)!;
|
||||
const pageEntry = pageEntries.get(event.pageId!)!;
|
||||
const action = pageEntry.actions[pageEntry.actions.length - 1];
|
||||
if (action)
|
||||
action.resources.push(event);
|
||||
else
|
||||
pageEntry.resources.push(event);
|
||||
let responseEvents = contextEntry.resourcesByUrl.get(event.url);
|
||||
if (!responseEvents) {
|
||||
responseEvents = [];
|
||||
contextEntry.resourcesByUrl.set(event.url, responseEvents);
|
||||
}
|
||||
responseEvents.push(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const contextEntry = contextEntries.get(event.contextId)!;
|
||||
contextEntry.startTime = Math.min(contextEntry.startTime, (event as any).timestamp);
|
||||
contextEntry.endTime = Math.max(contextEntry.endTime, (event as any).timestamp);
|
||||
}
|
||||
traceModel.contexts.push(...contextEntries.values());
|
||||
}
|
||||
|
||||
export function actionById(traceModel: TraceModel, actionId: string): { context: ContextEntry, page: PageEntry, action: ActionEntry } {
|
||||
const [contextId, pageId, actionIndex] = actionId.split('/');
|
||||
const context = traceModel.contexts.find(entry => entry.created.contextId === contextId)!;
|
||||
const page = context.pages.find(entry => entry.created.pageId === pageId)!;
|
||||
const action = page.actions[+actionIndex];
|
||||
return { context, page, action };
|
||||
}
|
||||
|
||||
export function videoById(traceModel: TraceModel, videoId: string): { context: ContextEntry, page: PageEntry } {
|
||||
const [contextId, pageId] = videoId.split('/');
|
||||
const context = traceModel.contexts.find(entry => entry.created.contextId === contextId)!;
|
||||
const page = context.pages.find(entry => entry.created.pageId === pageId)!;
|
||||
return { context, page };
|
||||
}
|
181
src/cli/traceViewer/traceViewer.ts
Normal file
181
src/cli/traceViewer/traceViewer.ts
Normal file
@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 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 * as playwright from '../../..';
|
||||
import * as util from 'util';
|
||||
import { ScreenshotGenerator } from './screenshotGenerator';
|
||||
import { SnapshotRouter } from './snapshotRouter';
|
||||
import { readTraceFile, TraceModel } from './traceModel';
|
||||
import type { ActionTraceEvent, PageSnapshot, TraceEvent } from '../../trace/traceTypes';
|
||||
import { VideoTileGenerator } from './videoTileGenerator';
|
||||
|
||||
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
|
||||
|
||||
class TraceViewer {
|
||||
private _traceStorageDir: string;
|
||||
private _traceModel: TraceModel;
|
||||
private _snapshotRouter: SnapshotRouter;
|
||||
private _screenshotGenerator: ScreenshotGenerator;
|
||||
private _videoTileGenerator: VideoTileGenerator;
|
||||
|
||||
constructor(traceStorageDir: string) {
|
||||
this._traceStorageDir = traceStorageDir;
|
||||
this._snapshotRouter = new SnapshotRouter(traceStorageDir);
|
||||
this._traceModel = {
|
||||
contexts: [],
|
||||
};
|
||||
this._screenshotGenerator = new ScreenshotGenerator(traceStorageDir, this._traceModel);
|
||||
this._videoTileGenerator = new VideoTileGenerator(this._traceModel);
|
||||
}
|
||||
|
||||
async load(filePath: string) {
|
||||
const traceContent = await fsReadFileAsync(filePath, 'utf8');
|
||||
const events = traceContent.split('\n').map(line => line.trim()).filter(line => !!line).map(line => JSON.parse(line)) as TraceEvent[];
|
||||
readTraceFile(events, this._traceModel, filePath);
|
||||
}
|
||||
|
||||
async show() {
|
||||
const browser = await playwright.chromium.launch({ headless: false });
|
||||
const uiPage = await browser.newPage({ viewport: null });
|
||||
uiPage.on('close', () => process.exit(0));
|
||||
await uiPage.exposeBinding('readFile', async (_, path: string) => {
|
||||
return fs.readFileSync(path).toString();
|
||||
});
|
||||
await uiPage.exposeBinding('renderSnapshot', async (_, action: ActionTraceEvent) => {
|
||||
try {
|
||||
if (!action.snapshot) {
|
||||
const snapshotFrame = uiPage.frames()[1];
|
||||
await snapshotFrame.goto('data:text/html,No snapshot available');
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await fsReadFileAsync(path.join(this._traceStorageDir, action.snapshot!.sha1), 'utf8');
|
||||
const snapshotObject = JSON.parse(snapshot) as PageSnapshot;
|
||||
const contextEntry = this._traceModel.contexts.find(entry => entry.created.contextId === action.contextId)!;
|
||||
this._snapshotRouter.selectSnapshot(snapshotObject, contextEntry);
|
||||
|
||||
// TODO: fix Playwright bug where frame.name is lost (empty).
|
||||
const snapshotFrame = uiPage.frames()[1];
|
||||
try {
|
||||
await snapshotFrame.goto(snapshotObject.frames[0].url);
|
||||
} catch (e) {
|
||||
if (!e.message.includes('frame was detached'))
|
||||
console.error(e);
|
||||
return;
|
||||
}
|
||||
const element = await snapshotFrame.$(action.selector || '*[__playwright_target__]');
|
||||
if (element) {
|
||||
await element.evaluate(e => {
|
||||
e.style.backgroundColor = '#ff69b460';
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e); // eslint-disable-line no-console
|
||||
}
|
||||
});
|
||||
await uiPage.exposeBinding('getTraceModel', () => this._traceModel);
|
||||
await uiPage.exposeBinding('getVideoMetaInfo', async (_, videoId: string) => {
|
||||
return this._videoTileGenerator.render(videoId);
|
||||
});
|
||||
await uiPage.route('**/*', (route, request) => {
|
||||
if (request.frame().parentFrame()) {
|
||||
this._snapshotRouter.route(route);
|
||||
return;
|
||||
}
|
||||
const url = new URL(request.url());
|
||||
try {
|
||||
if (request.url().includes('action-preview')) {
|
||||
const fullPath = url.pathname.substring('/action-preview/'.length);
|
||||
const actionId = fullPath.substring(0, fullPath.indexOf('.png'));
|
||||
this._screenshotGenerator.generateScreenshot(actionId).then(body => {
|
||||
if (body)
|
||||
route.fulfill({ contentType: 'image/png', body });
|
||||
else
|
||||
route.fulfill({ status: 404 });
|
||||
});
|
||||
return;
|
||||
}
|
||||
let filePath: string;
|
||||
if (request.url().includes('video-tile')) {
|
||||
const fullPath = url.pathname.substring('/video-tile/'.length);
|
||||
filePath = this._videoTileGenerator.tilePath(fullPath);
|
||||
} else {
|
||||
filePath = path.join(__dirname, 'web', url.pathname.substring(1));
|
||||
}
|
||||
const body = fs.readFileSync(filePath);
|
||||
route.fulfill({
|
||||
contentType: extensionToMime[path.extname(url.pathname).substring(1)] || 'text/plain',
|
||||
body,
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e); // eslint-disable-line no-console
|
||||
route.fulfill({
|
||||
status: 404
|
||||
});
|
||||
}
|
||||
});
|
||||
await uiPage.goto('http://trace-viewer/index.html');
|
||||
}
|
||||
}
|
||||
|
||||
export async function showTraceViewer(traceStorageDir: string | undefined, tracePath: string) {
|
||||
if (!fs.existsSync(tracePath))
|
||||
throw new Error(`${tracePath} does not exist`);
|
||||
|
||||
let files: string[];
|
||||
if (fs.statSync(tracePath).isFile()) {
|
||||
files = [tracePath];
|
||||
if (!traceStorageDir)
|
||||
traceStorageDir = path.dirname(tracePath);
|
||||
} else {
|
||||
files = collectFiles(tracePath);
|
||||
if (!traceStorageDir)
|
||||
traceStorageDir = tracePath;
|
||||
}
|
||||
|
||||
const traceViewer = new TraceViewer(traceStorageDir);
|
||||
for (const filePath of files)
|
||||
await traceViewer.load(filePath);
|
||||
await traceViewer.show();
|
||||
}
|
||||
|
||||
function collectFiles(dir: string): string[] {
|
||||
const files = [];
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const fullName = path.join(dir, name);
|
||||
if (fs.lstatSync(fullName).isDirectory())
|
||||
files.push(...collectFiles(fullName));
|
||||
else if (fullName.endsWith('.trace'))
|
||||
files.push(fullName);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const extensionToMime: { [key: string]: string } = {
|
||||
'css': 'text/css',
|
||||
'html': 'text/html',
|
||||
'jpeg': 'image/jpeg',
|
||||
'jpg': 'image/jpeg',
|
||||
'js': 'application/javascript',
|
||||
'png': 'image/png',
|
||||
'ttf': 'font/ttf',
|
||||
'svg': 'image/svg+xml',
|
||||
'webp': 'image/webp',
|
||||
'woff': 'font/woff',
|
||||
'woff2': 'font/woff2',
|
||||
};
|
87
src/cli/traceViewer/videoTileGenerator.ts
Normal file
87
src/cli/traceViewer/videoTileGenerator.ts
Normal file
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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 { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as util from 'util';
|
||||
import { TraceModel, videoById, VideoMetaInfo } from './traceModel';
|
||||
import type { PageVideoTraceEvent } from '../../trace/traceTypes';
|
||||
import { ffmpegExecutable } from '../../utils/binaryPaths';
|
||||
|
||||
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
|
||||
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
|
||||
|
||||
export class VideoTileGenerator {
|
||||
private _traceModel: TraceModel;
|
||||
|
||||
constructor(traceModel: TraceModel) {
|
||||
this._traceModel = traceModel;
|
||||
}
|
||||
|
||||
tilePath(urlPath: string) {
|
||||
const index = urlPath.lastIndexOf('/');
|
||||
const tile = urlPath.substring(index + 1);
|
||||
const videoId = urlPath.substring(0, index);
|
||||
const { context, page } = videoById(this._traceModel, videoId);
|
||||
const videoFilePath = path.join(path.dirname(context.filePath), page.video!.video.fileName);
|
||||
return videoFilePath + '-' + tile;
|
||||
}
|
||||
|
||||
async render(videoId: string): Promise<VideoMetaInfo | undefined> {
|
||||
const { context, page } = videoById(this._traceModel, videoId);
|
||||
const video = page.video!.video;
|
||||
const videoFilePath = path.join(path.dirname(context.filePath), video.fileName);
|
||||
const metaInfoFilePath = videoFilePath + '-metainfo.txt';
|
||||
try {
|
||||
const metaInfo = await fsReadFileAsync(metaInfoFilePath, 'utf8');
|
||||
return metaInfo ? JSON.parse(metaInfo) : undefined;
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
const ffmpeg = ffmpegExecutable()!;
|
||||
console.log('Generating frames for ' + videoFilePath); // eslint-disable-line no-console
|
||||
// Force output frame rate to 25 fps as otherwise it would produce one image per timebase unit
|
||||
// which is currently 1 / (25 * 1000).
|
||||
const result = spawnSync(ffmpeg, ['-i', videoFilePath, '-r', '25', `${videoFilePath}-%03d.png`]);
|
||||
const metaInfo = parseMetaInfo(result.stderr.toString(), video);
|
||||
await fsWriteFileAsync(metaInfoFilePath, metaInfo ? JSON.stringify(metaInfo) : '');
|
||||
return metaInfo;
|
||||
}
|
||||
}
|
||||
|
||||
function parseMetaInfo(text: string, video: PageVideoTraceEvent): VideoMetaInfo | undefined {
|
||||
const lines = text.split('\n');
|
||||
let framesLine = lines.find(l => l.startsWith('frame='));
|
||||
if (!framesLine)
|
||||
return;
|
||||
framesLine = framesLine.substring(framesLine.lastIndexOf('frame='));
|
||||
const framesMatch = framesLine.match(/frame=\s+(\d+)/);
|
||||
const outputLineIndex = lines.findIndex(l => l.trim().startsWith('Output #0'));
|
||||
const streamLine = lines.slice(outputLineIndex).find(l => l.trim().startsWith('Stream #0:0'))!;
|
||||
const fpsMatch = streamLine.match(/, (\d+) fps,/);
|
||||
const resolutionMatch = streamLine.match(/, (\d+)x(\d+)\D/);
|
||||
const durationMatch = lines.find(l => l.trim().startsWith('Duration'))!.match(/Duration: (\d+):(\d\d):(\d\d.\d\d)/);
|
||||
const duration = (((parseInt(durationMatch![1], 10) * 60) + parseInt(durationMatch![2], 10)) * 60 + parseFloat(durationMatch![3])) * 1000;
|
||||
return {
|
||||
frames: parseInt(framesMatch![1], 10),
|
||||
width: parseInt(resolutionMatch![1], 10),
|
||||
height: parseInt(resolutionMatch![2], 10),
|
||||
fps: parseInt(fpsMatch![1], 10),
|
||||
startTime: (video as any).timestamp,
|
||||
endTime: (video as any).timestamp + duration
|
||||
};
|
||||
}
|
122
src/cli/traceViewer/web/common.css
Normal file
122
src/cli/traceViewer/web/common.css
Normal file
@ -0,0 +1,122 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--light-background: #f3f2f1;
|
||||
--background: #edebe9;
|
||||
--active-background: #333333;
|
||||
--color: #252423;
|
||||
--red: #F44336;
|
||||
--green: #4CAF50;
|
||||
--purple: #9C27B0;
|
||||
--yellow: #FFC107;
|
||||
--blue: #2196F3;
|
||||
--orange: #d24726;
|
||||
--black: #1E1E1E;
|
||||
--gray: #888888;
|
||||
--separator: #80808059;
|
||||
--focus-ring: #0E639CCC;
|
||||
--inactive-focus-ring: #80808059;
|
||||
--layout-gap: 10px;
|
||||
--selection: #074771;
|
||||
--control-background: #3C3C3C;
|
||||
--settings: #E7E7E7;
|
||||
--sidebar-width: 250px;
|
||||
--light-pink: #ff69b460;
|
||||
--box-shadow: rgba(0, 0, 0, 0.133) 0px 1.6px 3.6px 0px, rgba(0, 0, 0, 0.11) 0px 0.3px 0.9px 0px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
background: var(--background);
|
||||
overscroll-behavior-x: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
*[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.codicon {
|
||||
color: #C5C5C5;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--color);
|
||||
font-size: 14px;
|
||||
font-family: SegoeUI-SemiBold-final,Segoe UI Semibold,SegoeUI-Regular-final,Segoe UI,"Segoe UI Web (West European)",Segoe,-apple-system,BlinkMacSystemFont,Roboto,Helvetica Neue,Tahoma,Helvetica,Arial,sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.platform-windows {
|
||||
--monospace-font: Consolas, Inconsolata, "Courier New", monospace;
|
||||
}
|
||||
|
||||
.platform-linux {
|
||||
--monospace-font:"Droid Sans Mono", Inconsolata, "Courier New", monospace, "Droid Sans Fallback";
|
||||
}
|
||||
|
||||
.platform-mac {
|
||||
--monospace-font: "SF Mono",Monaco,Menlo,Inconsolata,"Courier New",monospace;
|
||||
}
|
||||
|
||||
.vbox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hbox {
|
||||
display: flex;
|
||||
flex: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border: 1px solid #ccc;
|
||||
background-color: var(--light-background);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background-color: var(--background);
|
||||
}
|
25
src/cli/traceViewer/web/geometry.ts
Normal file
25
src/cli/traceViewer/web/geometry.ts
Normal file
@ -0,0 +1,25 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
export type Size = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type Boundaries = {
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
};
|
27
src/cli/traceViewer/web/index.html
Normal file
27
src/cli/traceViewer/web/index.html
Normal file
@ -0,0 +1,27 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Playwright Trace Viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id=root></div>
|
||||
</body>
|
||||
</html>
|
57
src/cli/traceViewer/web/index.tsx
Normal file
57
src/cli/traceViewer/web/index.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
1;/**
|
||||
* 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 { TraceModel, VideoMetaInfo } from '../traceModel';
|
||||
import './common.css';
|
||||
import './third_party/vscode/codicon.css';
|
||||
import { Workbench } from './ui/workbench';
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import { ActionTraceEvent } from '../../../trace/traceTypes';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
getTraceModel(): Promise<TraceModel>;
|
||||
getVideoMetaInfo(videoId: string): Promise<VideoMetaInfo | undefined>;
|
||||
readFile(filePath: string): Promise<string>;
|
||||
renderSnapshot(action: ActionTraceEvent): void;
|
||||
}
|
||||
}
|
||||
|
||||
function platformName(): string {
|
||||
if (window.navigator.userAgent.includes('Linux'))
|
||||
return 'platform-linux';
|
||||
if (window.navigator.userAgent.includes('Windows'))
|
||||
return 'platform-windows';
|
||||
if (window.navigator.userAgent.includes('Mac'))
|
||||
return 'platform-mac';
|
||||
return 'platform-generic';
|
||||
}
|
||||
|
||||
(async () => {
|
||||
document!.defaultView!.addEventListener('focus', (event: any) => {
|
||||
if (event.target.document.nodeType === Node.DOCUMENT_NODE)
|
||||
document.body.classList.remove('inactive');
|
||||
}, false);
|
||||
document!.defaultView!.addEventListener('blur', event => {
|
||||
document.body.classList.add('inactive');
|
||||
}, false);
|
||||
|
||||
document.documentElement.classList.add(platformName());
|
||||
|
||||
const traceModel = await window.getTraceModel();
|
||||
ReactDOM.render(<Workbench traceModel={traceModel} />, document.querySelector('#root'));
|
||||
})();
|
21
src/cli/traceViewer/web/third_party/vscode/LICENSE.txt
vendored
Normal file
21
src/cli/traceViewer/web/third_party/vscode/LICENSE.txt
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2015 - present Microsoft Corporation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
440
src/cli/traceViewer/web/third_party/vscode/codicon.css
vendored
Normal file
440
src/cli/traceViewer/web/third_party/vscode/codicon.css
vendored
Normal file
@ -0,0 +1,440 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
@font-face {
|
||||
font-family: "codicon";
|
||||
src: url("codicon.ttf") format("truetype");
|
||||
}
|
||||
|
||||
.codicon {
|
||||
font: normal normal normal 16px/1 codicon;
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
text-rendering: auto;
|
||||
text-align: center;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.codicon-add:before { content: '\ea60'; }
|
||||
.codicon-plus:before { content: '\ea60'; }
|
||||
.codicon-gist-new:before { content: '\ea60'; }
|
||||
.codicon-repo-create:before { content: '\ea60'; }
|
||||
.codicon-lightbulb:before { content: '\ea61'; }
|
||||
.codicon-light-bulb:before { content: '\ea61'; }
|
||||
.codicon-repo:before { content: '\ea62'; }
|
||||
.codicon-repo-delete:before { content: '\ea62'; }
|
||||
.codicon-gist-fork:before { content: '\ea63'; }
|
||||
.codicon-repo-forked:before { content: '\ea63'; }
|
||||
.codicon-git-pull-request:before { content: '\ea64'; }
|
||||
.codicon-git-pull-request-abandoned:before { content: '\ea64'; }
|
||||
.codicon-record-keys:before { content: '\ea65'; }
|
||||
.codicon-keyboard:before { content: '\ea65'; }
|
||||
.codicon-tag:before { content: '\ea66'; }
|
||||
.codicon-tag-add:before { content: '\ea66'; }
|
||||
.codicon-tag-remove:before { content: '\ea66'; }
|
||||
.codicon-person:before { content: '\ea67'; }
|
||||
.codicon-person-add:before { content: '\ea67'; }
|
||||
.codicon-person-follow:before { content: '\ea67'; }
|
||||
.codicon-person-outline:before { content: '\ea67'; }
|
||||
.codicon-person-filled:before { content: '\ea67'; }
|
||||
.codicon-git-branch:before { content: '\ea68'; }
|
||||
.codicon-git-branch-create:before { content: '\ea68'; }
|
||||
.codicon-git-branch-delete:before { content: '\ea68'; }
|
||||
.codicon-source-control:before { content: '\ea68'; }
|
||||
.codicon-mirror:before { content: '\ea69'; }
|
||||
.codicon-mirror-public:before { content: '\ea69'; }
|
||||
.codicon-star:before { content: '\ea6a'; }
|
||||
.codicon-star-add:before { content: '\ea6a'; }
|
||||
.codicon-star-delete:before { content: '\ea6a'; }
|
||||
.codicon-star-empty:before { content: '\ea6a'; }
|
||||
.codicon-comment:before { content: '\ea6b'; }
|
||||
.codicon-comment-add:before { content: '\ea6b'; }
|
||||
.codicon-alert:before { content: '\ea6c'; }
|
||||
.codicon-warning:before { content: '\ea6c'; }
|
||||
.codicon-search:before { content: '\ea6d'; }
|
||||
.codicon-search-save:before { content: '\ea6d'; }
|
||||
.codicon-log-out:before { content: '\ea6e'; }
|
||||
.codicon-sign-out:before { content: '\ea6e'; }
|
||||
.codicon-log-in:before { content: '\ea6f'; }
|
||||
.codicon-sign-in:before { content: '\ea6f'; }
|
||||
.codicon-eye:before { content: '\ea70'; }
|
||||
.codicon-eye-unwatch:before { content: '\ea70'; }
|
||||
.codicon-eye-watch:before { content: '\ea70'; }
|
||||
.codicon-circle-filled:before { content: '\ea71'; }
|
||||
.codicon-primitive-dot:before { content: '\ea71'; }
|
||||
.codicon-close-dirty:before { content: '\ea71'; }
|
||||
.codicon-debug-breakpoint:before { content: '\ea71'; }
|
||||
.codicon-debug-breakpoint-disabled:before { content: '\ea71'; }
|
||||
.codicon-debug-hint:before { content: '\ea71'; }
|
||||
.codicon-primitive-square:before { content: '\ea72'; }
|
||||
.codicon-edit:before { content: '\ea73'; }
|
||||
.codicon-pencil:before { content: '\ea73'; }
|
||||
.codicon-info:before { content: '\ea74'; }
|
||||
.codicon-issue-opened:before { content: '\ea74'; }
|
||||
.codicon-gist-private:before { content: '\ea75'; }
|
||||
.codicon-git-fork-private:before { content: '\ea75'; }
|
||||
.codicon-lock:before { content: '\ea75'; }
|
||||
.codicon-mirror-private:before { content: '\ea75'; }
|
||||
.codicon-close:before { content: '\ea76'; }
|
||||
.codicon-remove-close:before { content: '\ea76'; }
|
||||
.codicon-x:before { content: '\ea76'; }
|
||||
.codicon-repo-sync:before { content: '\ea77'; }
|
||||
.codicon-sync:before { content: '\ea77'; }
|
||||
.codicon-clone:before { content: '\ea78'; }
|
||||
.codicon-desktop-download:before { content: '\ea78'; }
|
||||
.codicon-beaker:before { content: '\ea79'; }
|
||||
.codicon-microscope:before { content: '\ea79'; }
|
||||
.codicon-vm:before { content: '\ea7a'; }
|
||||
.codicon-device-desktop:before { content: '\ea7a'; }
|
||||
.codicon-file:before { content: '\ea7b'; }
|
||||
.codicon-file-text:before { content: '\ea7b'; }
|
||||
.codicon-more:before { content: '\ea7c'; }
|
||||
.codicon-ellipsis:before { content: '\ea7c'; }
|
||||
.codicon-kebab-horizontal:before { content: '\ea7c'; }
|
||||
.codicon-mail-reply:before { content: '\ea7d'; }
|
||||
.codicon-reply:before { content: '\ea7d'; }
|
||||
.codicon-organization:before { content: '\ea7e'; }
|
||||
.codicon-organization-filled:before { content: '\ea7e'; }
|
||||
.codicon-organization-outline:before { content: '\ea7e'; }
|
||||
.codicon-new-file:before { content: '\ea7f'; }
|
||||
.codicon-file-add:before { content: '\ea7f'; }
|
||||
.codicon-new-folder:before { content: '\ea80'; }
|
||||
.codicon-file-directory-create:before { content: '\ea80'; }
|
||||
.codicon-trash:before { content: '\ea81'; }
|
||||
.codicon-trashcan:before { content: '\ea81'; }
|
||||
.codicon-history:before { content: '\ea82'; }
|
||||
.codicon-clock:before { content: '\ea82'; }
|
||||
.codicon-folder:before { content: '\ea83'; }
|
||||
.codicon-file-directory:before { content: '\ea83'; }
|
||||
.codicon-symbol-folder:before { content: '\ea83'; }
|
||||
.codicon-logo-github:before { content: '\ea84'; }
|
||||
.codicon-mark-github:before { content: '\ea84'; }
|
||||
.codicon-github:before { content: '\ea84'; }
|
||||
.codicon-terminal:before { content: '\ea85'; }
|
||||
.codicon-console:before { content: '\ea85'; }
|
||||
.codicon-repl:before { content: '\ea85'; }
|
||||
.codicon-zap:before { content: '\ea86'; }
|
||||
.codicon-symbol-event:before { content: '\ea86'; }
|
||||
.codicon-error:before { content: '\ea87'; }
|
||||
.codicon-stop:before { content: '\ea87'; }
|
||||
.codicon-variable:before { content: '\ea88'; }
|
||||
.codicon-symbol-variable:before { content: '\ea88'; }
|
||||
.codicon-array:before { content: '\ea8a'; }
|
||||
.codicon-symbol-array:before { content: '\ea8a'; }
|
||||
.codicon-symbol-module:before { content: '\ea8b'; }
|
||||
.codicon-symbol-package:before { content: '\ea8b'; }
|
||||
.codicon-symbol-namespace:before { content: '\ea8b'; }
|
||||
.codicon-symbol-object:before { content: '\ea8b'; }
|
||||
.codicon-symbol-method:before { content: '\ea8c'; }
|
||||
.codicon-symbol-function:before { content: '\ea8c'; }
|
||||
.codicon-symbol-constructor:before { content: '\ea8c'; }
|
||||
.codicon-symbol-boolean:before { content: '\ea8f'; }
|
||||
.codicon-symbol-null:before { content: '\ea8f'; }
|
||||
.codicon-symbol-numeric:before { content: '\ea90'; }
|
||||
.codicon-symbol-number:before { content: '\ea90'; }
|
||||
.codicon-symbol-structure:before { content: '\ea91'; }
|
||||
.codicon-symbol-struct:before { content: '\ea91'; }
|
||||
.codicon-symbol-parameter:before { content: '\ea92'; }
|
||||
.codicon-symbol-type-parameter:before { content: '\ea92'; }
|
||||
.codicon-symbol-key:before { content: '\ea93'; }
|
||||
.codicon-symbol-text:before { content: '\ea93'; }
|
||||
.codicon-symbol-reference:before { content: '\ea94'; }
|
||||
.codicon-go-to-file:before { content: '\ea94'; }
|
||||
.codicon-symbol-enum:before { content: '\ea95'; }
|
||||
.codicon-symbol-value:before { content: '\ea95'; }
|
||||
.codicon-symbol-ruler:before { content: '\ea96'; }
|
||||
.codicon-symbol-unit:before { content: '\ea96'; }
|
||||
.codicon-activate-breakpoints:before { content: '\ea97'; }
|
||||
.codicon-archive:before { content: '\ea98'; }
|
||||
.codicon-arrow-both:before { content: '\ea99'; }
|
||||
.codicon-arrow-down:before { content: '\ea9a'; }
|
||||
.codicon-arrow-left:before { content: '\ea9b'; }
|
||||
.codicon-arrow-right:before { content: '\ea9c'; }
|
||||
.codicon-arrow-small-down:before { content: '\ea9d'; }
|
||||
.codicon-arrow-small-left:before { content: '\ea9e'; }
|
||||
.codicon-arrow-small-right:before { content: '\ea9f'; }
|
||||
.codicon-arrow-small-up:before { content: '\eaa0'; }
|
||||
.codicon-arrow-up:before { content: '\eaa1'; }
|
||||
.codicon-bell:before { content: '\eaa2'; }
|
||||
.codicon-bold:before { content: '\eaa3'; }
|
||||
.codicon-book:before { content: '\eaa4'; }
|
||||
.codicon-bookmark:before { content: '\eaa5'; }
|
||||
.codicon-debug-breakpoint-conditional-unverified:before { content: '\eaa6'; }
|
||||
.codicon-debug-breakpoint-conditional:before { content: '\eaa7'; }
|
||||
.codicon-debug-breakpoint-conditional-disabled:before { content: '\eaa7'; }
|
||||
.codicon-debug-breakpoint-data-unverified:before { content: '\eaa8'; }
|
||||
.codicon-debug-breakpoint-data:before { content: '\eaa9'; }
|
||||
.codicon-debug-breakpoint-data-disabled:before { content: '\eaa9'; }
|
||||
.codicon-debug-breakpoint-log-unverified:before { content: '\eaaa'; }
|
||||
.codicon-debug-breakpoint-log:before { content: '\eaab'; }
|
||||
.codicon-debug-breakpoint-log-disabled:before { content: '\eaab'; }
|
||||
.codicon-briefcase:before { content: '\eaac'; }
|
||||
.codicon-broadcast:before { content: '\eaad'; }
|
||||
.codicon-browser:before { content: '\eaae'; }
|
||||
.codicon-bug:before { content: '\eaaf'; }
|
||||
.codicon-calendar:before { content: '\eab0'; }
|
||||
.codicon-case-sensitive:before { content: '\eab1'; }
|
||||
.codicon-check:before { content: '\eab2'; }
|
||||
.codicon-checklist:before { content: '\eab3'; }
|
||||
.codicon-chevron-down:before { content: '\eab4'; }
|
||||
.codicon-chevron-left:before { content: '\eab5'; }
|
||||
.codicon-chevron-right:before { content: '\eab6'; }
|
||||
.codicon-chevron-up:before { content: '\eab7'; }
|
||||
.codicon-chrome-close:before { content: '\eab8'; }
|
||||
.codicon-chrome-maximize:before { content: '\eab9'; }
|
||||
.codicon-chrome-minimize:before { content: '\eaba'; }
|
||||
.codicon-chrome-restore:before { content: '\eabb'; }
|
||||
.codicon-circle-outline:before { content: '\eabc'; }
|
||||
.codicon-debug-breakpoint-unverified:before { content: '\eabc'; }
|
||||
.codicon-circle-slash:before { content: '\eabd'; }
|
||||
.codicon-circuit-board:before { content: '\eabe'; }
|
||||
.codicon-clear-all:before { content: '\eabf'; }
|
||||
.codicon-clippy:before { content: '\eac0'; }
|
||||
.codicon-close-all:before { content: '\eac1'; }
|
||||
.codicon-cloud-download:before { content: '\eac2'; }
|
||||
.codicon-cloud-upload:before { content: '\eac3'; }
|
||||
.codicon-code:before { content: '\eac4'; }
|
||||
.codicon-collapse-all:before { content: '\eac5'; }
|
||||
.codicon-color-mode:before { content: '\eac6'; }
|
||||
.codicon-comment-discussion:before { content: '\eac7'; }
|
||||
.codicon-compare-changes:before { content: '\eafd'; }
|
||||
.codicon-credit-card:before { content: '\eac9'; }
|
||||
.codicon-dash:before { content: '\eacc'; }
|
||||
.codicon-dashboard:before { content: '\eacd'; }
|
||||
.codicon-database:before { content: '\eace'; }
|
||||
.codicon-debug-continue:before { content: '\eacf'; }
|
||||
.codicon-debug-disconnect:before { content: '\ead0'; }
|
||||
.codicon-debug-pause:before { content: '\ead1'; }
|
||||
.codicon-debug-restart:before { content: '\ead2'; }
|
||||
.codicon-debug-start:before { content: '\ead3'; }
|
||||
.codicon-debug-step-into:before { content: '\ead4'; }
|
||||
.codicon-debug-step-out:before { content: '\ead5'; }
|
||||
.codicon-debug-step-over:before { content: '\ead6'; }
|
||||
.codicon-debug-stop:before { content: '\ead7'; }
|
||||
.codicon-debug:before { content: '\ead8'; }
|
||||
.codicon-device-camera-video:before { content: '\ead9'; }
|
||||
.codicon-device-camera:before { content: '\eada'; }
|
||||
.codicon-device-mobile:before { content: '\eadb'; }
|
||||
.codicon-diff-added:before { content: '\eadc'; }
|
||||
.codicon-diff-ignored:before { content: '\eadd'; }
|
||||
.codicon-diff-modified:before { content: '\eade'; }
|
||||
.codicon-diff-removed:before { content: '\eadf'; }
|
||||
.codicon-diff-renamed:before { content: '\eae0'; }
|
||||
.codicon-diff:before { content: '\eae1'; }
|
||||
.codicon-discard:before { content: '\eae2'; }
|
||||
.codicon-editor-layout:before { content: '\eae3'; }
|
||||
.codicon-empty-window:before { content: '\eae4'; }
|
||||
.codicon-exclude:before { content: '\eae5'; }
|
||||
.codicon-extensions:before { content: '\eae6'; }
|
||||
.codicon-eye-closed:before { content: '\eae7'; }
|
||||
.codicon-file-binary:before { content: '\eae8'; }
|
||||
.codicon-file-code:before { content: '\eae9'; }
|
||||
.codicon-file-media:before { content: '\eaea'; }
|
||||
.codicon-file-pdf:before { content: '\eaeb'; }
|
||||
.codicon-file-submodule:before { content: '\eaec'; }
|
||||
.codicon-file-symlink-directory:before { content: '\eaed'; }
|
||||
.codicon-file-symlink-file:before { content: '\eaee'; }
|
||||
.codicon-file-zip:before { content: '\eaef'; }
|
||||
.codicon-files:before { content: '\eaf0'; }
|
||||
.codicon-filter:before { content: '\eaf1'; }
|
||||
.codicon-flame:before { content: '\eaf2'; }
|
||||
.codicon-fold-down:before { content: '\eaf3'; }
|
||||
.codicon-fold-up:before { content: '\eaf4'; }
|
||||
.codicon-fold:before { content: '\eaf5'; }
|
||||
.codicon-folder-active:before { content: '\eaf6'; }
|
||||
.codicon-folder-opened:before { content: '\eaf7'; }
|
||||
.codicon-gear:before { content: '\eaf8'; }
|
||||
.codicon-gift:before { content: '\eaf9'; }
|
||||
.codicon-gist-secret:before { content: '\eafa'; }
|
||||
.codicon-gist:before { content: '\eafb'; }
|
||||
.codicon-git-commit:before { content: '\eafc'; }
|
||||
.codicon-git-compare:before { content: '\eafd'; }
|
||||
.codicon-git-merge:before { content: '\eafe'; }
|
||||
.codicon-github-action:before { content: '\eaff'; }
|
||||
.codicon-github-alt:before { content: '\eb00'; }
|
||||
.codicon-globe:before { content: '\eb01'; }
|
||||
.codicon-grabber:before { content: '\eb02'; }
|
||||
.codicon-graph:before { content: '\eb03'; }
|
||||
.codicon-gripper:before { content: '\eb04'; }
|
||||
.codicon-heart:before { content: '\eb05'; }
|
||||
.codicon-home:before { content: '\eb06'; }
|
||||
.codicon-horizontal-rule:before { content: '\eb07'; }
|
||||
.codicon-hubot:before { content: '\eb08'; }
|
||||
.codicon-inbox:before { content: '\eb09'; }
|
||||
.codicon-issue-closed:before { content: '\eb0a'; }
|
||||
.codicon-issue-reopened:before { content: '\eb0b'; }
|
||||
.codicon-issues:before { content: '\eb0c'; }
|
||||
.codicon-italic:before { content: '\eb0d'; }
|
||||
.codicon-jersey:before { content: '\eb0e'; }
|
||||
.codicon-json:before { content: '\eb0f'; }
|
||||
.codicon-kebab-vertical:before { content: '\eb10'; }
|
||||
.codicon-key:before { content: '\eb11'; }
|
||||
.codicon-law:before { content: '\eb12'; }
|
||||
.codicon-lightbulb-autofix:before { content: '\eb13'; }
|
||||
.codicon-link-external:before { content: '\eb14'; }
|
||||
.codicon-link:before { content: '\eb15'; }
|
||||
.codicon-list-ordered:before { content: '\eb16'; }
|
||||
.codicon-list-unordered:before { content: '\eb17'; }
|
||||
.codicon-live-share:before { content: '\eb18'; }
|
||||
.codicon-loading:before { content: '\eb19'; }
|
||||
.codicon-location:before { content: '\eb1a'; }
|
||||
.codicon-mail-read:before { content: '\eb1b'; }
|
||||
.codicon-mail:before { content: '\eb1c'; }
|
||||
.codicon-markdown:before { content: '\eb1d'; }
|
||||
.codicon-megaphone:before { content: '\eb1e'; }
|
||||
.codicon-mention:before { content: '\eb1f'; }
|
||||
.codicon-milestone:before { content: '\eb20'; }
|
||||
.codicon-mortar-board:before { content: '\eb21'; }
|
||||
.codicon-move:before { content: '\eb22'; }
|
||||
.codicon-multiple-windows:before { content: '\eb23'; }
|
||||
.codicon-mute:before { content: '\eb24'; }
|
||||
.codicon-no-newline:before { content: '\eb25'; }
|
||||
.codicon-note:before { content: '\eb26'; }
|
||||
.codicon-octoface:before { content: '\eb27'; }
|
||||
.codicon-open-preview:before { content: '\eb28'; }
|
||||
.codicon-package:before { content: '\eb29'; }
|
||||
.codicon-paintcan:before { content: '\eb2a'; }
|
||||
.codicon-pin:before { content: '\eb2b'; }
|
||||
.codicon-play:before { content: '\eb2c'; }
|
||||
.codicon-run:before { content: '\eb2c'; }
|
||||
.codicon-plug:before { content: '\eb2d'; }
|
||||
.codicon-preserve-case:before { content: '\eb2e'; }
|
||||
.codicon-preview:before { content: '\eb2f'; }
|
||||
.codicon-project:before { content: '\eb30'; }
|
||||
.codicon-pulse:before { content: '\eb31'; }
|
||||
.codicon-question:before { content: '\eb32'; }
|
||||
.codicon-quote:before { content: '\eb33'; }
|
||||
.codicon-radio-tower:before { content: '\eb34'; }
|
||||
.codicon-reactions:before { content: '\eb35'; }
|
||||
.codicon-references:before { content: '\eb36'; }
|
||||
.codicon-refresh:before { content: '\eb37'; }
|
||||
.codicon-regex:before { content: '\eb38'; }
|
||||
.codicon-remote-explorer:before { content: '\eb39'; }
|
||||
.codicon-remote:before { content: '\eb3a'; }
|
||||
.codicon-remove:before { content: '\eb3b'; }
|
||||
.codicon-replace-all:before { content: '\eb3c'; }
|
||||
.codicon-replace:before { content: '\eb3d'; }
|
||||
.codicon-repo-clone:before { content: '\eb3e'; }
|
||||
.codicon-repo-force-push:before { content: '\eb3f'; }
|
||||
.codicon-repo-pull:before { content: '\eb40'; }
|
||||
.codicon-repo-push:before { content: '\eb41'; }
|
||||
.codicon-report:before { content: '\eb42'; }
|
||||
.codicon-request-changes:before { content: '\eb43'; }
|
||||
.codicon-rocket:before { content: '\eb44'; }
|
||||
.codicon-root-folder-opened:before { content: '\eb45'; }
|
||||
.codicon-root-folder:before { content: '\eb46'; }
|
||||
.codicon-rss:before { content: '\eb47'; }
|
||||
.codicon-ruby:before { content: '\eb48'; }
|
||||
.codicon-save-all:before { content: '\eb49'; }
|
||||
.codicon-save-as:before { content: '\eb4a'; }
|
||||
.codicon-save:before { content: '\eb4b'; }
|
||||
.codicon-screen-full:before { content: '\eb4c'; }
|
||||
.codicon-screen-normal:before { content: '\eb4d'; }
|
||||
.codicon-search-stop:before { content: '\eb4e'; }
|
||||
.codicon-server:before { content: '\eb50'; }
|
||||
.codicon-settings-gear:before { content: '\eb51'; }
|
||||
.codicon-settings:before { content: '\eb52'; }
|
||||
.codicon-shield:before { content: '\eb53'; }
|
||||
.codicon-smiley:before { content: '\eb54'; }
|
||||
.codicon-sort-precedence:before { content: '\eb55'; }
|
||||
.codicon-split-horizontal:before { content: '\eb56'; }
|
||||
.codicon-split-vertical:before { content: '\eb57'; }
|
||||
.codicon-squirrel:before { content: '\eb58'; }
|
||||
.codicon-star-full:before { content: '\eb59'; }
|
||||
.codicon-star-half:before { content: '\eb5a'; }
|
||||
.codicon-symbol-class:before { content: '\eb5b'; }
|
||||
.codicon-symbol-color:before { content: '\eb5c'; }
|
||||
.codicon-symbol-constant:before { content: '\eb5d'; }
|
||||
.codicon-symbol-enum-member:before { content: '\eb5e'; }
|
||||
.codicon-symbol-field:before { content: '\eb5f'; }
|
||||
.codicon-symbol-file:before { content: '\eb60'; }
|
||||
.codicon-symbol-interface:before { content: '\eb61'; }
|
||||
.codicon-symbol-keyword:before { content: '\eb62'; }
|
||||
.codicon-symbol-misc:before { content: '\eb63'; }
|
||||
.codicon-symbol-operator:before { content: '\eb64'; }
|
||||
.codicon-symbol-property:before { content: '\eb65'; }
|
||||
.codicon-wrench:before { content: '\eb65'; }
|
||||
.codicon-wrench-subaction:before { content: '\eb65'; }
|
||||
.codicon-symbol-snippet:before { content: '\eb66'; }
|
||||
.codicon-tasklist:before { content: '\eb67'; }
|
||||
.codicon-telescope:before { content: '\eb68'; }
|
||||
.codicon-text-size:before { content: '\eb69'; }
|
||||
.codicon-three-bars:before { content: '\eb6a'; }
|
||||
.codicon-thumbsdown:before { content: '\eb6b'; }
|
||||
.codicon-thumbsup:before { content: '\eb6c'; }
|
||||
.codicon-tools:before { content: '\eb6d'; }
|
||||
.codicon-triangle-down:before { content: '\eb6e'; }
|
||||
.codicon-triangle-left:before { content: '\eb6f'; }
|
||||
.codicon-triangle-right:before { content: '\eb70'; }
|
||||
.codicon-triangle-up:before { content: '\eb71'; }
|
||||
.codicon-twitter:before { content: '\eb72'; }
|
||||
.codicon-unfold:before { content: '\eb73'; }
|
||||
.codicon-unlock:before { content: '\eb74'; }
|
||||
.codicon-unmute:before { content: '\eb75'; }
|
||||
.codicon-unverified:before { content: '\eb76'; }
|
||||
.codicon-verified:before { content: '\eb77'; }
|
||||
.codicon-versions:before { content: '\eb78'; }
|
||||
.codicon-vm-active:before { content: '\eb79'; }
|
||||
.codicon-vm-outline:before { content: '\eb7a'; }
|
||||
.codicon-vm-running:before { content: '\eb7b'; }
|
||||
.codicon-watch:before { content: '\eb7c'; }
|
||||
.codicon-whitespace:before { content: '\eb7d'; }
|
||||
.codicon-whole-word:before { content: '\eb7e'; }
|
||||
.codicon-window:before { content: '\eb7f'; }
|
||||
.codicon-word-wrap:before { content: '\eb80'; }
|
||||
.codicon-zoom-in:before { content: '\eb81'; }
|
||||
.codicon-zoom-out:before { content: '\eb82'; }
|
||||
.codicon-list-filter:before { content: '\eb83'; }
|
||||
.codicon-list-flat:before { content: '\eb84'; }
|
||||
.codicon-list-selection:before { content: '\eb85'; }
|
||||
.codicon-selection:before { content: '\eb85'; }
|
||||
.codicon-list-tree:before { content: '\eb86'; }
|
||||
.codicon-debug-breakpoint-function-unverified:before { content: '\eb87'; }
|
||||
.codicon-debug-breakpoint-function:before { content: '\eb88'; }
|
||||
.codicon-debug-breakpoint-function-disabled:before { content: '\eb88'; }
|
||||
.codicon-debug-stackframe-active:before { content: '\eb89'; }
|
||||
.codicon-debug-stackframe-dot:before { content: '\eb8a'; }
|
||||
.codicon-debug-stackframe:before { content: '\eb8b'; }
|
||||
.codicon-debug-stackframe-focused:before { content: '\eb8b'; }
|
||||
.codicon-debug-breakpoint-unsupported:before { content: '\eb8c'; }
|
||||
.codicon-symbol-string:before { content: '\eb8d'; }
|
||||
.codicon-debug-reverse-continue:before { content: '\eb8e'; }
|
||||
.codicon-debug-step-back:before { content: '\eb8f'; }
|
||||
.codicon-debug-restart-frame:before { content: '\eb90'; }
|
||||
.codicon-call-incoming:before { content: '\eb92'; }
|
||||
.codicon-call-outgoing:before { content: '\eb93'; }
|
||||
.codicon-menu:before { content: '\eb94'; }
|
||||
.codicon-expand-all:before { content: '\eb95'; }
|
||||
.codicon-feedback:before { content: '\eb96'; }
|
||||
.codicon-group-by-ref-type:before { content: '\eb97'; }
|
||||
.codicon-ungroup-by-ref-type:before { content: '\eb98'; }
|
||||
.codicon-account:before { content: '\eb99'; }
|
||||
.codicon-bell-dot:before { content: '\eb9a'; }
|
||||
.codicon-debug-console:before { content: '\eb9b'; }
|
||||
.codicon-library:before { content: '\eb9c'; }
|
||||
.codicon-output:before { content: '\eb9d'; }
|
||||
.codicon-run-all:before { content: '\eb9e'; }
|
||||
.codicon-sync-ignored:before { content: '\eb9f'; }
|
||||
.codicon-pinned:before { content: '\eba0'; }
|
||||
.codicon-github-inverted:before { content: '\eba1'; }
|
||||
.codicon-debug-alt:before { content: '\eb91'; }
|
||||
.codicon-server-process:before { content: '\eba2'; }
|
||||
.codicon-server-environment:before { content: '\eba3'; }
|
||||
.codicon-pass:before { content: '\eba4'; }
|
||||
.codicon-stop-circle:before { content: '\eba5'; }
|
||||
.codicon-play-circle:before { content: '\eba6'; }
|
||||
.codicon-record:before { content: '\eba7'; }
|
||||
.codicon-debug-alt-small:before { content: '\eba8'; }
|
||||
.codicon-vm-connect:before { content: '\eba9'; }
|
||||
.codicon-cloud:before { content: '\ebaa'; }
|
||||
.codicon-merge:before { content: '\ebab'; }
|
||||
.codicon-export:before { content: '\ebac'; }
|
||||
.codicon-graph-left:before { content: '\ebad'; }
|
||||
.codicon-magnet:before { content: '\ebae'; }
|
BIN
src/cli/traceViewer/web/third_party/vscode/codicon.ttf
vendored
Normal file
BIN
src/cli/traceViewer/web/third_party/vscode/codicon.ttf
vendored
Normal file
Binary file not shown.
92
src/cli/traceViewer/web/ui/actionList.css
Normal file
92
src/cli/traceViewer/web/ui/actionList.css
Normal file
@ -0,0 +1,92 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.action-list {
|
||||
width: var(--sidebar-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: none;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
contain: size;
|
||||
padding: 0 var(--layout-gap);
|
||||
}
|
||||
|
||||
.action-entry {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border: 3px solid var(--background);
|
||||
margin-top: var(--layout-gap);
|
||||
user-select: none;
|
||||
padding: 0 5px 5px 5px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.action-entry:hover {
|
||||
border-color: var(--inactive-focus-ring);
|
||||
}
|
||||
|
||||
.action-entry.selected {
|
||||
border-color: var(--inactive-focus-ring);
|
||||
}
|
||||
|
||||
.action-entry.selected:focus {
|
||||
border-color: var(--orange);
|
||||
}
|
||||
|
||||
.action-title {
|
||||
display: inline;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-header {
|
||||
display: block;
|
||||
align-items: center;
|
||||
margin: 5px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-selector {
|
||||
display: inline;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.action-url {
|
||||
display: inline;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.action-thumbnail {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 200px;
|
||||
height: 100px;
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
|
||||
.action-thumbnail img {
|
||||
max-width: 200px;
|
||||
max-height: 100px;
|
||||
}
|
42
src/cli/traceViewer/web/ui/actionList.tsx
Normal file
42
src/cli/traceViewer/web/ui/actionList.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
/*
|
||||
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 { ActionEntry } from '../../traceModel';
|
||||
import './actionList.css';
|
||||
import * as React from 'react';
|
||||
|
||||
export const ActionList: React.FunctionComponent<{
|
||||
actions: ActionEntry[],
|
||||
selectedAction?: ActionEntry,
|
||||
onSelected: (action: ActionEntry) => void,
|
||||
}> = ({ actions, selectedAction, onSelected }) => {
|
||||
return <div className='action-list'>{actions.map(actionEntry => {
|
||||
const { action, actionId } = actionEntry;
|
||||
return <div
|
||||
className={'action-entry' + (actionEntry === selectedAction ? ' selected' : '')}
|
||||
key={actionId}
|
||||
onClick={() => onSelected(actionEntry)}>
|
||||
<div className='action-header'>
|
||||
<div className='action-title'>{action.action}</div>
|
||||
{action.selector && <div className='action-selector' title={action.selector}>{action.selector}</div>}
|
||||
{action.action === 'goto' && action.value && <div className='action-url' title={action.value}>{action.value}</div>}
|
||||
</div>
|
||||
<div className='action-thumbnail'>
|
||||
{action.snapshot ? <img src={`action-preview/${actionId}.png`} /> : 'No snapshot available'}
|
||||
</div>
|
||||
</div>;
|
||||
})}</div>;
|
||||
};
|
30
src/cli/traceViewer/web/ui/contextSelector.css
Normal file
30
src/cli/traceViewer/web/ui/contextSelector.css
Normal file
@ -0,0 +1,30 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.context-selector {
|
||||
min-width: 38px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
padding: 0 13px 0 5px;
|
||||
position: relative;
|
||||
height: 22px;
|
||||
align-self: center;
|
||||
margin-right: 20px;
|
||||
background: rgba(222, 222, 222, 0.3);
|
||||
color: white;
|
||||
border-radius: 3px;
|
||||
outline: none !important;
|
||||
}
|
41
src/cli/traceViewer/web/ui/contextSelector.tsx
Normal file
41
src/cli/traceViewer/web/ui/contextSelector.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
/*
|
||||
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 React from 'react';
|
||||
import { ContextEntry } from '../../traceModel';
|
||||
import './contextSelector.css';
|
||||
|
||||
export const ContextSelector: React.FunctionComponent<{
|
||||
contexts: ContextEntry[],
|
||||
context: ContextEntry,
|
||||
onChange: (contextEntry: ContextEntry) => void,
|
||||
}> = ({ contexts, context, onChange }) => {
|
||||
return (
|
||||
<select
|
||||
className='context-selector'
|
||||
style={{
|
||||
visibility: contexts.length <= 1 ? 'hidden' : 'visible',
|
||||
}}
|
||||
value={context.created.contextId}
|
||||
onChange={e => {
|
||||
const newIndex = e.target.selectedIndex;
|
||||
onChange(contexts[newIndex]);
|
||||
}}
|
||||
>
|
||||
{contexts.map(entry => <option value={entry.created.contextId} key={entry.created.contextId}>{entry.name}</option>)}
|
||||
</select>
|
||||
);
|
||||
};
|
45
src/cli/traceViewer/web/ui/filmStrip.css
Normal file
45
src/cli/traceViewer/web/ui/filmStrip.css
Normal file
@ -0,0 +1,45 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.film-strip {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.film-strip-lane {
|
||||
flex: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.film-strip-frame {
|
||||
flex: none;
|
||||
pointer-events: none;
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
|
||||
.film-strip-hover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: white;
|
||||
box-shadow: rgba(0, 0, 0, 0.133) 0px 1.6px 3.6px 0px, rgba(0, 0, 0, 0.11) 0px 0.3px 0.9px 0px;
|
||||
box-shadow: rgba(0, 0, 0, 0.133) 0px 1.6px 10px 0px, rgba(0, 0, 0, 0.11) 0px 0.3px 10px 0px;
|
||||
z-index: 10;
|
||||
}
|
136
src/cli/traceViewer/web/ui/filmStrip.tsx
Normal file
136
src/cli/traceViewer/web/ui/filmStrip.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
/*
|
||||
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 { ContextEntry, VideoEntry, VideoMetaInfo } from '../../traceModel';
|
||||
import './filmStrip.css';
|
||||
import { Boundaries } from '../geometry';
|
||||
import * as React from 'react';
|
||||
import { useAsyncMemo, useMeasure } from './helpers';
|
||||
|
||||
function imageURL(videoId: string, index: number) {
|
||||
const imageURLpadding = '0'.repeat(3 - String(index + 1).length);
|
||||
return `video-tile/${videoId}/${imageURLpadding}${index + 1}.png`;
|
||||
}
|
||||
|
||||
export const FilmStrip: React.FunctionComponent<{
|
||||
context: ContextEntry,
|
||||
boundaries: Boundaries,
|
||||
previewX?: number,
|
||||
}> = ({ context, boundaries, previewX }) => {
|
||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
||||
|
||||
const videos = React.useMemo(() => {
|
||||
const videos: VideoEntry[] = [];
|
||||
for (const page of context.pages) {
|
||||
if (page.video)
|
||||
videos.push(page.video);
|
||||
}
|
||||
return videos;
|
||||
}, [context]);
|
||||
|
||||
const metaInfos = useAsyncMemo<Map<VideoEntry, VideoMetaInfo | undefined>>(async () => {
|
||||
const infos = new Map<VideoEntry, VideoMetaInfo | undefined>();
|
||||
for (const video of videos)
|
||||
infos.set(video, await window.getVideoMetaInfo(video.videoId));
|
||||
return infos;
|
||||
}, [videos], new Map(), new Map());
|
||||
|
||||
// TODO: pick file from the Y position.
|
||||
const previewVideo = videos[0];
|
||||
const previewMetaInfo = metaInfos.get(previewVideo);
|
||||
let previewIndex = 0;
|
||||
if ((previewX !== undefined) && previewMetaInfo) {
|
||||
const previewTime = boundaries.minimum + (boundaries.maximum - boundaries.minimum) * previewX / measure.width;
|
||||
previewIndex = (previewTime - previewMetaInfo.startTime) / (previewMetaInfo.endTime - previewMetaInfo.startTime) * previewMetaInfo.frames | 0;
|
||||
}
|
||||
|
||||
const previewImage = useAsyncMemo<HTMLImageElement | undefined>(async () => {
|
||||
if (!previewMetaInfo || previewIndex < 0 || previewIndex >= previewMetaInfo.frames)
|
||||
return;
|
||||
const idealWidth = previewMetaInfo.width / 2;
|
||||
const idealHeight = previewMetaInfo.height / 2;
|
||||
const ratio = Math.min(1, (measure.width - 20) / idealWidth);
|
||||
const image = new Image((idealWidth * ratio) | 0, (idealHeight * ratio) | 0);
|
||||
image.src = imageURL(previewVideo.videoId, previewIndex);
|
||||
await new Promise(f => image.onload = f);
|
||||
return image;
|
||||
}, [previewMetaInfo, previewIndex, measure.width, previewVideo], undefined);
|
||||
|
||||
return <div className='film-strip' ref={ref}>{
|
||||
videos.map(video => <FilmStripLane
|
||||
boundaries={boundaries}
|
||||
video={video}
|
||||
metaInfo={metaInfos.get(video)}
|
||||
width={measure.width}
|
||||
key={video.videoId}
|
||||
/>)
|
||||
}
|
||||
{(previewX !== undefined) && previewMetaInfo && previewImage &&
|
||||
<div className='film-strip-hover' style={{
|
||||
width: previewImage.width + 'px',
|
||||
height: previewImage.height + 'px',
|
||||
top: measure.bottom + 5 + 'px',
|
||||
left: Math.min(previewX, measure.width - previewImage.width - 10) + 'px',
|
||||
}}>
|
||||
<img src={previewImage.src} width={previewImage.width} height={previewImage.height} />
|
||||
</div>
|
||||
}
|
||||
</div>;
|
||||
};
|
||||
|
||||
const FilmStripLane: React.FunctionComponent<{
|
||||
boundaries: Boundaries,
|
||||
video: VideoEntry,
|
||||
metaInfo: VideoMetaInfo | undefined,
|
||||
width: number,
|
||||
}> = ({ boundaries, video, metaInfo, width }) => {
|
||||
const frameHeight = 45;
|
||||
const frameMargin = 2.5;
|
||||
|
||||
if (!metaInfo)
|
||||
return <div className='film-strip-lane' style={{ height: (frameHeight + 2 * frameMargin) + 'px' }}></div>;
|
||||
|
||||
const frameWidth = frameHeight / metaInfo.height * metaInfo.width | 0;
|
||||
const boundariesSize = boundaries.maximum - boundaries.minimum;
|
||||
const gapLeft = (metaInfo.startTime - boundaries.minimum) / boundariesSize * width;
|
||||
const gapRight = (boundaries.maximum - metaInfo.endTime) / boundariesSize * width;
|
||||
const effectiveWidth = (metaInfo.endTime - metaInfo.startTime) / boundariesSize * width;
|
||||
|
||||
const frameCount = effectiveWidth / (frameWidth + 2 * frameMargin) | 0;
|
||||
const frameStep = metaInfo.frames / frameCount;
|
||||
const frameGap = frameCount <= 1 ? 0 : (effectiveWidth - (frameWidth + 2 * frameMargin) * frameCount) / (frameCount - 1);
|
||||
|
||||
const frames: JSX.Element[] = [];
|
||||
for (let i = 0; i < metaInfo.frames; i += frameStep) {
|
||||
let index = i | 0;
|
||||
// Always show last frame.
|
||||
if (Math.floor(i + frameStep) >= metaInfo.frames)
|
||||
index = metaInfo.frames - 1;
|
||||
frames.push(<div className='film-strip-frame' key={i} style={{
|
||||
width: frameWidth + 'px',
|
||||
height: frameHeight + 'px',
|
||||
backgroundImage: `url(${imageURL(video.videoId, index)})`,
|
||||
backgroundSize: `${frameWidth}px ${frameHeight}px`,
|
||||
margin: frameMargin + 'px',
|
||||
marginRight: (frameMargin + frameGap) + 'px',
|
||||
}} />);
|
||||
}
|
||||
|
||||
return <div className='film-strip-lane' style={{
|
||||
marginLeft: gapLeft + 'px',
|
||||
marginRight: gapRight + 'px',
|
||||
}}>{frames}</div>;
|
||||
};
|
73
src/cli/traceViewer/web/ui/helpers.tsx
Normal file
73
src/cli/traceViewer/web/ui/helpers.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
/*
|
||||
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 React from 'react';
|
||||
|
||||
// Recalculates the value when dependencies change.
|
||||
export function useAsyncMemo<T>(fn: () => Promise<T>, deps: React.DependencyList, initialValue: T, resetValue?: T) {
|
||||
const [value, setValue] = React.useState<T>(initialValue);
|
||||
React.useEffect(() => {
|
||||
let canceled = false;
|
||||
if (resetValue !== undefined)
|
||||
setValue(resetValue);
|
||||
fn().then(value => {
|
||||
if (!canceled)
|
||||
setValue(value);
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
return value;
|
||||
}
|
||||
|
||||
// Tracks the element size and returns it's contentRect (always has x=0, y=0).
|
||||
export function useMeasure<T extends Element>() {
|
||||
const ref = React.useRef<T | null>(null);
|
||||
const [measure, setMeasure] = React.useState(new DOMRect(0, 0, 10, 10));
|
||||
React.useLayoutEffect(() => {
|
||||
const target = ref.current;
|
||||
if (!target)
|
||||
return;
|
||||
const resizeObserver = new ResizeObserver(entries => {
|
||||
const entry = entries[entries.length - 1];
|
||||
if (entry && entry.contentRect)
|
||||
setMeasure(entry.contentRect);
|
||||
});
|
||||
resizeObserver.observe(target);
|
||||
return () => resizeObserver.unobserve(target);
|
||||
}, [ref]);
|
||||
return [measure, ref] as const;
|
||||
}
|
||||
|
||||
export const Expandable: React.FunctionComponent<{
|
||||
title: JSX.Element,
|
||||
body: JSX.Element,
|
||||
style?: React.CSSProperties,
|
||||
}> = ({ title, body, style }) => {
|
||||
const [expanded, setExpanded] = React.useState(true);
|
||||
return <div style={{ ...style, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
||||
<div
|
||||
className={'codicon codicon-' + (expanded ? 'chevron-down' : 'chevron-right')}
|
||||
style={{ cursor: 'pointer', color: 'var(--color)', marginRight: '4px'}}
|
||||
onClick={() => setExpanded(!expanded)} />
|
||||
{title}
|
||||
</div>
|
||||
{ expanded && <div style={{ display: 'flex', flex: 'auto', margin: '5px 0 5px 20px' }}>{body}</div> }
|
||||
</div>;
|
||||
};
|
68
src/cli/traceViewer/web/ui/networkTab.css
Normal file
68
src/cli/traceViewer/web/ui/networkTab.css
Normal file
@ -0,0 +1,68 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.network-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.network-tab:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.network-request {
|
||||
box-shadow: var(--box-shadow);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
margin-bottom: 10px;
|
||||
background: #fdfcfc;
|
||||
width: 100%;
|
||||
border: 3px solid transparent;
|
||||
flex: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.network-request-title {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.network-request-details {
|
||||
font-family: var(--monospace-font);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.network-request-title > div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.network-request.selected,
|
||||
.network-request:hover {
|
||||
border-color: var(--inactive-focus-ring);
|
||||
}
|
||||
|
||||
.network-request.selected:focus {
|
||||
border-color: var(--orange);
|
||||
}
|
39
src/cli/traceViewer/web/ui/networkTab.tsx
Normal file
39
src/cli/traceViewer/web/ui/networkTab.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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 { ActionEntry } from '../../traceModel';
|
||||
import './networkTab.css';
|
||||
import * as React from 'react';
|
||||
import { Expandable } from './helpers';
|
||||
|
||||
export const NetworkTab: React.FunctionComponent<{
|
||||
actionEntry: ActionEntry | undefined,
|
||||
}> = ({ actionEntry }) => {
|
||||
const [selected, setSelected] = React.useState(0);
|
||||
return <div className='network-tab'>{
|
||||
(actionEntry ? actionEntry.resources : []).map((resource, index) => {
|
||||
return <div key={index}
|
||||
className={'network-request ' + (index === selected ? 'selected' : '')}
|
||||
onClick={() => setSelected(index)}>
|
||||
<Expandable style={{ width: '100%' }} title={
|
||||
<div className='network-request-title'><div>resource.url</div></div>
|
||||
} body={
|
||||
<div className='network-request-details'>{resource.responseHeaders.map(pair => `${pair.name}: ${pair.value}`).join('\n')}</div>
|
||||
}/>
|
||||
</div>;
|
||||
})
|
||||
}</div>;
|
||||
};
|
86
src/cli/traceViewer/web/ui/propertiesTabbedPane.css
Normal file
86
src/cli/traceViewer/web/ui/propertiesTabbedPane.css
Normal file
@ -0,0 +1,86 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.properties-tabbed-pane {
|
||||
display: flex;
|
||||
flex: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.properties-tab-content {
|
||||
display: flex;
|
||||
flex: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.properties-tab-strip {
|
||||
flex: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.properties-tab-strip:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.properties-tab-element {
|
||||
padding: 2px 6px 0 6px;
|
||||
margin-right: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
width: 80px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.properties-tab-label {
|
||||
max-width: 250px;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.properties-tab-element.selected {
|
||||
border-bottom-color: var(--color);
|
||||
}
|
||||
|
||||
.properties-tab-element:hover {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.snapshot-wrapper {
|
||||
flex: auto;
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
.snapshot-container {
|
||||
display: block;
|
||||
background: white;
|
||||
outline: 1px solid #aaa;
|
||||
}
|
||||
|
||||
iframe#snapshot {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
88
src/cli/traceViewer/web/ui/propertiesTabbedPane.tsx
Normal file
88
src/cli/traceViewer/web/ui/propertiesTabbedPane.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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 { ActionEntry } from '../../traceModel';
|
||||
import { Size } from '../geometry';
|
||||
import { NetworkTab } from './networkTab';
|
||||
import { SourceTab } from './sourceTab';
|
||||
import './propertiesTabbedPane.css';
|
||||
import * as React from 'react';
|
||||
import { useMeasure } from './helpers';
|
||||
|
||||
export const PropertiesTabbedPane: React.FunctionComponent<{
|
||||
actionEntry: ActionEntry | undefined,
|
||||
snapshotSize: Size,
|
||||
}> = ({ actionEntry, snapshotSize }) => {
|
||||
const [selected, setSelected] = React.useState<'snapshot' | 'source' | 'network'>('snapshot');
|
||||
return <div className='properties-tabbed-pane'>
|
||||
<div className='vbox'>
|
||||
<div className='hbox' style={{ flex: 'none' }}>
|
||||
<div className='properties-tab-strip'>
|
||||
<div className={'properties-tab-element ' + (selected === 'snapshot' ? 'selected' : '')}
|
||||
onClick={() => setSelected('snapshot')}>
|
||||
<div className='properties-tab-label'>Snapshot</div>
|
||||
</div>
|
||||
<div className={'properties-tab-element ' + (selected === 'source' ? 'selected' : '')}
|
||||
onClick={() => setSelected('source')}>
|
||||
<div className='properties-tab-label'>Source</div>
|
||||
</div>
|
||||
<div className={'properties-tab-element ' + (selected === 'network' ? 'selected' : '')}
|
||||
onClick={() => setSelected('network')}>
|
||||
<div className='properties-tab-label'>Network</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='properties-tab-content' style={{ display: selected === 'snapshot' ? 'flex' : 'none' }}>
|
||||
<SnapshotTab actionEntry={actionEntry} snapshotSize={snapshotSize} />
|
||||
</div>
|
||||
<div className='properties-tab-content' style={{ display: selected === 'source' ? 'flex' : 'none' }}>
|
||||
<SourceTab actionEntry={actionEntry} />
|
||||
</div>
|
||||
<div className='properties-tab-content' style={{ display: selected === 'network' ? 'flex' : 'none' }}>
|
||||
<NetworkTab actionEntry={actionEntry} />
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SnapshotTab: React.FunctionComponent<{
|
||||
actionEntry: ActionEntry | undefined,
|
||||
snapshotSize: Size,
|
||||
}> = ({ actionEntry, snapshotSize }) => {
|
||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
||||
|
||||
const iframeRef = React.createRef<HTMLIFrameElement>();
|
||||
React.useEffect(() => {
|
||||
if (iframeRef.current && !actionEntry)
|
||||
iframeRef.current.src = 'about:blank';
|
||||
}, [actionEntry, iframeRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (actionEntry)
|
||||
window.renderSnapshot(actionEntry.action);
|
||||
}, [actionEntry]);
|
||||
|
||||
const scale = Math.min(measure.width / snapshotSize.width, measure.height / snapshotSize.height);
|
||||
return <div ref={ref} className='snapshot-wrapper'>
|
||||
<div className='snapshot-container' style={{
|
||||
width: snapshotSize.width + 'px',
|
||||
height: snapshotSize.height + 'px',
|
||||
transform: `translate(${-snapshotSize.width * (1 - scale) / 2}px, ${-snapshotSize.height * (1 - scale) / 2}px) scale(${scale})`,
|
||||
}}>
|
||||
<iframe ref={iframeRef} id='snapshot' name='snapshot'></iframe>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
43
src/cli/traceViewer/web/ui/sourceTab.css
Normal file
43
src/cli/traceViewer/web/ui/sourceTab.css
Normal file
@ -0,0 +1,43 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.source-tab {
|
||||
flex: auto;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
background: #fdfcfc;
|
||||
font-family: var(--monospace-font);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-line-number {
|
||||
width: 80px;
|
||||
border-right: 1px solid var(--separator);
|
||||
display: inline-block;
|
||||
margin-right: 3px;
|
||||
text-align: end;
|
||||
padding-right: 4px;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.source-code {
|
||||
white-space: pre;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.source-line-highlight {
|
||||
background-color: #ff69b460;
|
||||
}
|
75
src/cli/traceViewer/web/ui/sourceTab.tsx
Normal file
75
src/cli/traceViewer/web/ui/sourceTab.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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 { ActionEntry } from '../../traceModel';
|
||||
import * as React from 'react';
|
||||
import { useAsyncMemo } from './helpers';
|
||||
import './sourceTab.css';
|
||||
import '../../../../third_party/highlightjs/highlightjs/tomorrow.css';
|
||||
import * as highlightjs from '../../../../third_party/highlightjs/highlightjs';
|
||||
|
||||
export const SourceTab: React.FunctionComponent<{
|
||||
actionEntry: ActionEntry | undefined,
|
||||
}> = ({ actionEntry }) => {
|
||||
const location = React.useMemo<{ fileName?: string, lineNumber?: number, value?: string }>(() => {
|
||||
if (!actionEntry)
|
||||
return { value: '' };
|
||||
const { action } = actionEntry;
|
||||
const frames = action.stack!.split('\n').slice(1);
|
||||
const frame = frames.filter(frame => !frame.includes('playwright/lib/') && !frame.includes('playwright/src/'))[0];
|
||||
if (!frame)
|
||||
return { value: action.stack! };
|
||||
const match = frame.match(/at [^(]+\(([^:]+):(\d+):\d+\)/) || frame.match(/at ([^:^(]+):(\d+):\d+/);
|
||||
if (!match)
|
||||
return { value: action.stack! };
|
||||
const fileName = match[1];
|
||||
const lineNumber = parseInt(match[2], 10);
|
||||
return { fileName, lineNumber };
|
||||
}, [actionEntry]);
|
||||
|
||||
const content = useAsyncMemo<string[]>(async () => {
|
||||
const value = location.fileName ? await window.readFile(location.fileName) : location.value;
|
||||
const result = [];
|
||||
let continuation: any;
|
||||
for (const line of (value || '').split('\n')) {
|
||||
const highlighted = highlightjs.highlight('javascript', line, true, continuation);
|
||||
continuation = highlighted.top;
|
||||
result.push(highlighted.value);
|
||||
}
|
||||
return result;
|
||||
}, [location.fileName, location.value], []);
|
||||
|
||||
const targetLineRef = React.createRef<HTMLDivElement>();
|
||||
React.useLayoutEffect(() => {
|
||||
if (targetLineRef.current)
|
||||
targetLineRef.current.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
}, [content, location.lineNumber, targetLineRef]);
|
||||
|
||||
return <div className='source-tab'>{
|
||||
content.map((markup, index) => {
|
||||
const isTargetLine = (index + 1) === location.lineNumber;
|
||||
return <div
|
||||
key={index}
|
||||
className={isTargetLine ? 'source-line-highlight' : ''}
|
||||
ref={isTargetLine ? targetLineRef : null}
|
||||
>
|
||||
<div className='source-line-number'>{index + 1}</div>
|
||||
<div className='source-code' dangerouslySetInnerHTML={{ __html: markup }}></div>
|
||||
</div>;
|
||||
})
|
||||
}
|
||||
</div>;
|
||||
};
|
110
src/cli/traceViewer/web/ui/timeline.css
Normal file
110
src/cli/traceViewer/web/ui/timeline.css
Normal file
@ -0,0 +1,110 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.timeline-view {
|
||||
flex: none;
|
||||
flex-basis: 60px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
padding: 20px 0 5px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.timeline-divider {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
.timeline-label {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 3px;
|
||||
font-size: 80%;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-lane {
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
flex-basis: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-grid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.timeline-lane.timeline-action-labels {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.timeline-lane.timeline-actions {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.timeline-action {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: red;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.timeline-action.click {
|
||||
background-color: var(--green);
|
||||
}
|
||||
|
||||
.timeline-action.fill,
|
||||
.timeline-action.press {
|
||||
background-color: var(--orange);
|
||||
}
|
||||
|
||||
.timeline-action.goto {
|
||||
background-color: var(--blue);
|
||||
}
|
||||
|
||||
.timeline-action-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin-left: 2px;
|
||||
background-color: #fffffff0;
|
||||
}
|
||||
|
||||
.timeline-time-bar {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background-color: black;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-time-bar.timeline-time-bar-hover {
|
||||
background-color: var(--light-pink);
|
||||
}
|
156
src/cli/traceViewer/web/ui/timeline.tsx
Normal file
156
src/cli/traceViewer/web/ui/timeline.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications 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 { ContextEntry } from '../../traceModel';
|
||||
import './timeline.css';
|
||||
import { FilmStrip } from './filmStrip';
|
||||
import { Boundaries } from '../geometry';
|
||||
import * as React from 'react';
|
||||
import { useMeasure } from './helpers';
|
||||
|
||||
export const Timeline: React.FunctionComponent<{
|
||||
context: ContextEntry,
|
||||
boundaries: Boundaries,
|
||||
}> = ({ context, boundaries }) => {
|
||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
||||
const [previewX, setPreviewX] = React.useState<number | undefined>();
|
||||
|
||||
const offsets = React.useMemo(() => {
|
||||
return calculateDividerOffsets(measure.width, boundaries);
|
||||
}, [measure.width, boundaries]);
|
||||
const actionEntries = React.useMemo(() => {
|
||||
const actions = [];
|
||||
for (const page of context.pages)
|
||||
actions.push(...page.actions);
|
||||
return actions;
|
||||
}, [context]);
|
||||
const actionTimes = React.useMemo(() => {
|
||||
return actionEntries.map(entry => {
|
||||
return {
|
||||
action: entry.action,
|
||||
actionId: entry.actionId,
|
||||
left: timeToPercent(measure.width, boundaries, entry.action.startTime!),
|
||||
right: timeToPercent(measure.width, boundaries, entry.action.endTime!),
|
||||
};
|
||||
});
|
||||
}, [actionEntries, boundaries, measure.width]);
|
||||
|
||||
const onMouseMove = (event: React.MouseEvent) => {
|
||||
if (ref.current)
|
||||
setPreviewX(event.clientX - ref.current.getBoundingClientRect().left);
|
||||
};
|
||||
const onMouseLeave = () => {
|
||||
setPreviewX(undefined);
|
||||
};
|
||||
|
||||
return <div ref={ref} className='timeline-view' onMouseMove={onMouseMove} onMouseOver={onMouseMove} onMouseLeave={onMouseLeave}>
|
||||
<div className='timeline-grid'>{
|
||||
offsets.map((offset, index) => {
|
||||
return <div key={index} className='timeline-divider' style={{ left: offset.percent + '%' }}>
|
||||
<div className='timeline-label'>{msToString(offset.time - boundaries.minimum)}</div>
|
||||
</div>;
|
||||
})
|
||||
}</div>
|
||||
<div className='timeline-lane timeline-action-labels'>{
|
||||
actionTimes.map(({ action, actionId, left }) => {
|
||||
return <div key={actionId}
|
||||
className={'timeline-action-label ' + action.action}
|
||||
style={{ left: left + '%' }}
|
||||
>
|
||||
{action.action}
|
||||
</div>;
|
||||
})
|
||||
}</div>
|
||||
<div className='timeline-lane timeline-actions'>{
|
||||
actionTimes.map(({ action, actionId, left, right }) => {
|
||||
return <div key={actionId}
|
||||
className={'timeline-action ' + action.action}
|
||||
style={{
|
||||
left: left + '%',
|
||||
width: (right - left) + '%',
|
||||
}}
|
||||
></div>;
|
||||
})
|
||||
}</div>
|
||||
<FilmStrip context={context} boundaries={boundaries} previewX={previewX} />
|
||||
<div className='timeline-time-bar timeline-time-bar-hover' style={{
|
||||
display: (previewX !== undefined) ? 'block' : 'none',
|
||||
left: (previewX || 0) + 'px',
|
||||
}}></div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
function calculateDividerOffsets(clientWidth: number, boundaries: Boundaries): { percent: number, time: number }[] {
|
||||
const minimumGap = 64;
|
||||
let dividerCount = clientWidth / minimumGap;
|
||||
const boundarySpan = boundaries.maximum - boundaries.minimum;
|
||||
const pixelsPerMillisecond = clientWidth / boundarySpan;
|
||||
let sectionTime = boundarySpan / dividerCount;
|
||||
|
||||
const logSectionTime = Math.ceil(Math.log(sectionTime) / Math.LN10);
|
||||
sectionTime = Math.pow(10, logSectionTime);
|
||||
if (sectionTime * pixelsPerMillisecond >= 5 * minimumGap)
|
||||
sectionTime = sectionTime / 5;
|
||||
if (sectionTime * pixelsPerMillisecond >= 2 * minimumGap)
|
||||
sectionTime = sectionTime / 2;
|
||||
|
||||
const firstDividerTime = boundaries.minimum;
|
||||
let lastDividerTime = boundaries.maximum;
|
||||
lastDividerTime += minimumGap / pixelsPerMillisecond;
|
||||
dividerCount = Math.ceil((lastDividerTime - firstDividerTime) / sectionTime);
|
||||
|
||||
if (!sectionTime)
|
||||
dividerCount = 0;
|
||||
|
||||
const offsets = [];
|
||||
for (let i = 0; i < dividerCount; ++i) {
|
||||
const time = firstDividerTime + sectionTime * i;
|
||||
offsets.push({ percent: timeToPercent(clientWidth, boundaries, time), time });
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
function timeToPercent(clientWidth: number, boundaries: Boundaries, time: number): number {
|
||||
const position = (time - boundaries.minimum) / (boundaries.maximum - boundaries.minimum) * clientWidth;
|
||||
return 100 * position / clientWidth;
|
||||
}
|
||||
|
||||
function msToString(ms: number): string {
|
||||
if (!isFinite(ms))
|
||||
return '-';
|
||||
|
||||
if (ms === 0)
|
||||
return '0';
|
||||
|
||||
if (ms < 1000)
|
||||
return ms.toFixed(0) + 'ms';
|
||||
|
||||
const seconds = ms / 1000;
|
||||
if (seconds < 60)
|
||||
return seconds.toFixed(1) + 's';
|
||||
|
||||
const minutes = seconds / 60;
|
||||
if (minutes < 60)
|
||||
return minutes.toFixed(1) + 's';
|
||||
|
||||
const hours = minutes / 60;
|
||||
if (hours < 24)
|
||||
return hours.toFixed(1) + 'h';
|
||||
|
||||
const days = hours / 24;
|
||||
return days.toFixed(1) + 'h';
|
||||
}
|
59
src/cli/traceViewer/web/ui/workbench.css
Normal file
59
src/cli/traceViewer/web/ui/workbench.css
Normal file
@ -0,0 +1,59 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.workbench {
|
||||
contain: size;
|
||||
}
|
||||
|
||||
.workbench .header {
|
||||
display: flex;
|
||||
background-color: #000;
|
||||
flex: none;
|
||||
flex-basis: 48px;
|
||||
line-height: 48px;
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.workbench tab-content {
|
||||
padding: 25px;
|
||||
contain: size;
|
||||
}
|
||||
|
||||
.workbench tab-strip {
|
||||
margin-left: calc(-1*var(--sidebar-width));
|
||||
padding-left: var(--sidebar-width);
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
|
||||
.workbench .logo {
|
||||
font-size: 20px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.workbench .product {
|
||||
font-weight: 600;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.workbench .spacer {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
tab-strip {
|
||||
background-color: var(--light-background);
|
||||
}
|
||||
|
67
src/cli/traceViewer/web/ui/workbench.tsx
Normal file
67
src/cli/traceViewer/web/ui/workbench.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
/*
|
||||
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 { ActionEntry, TraceModel } from '../../traceModel';
|
||||
import { ActionList } from './actionList';
|
||||
import { PropertiesTabbedPane } from './propertiesTabbedPane';
|
||||
import { Timeline } from './timeline';
|
||||
import './workbench.css';
|
||||
import * as React from 'react';
|
||||
import { ContextSelector } from './contextSelector';
|
||||
|
||||
export const Workbench: React.FunctionComponent<{
|
||||
traceModel: TraceModel,
|
||||
}> = ({ traceModel }) => {
|
||||
const [context, setContext] = React.useState(traceModel.contexts[0]);
|
||||
const [action, setAction] = React.useState<ActionEntry | undefined>();
|
||||
|
||||
const actions = React.useMemo(() => {
|
||||
const actions: ActionEntry[] = [];
|
||||
for (const page of context.pages)
|
||||
actions.push(...page.actions);
|
||||
return actions;
|
||||
}, [context]);
|
||||
|
||||
const snapshotSize = context.created.viewportSize!;
|
||||
|
||||
return <div className='vbox workbench'>
|
||||
<div className='hbox header'>
|
||||
<div className='logo'>🎭</div>
|
||||
<div className='product'>Playwright</div>
|
||||
<div className='spacer'></div>
|
||||
<ContextSelector
|
||||
contexts={traceModel.contexts}
|
||||
context={context}
|
||||
onChange={context => {
|
||||
setContext(context);
|
||||
setAction(undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ background: 'white', paddingLeft: '20px', flex: 'none' }}>
|
||||
<Timeline
|
||||
context={context}
|
||||
boundaries={{ minimum: context.startTime, maximum: context.endTime }}
|
||||
/>
|
||||
</div>
|
||||
<div className='hbox'>
|
||||
<div style={{ display: 'flex', flex: 'none' }}>
|
||||
<ActionList actions={actions} selectedAction={action} onSelected={action => setAction(action)} />
|
||||
</div>
|
||||
<PropertiesTabbedPane actionEntry={action} snapshotSize={snapshotSize} />
|
||||
</div>
|
||||
</div>;
|
||||
};
|
40
src/cli/traceViewer/web/web.webpack.config.js
Normal file
40
src/cli/traceViewer/web/web.webpack.config.js
Normal file
@ -0,0 +1,40 @@
|
||||
const path = require('path');
|
||||
const HtmlWebPackPlugin = require('html-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
mode: 'production',
|
||||
entry: {
|
||||
app: path.join(__dirname, 'index.tsx'),
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js', '.tsx', '.jsx']
|
||||
},
|
||||
output: {
|
||||
globalObject: 'self',
|
||||
filename: '[name].bundle.js',
|
||||
path: path.resolve(__dirname, '../../../../lib/cli/traceViewer/web')
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(j|t)sx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader']
|
||||
},
|
||||
{
|
||||
test: /\.ttf$/,
|
||||
use: ['file-loader']
|
||||
}
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebPackPlugin({
|
||||
title: 'Playwright Trace Viewer',
|
||||
template: path.join(__dirname, 'index.html'),
|
||||
})
|
||||
]
|
||||
};
|
@ -20,6 +20,7 @@ const InlineSource = require('../../server/injected/webpack-inline-source-plugin
|
||||
module.exports = {
|
||||
entry: path.join(__dirname, 'consoleApi.ts'),
|
||||
devtool: 'source-map',
|
||||
mode: 'development',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
|
@ -20,6 +20,7 @@ const InlineSource = require('./webpack-inline-source-plugin.js');
|
||||
module.exports = {
|
||||
entry: path.join(__dirname, 'injectedScript.ts'),
|
||||
devtool: 'source-map',
|
||||
mode: 'development',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
|
@ -20,6 +20,7 @@ const InlineSource = require('./webpack-inline-source-plugin.js');
|
||||
module.exports = {
|
||||
entry: path.join(__dirname, 'utilityScript.ts'),
|
||||
devtool: 'source-map',
|
||||
mode: 'development',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
|
72
src/third_party/highlightjs/highlightjs/tomorrow.css
vendored
Normal file
72
src/third_party/highlightjs/highlightjs/tomorrow.css
vendored
Normal file
@ -0,0 +1,72 @@
|
||||
/* http://jmblog.github.com/color-themes-for-google-code-highlightjs */
|
||||
|
||||
/* Tomorrow Comment */
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #8e908c;
|
||||
}
|
||||
|
||||
/* Tomorrow Red */
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class,
|
||||
.hljs-regexp,
|
||||
.hljs-deletion {
|
||||
color: #c82829;
|
||||
}
|
||||
|
||||
/* Tomorrow Orange */
|
||||
.hljs-number,
|
||||
.hljs-built_in,
|
||||
.hljs-builtin-name,
|
||||
.hljs-literal,
|
||||
.hljs-type,
|
||||
.hljs-params,
|
||||
.hljs-meta,
|
||||
.hljs-link {
|
||||
color: #f5871f;
|
||||
}
|
||||
|
||||
/* Tomorrow Yellow */
|
||||
.hljs-attribute {
|
||||
color: #eab700;
|
||||
}
|
||||
|
||||
/* Tomorrow Green */
|
||||
.hljs-string,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet,
|
||||
.hljs-addition {
|
||||
color: #718c00;
|
||||
}
|
||||
|
||||
/* Tomorrow Blue */
|
||||
.hljs-title,
|
||||
.hljs-section {
|
||||
color: #4271ae;
|
||||
}
|
||||
|
||||
/* Tomorrow Purple */
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag {
|
||||
color: #8959a8;
|
||||
}
|
||||
|
||||
.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
background: white;
|
||||
color: #4d4d4c;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: bold;
|
||||
}
|
2
src/third_party/highlightjs/roll.sh
vendored
2
src/third_party/highlightjs/roll.sh
vendored
@ -6,6 +6,7 @@ set +x
|
||||
# https://github.com/highlightjs/highlight.js/releases
|
||||
RELEASE_REVISION="af20048d5c601d6e30016d8171317bfdf8a6c242"
|
||||
LANGUAGES="javascript python csharp"
|
||||
STYLES="tomorrow.css"
|
||||
|
||||
trap "cd $(pwd -P)" EXIT
|
||||
SCRIPT_PATH="$(cd "$(dirname "$0")" ; pwd -P)"
|
||||
@ -27,5 +28,6 @@ mkdir -p ./highlightjs
|
||||
cp -R output/highlight.js/build/lib/* highlightjs/
|
||||
cp output/highlight.js/build/LICENSE highlightjs/
|
||||
cp output/highlight.js/build/types/index.d.ts highlightjs/
|
||||
cp output/highlight.js/build/styles/${STYLES} highlightjs/
|
||||
echo $'\n'"export = hljs;"$'\n' >> highlightjs/index.d.ts
|
||||
rm -rf ./output
|
||||
|
@ -8,9 +8,10 @@
|
||||
"outDir": "./lib",
|
||||
"strict": true,
|
||||
"allowJs": true,
|
||||
"declaration": false
|
||||
"declaration": false,
|
||||
"jsx": "react"
|
||||
},
|
||||
"compileOnSave": true,
|
||||
"include": ["src/**/*.ts", "src/**/*.js"],
|
||||
"exclude": ["node_modules", "src/.eslintrc.js"]
|
||||
"exclude": ["node_modules", "src/.eslintrc.js", "src/cli/traceViewer/web/**"]
|
||||
}
|
||||
|
@ -67,11 +67,12 @@ const webPackFiles = [
|
||||
'src/server/injected/utilityScript.webpack.config.js',
|
||||
'src/debug/injected/consoleApi.webpack.config.js',
|
||||
'src/cli/injected/recorder.webpack.config.js',
|
||||
'src/cli/traceViewer/web/web.webpack.config.js',
|
||||
];
|
||||
for (const file of webPackFiles) {
|
||||
steps.push({
|
||||
command: 'npx',
|
||||
args: ['webpack', '--config', filePath(file), '--mode', 'development', ...(watchMode ? ['--watch', '--silent'] : [])],
|
||||
args: ['webpack', '--config', filePath(file), ...(watchMode ? ['--watch', '--silent', '--mode', 'development'] : [])],
|
||||
shell: true,
|
||||
});
|
||||
}
|
||||
|
@ -150,7 +150,7 @@ DEPS['src/remote/'] = ['src/client/', 'src/debug/', 'src/dispatchers/', 'src/ser
|
||||
DEPS['src/service.ts'] = ['src/remote/'];
|
||||
|
||||
// CLI should only use client-side features.
|
||||
DEPS['src/cli/'] = ['src/cli/**', 'src/client/**', 'src/install/**', 'src/generated/', 'src/server/injected/', 'src/debug/injected/'];
|
||||
DEPS['src/cli/'] = ['src/cli/**', 'src/client/**', 'src/install/**', 'src/generated/', 'src/server/injected/', 'src/debug/injected/', 'src/trace/**', 'src/utils/**'];
|
||||
|
||||
checkDeps().catch(e => {
|
||||
console.error(e && e.stack ? e.stack : e);
|
||||
|
Loading…
Reference in New Issue
Block a user