Browse Source

feat(cli): bring in trace viewer (#4920)

pull/4940/head
Dmitry Gozman 3 months ago
committed by GitHub
parent
commit
2e05feac25
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 3583 additions and 4 deletions
  1. +3
    -0
      .eslintrc.js
  2. +807
    -0
      package-lock.json
  3. +10
    -0
      package.json
  4. +17
    -0
      src/cli/cli.ts
  5. +1
    -0
      src/cli/injected/recorder.webpack.config.js
  6. +136
    -0
      src/cli/traceViewer/screenshotGenerator.ts
  7. +122
    -0
      src/cli/traceViewer/snapshotRouter.ts
  8. +154
    -0
      src/cli/traceViewer/traceModel.ts
  9. +181
    -0
      src/cli/traceViewer/traceViewer.ts
  10. +87
    -0
      src/cli/traceViewer/videoTileGenerator.ts
  11. +122
    -0
      src/cli/traceViewer/web/common.css
  12. +25
    -0
      src/cli/traceViewer/web/geometry.ts
  13. +27
    -0
      src/cli/traceViewer/web/index.html
  14. +57
    -0
      src/cli/traceViewer/web/index.tsx
  15. +21
    -0
      src/cli/traceViewer/web/third_party/vscode/LICENSE.txt
  16. +440
    -0
      src/cli/traceViewer/web/third_party/vscode/codicon.css
  17. BIN
      src/cli/traceViewer/web/third_party/vscode/codicon.ttf
  18. +92
    -0
      src/cli/traceViewer/web/ui/actionList.css
  19. +42
    -0
      src/cli/traceViewer/web/ui/actionList.tsx
  20. +30
    -0
      src/cli/traceViewer/web/ui/contextSelector.css
  21. +41
    -0
      src/cli/traceViewer/web/ui/contextSelector.tsx
  22. +45
    -0
      src/cli/traceViewer/web/ui/filmStrip.css
  23. +136
    -0
      src/cli/traceViewer/web/ui/filmStrip.tsx
  24. +73
    -0
      src/cli/traceViewer/web/ui/helpers.tsx
  25. +68
    -0
      src/cli/traceViewer/web/ui/networkTab.css
  26. +39
    -0
      src/cli/traceViewer/web/ui/networkTab.tsx
  27. +86
    -0
      src/cli/traceViewer/web/ui/propertiesTabbedPane.css
  28. +88
    -0
      src/cli/traceViewer/web/ui/propertiesTabbedPane.tsx
  29. +43
    -0
      src/cli/traceViewer/web/ui/sourceTab.css
  30. +75
    -0
      src/cli/traceViewer/web/ui/sourceTab.tsx
  31. +110
    -0
      src/cli/traceViewer/web/ui/timeline.css
  32. +156
    -0
      src/cli/traceViewer/web/ui/timeline.tsx
  33. +59
    -0
      src/cli/traceViewer/web/ui/workbench.css
  34. +67
    -0
      src/cli/traceViewer/web/ui/workbench.tsx
  35. +40
    -0
      src/cli/traceViewer/web/web.webpack.config.js
  36. +1
    -0
      src/debug/injected/consoleApi.webpack.config.js
  37. +1
    -0
      src/server/injected/injectedScript.webpack.config.js
  38. +1
    -0
      src/server/injected/utilityScript.webpack.config.js
  39. +72
    -0
      src/third_party/highlightjs/highlightjs/tomorrow.css
  40. +2
    -0
      src/third_party/highlightjs/roll.sh
  41. +3
    -2
      tsconfig.json
  42. +2
    -1
      utils/build/build.js
  43. +1
    -1
      utils/check_deps.js

+ 3
- 0
.eslintrc.js View File

@ -5,6 +5,9 @@ module.exports = {
ecmaVersion: 9,
sourceType: 'module',
},
extends: [
'plugin:react-hooks/recommended'
],
/**
* ESLint rules


+ 807
- 0
package-lock.json
File diff suppressed because it is too large
View File


+ 10
- 0
package.json View File

@ -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",


+ 17
- 0
src/cli/cli.ts View File

@ -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')


+ 1
- 0
src/cli/injected/recorder.webpack.config.js View File

@ -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
- 0
src/cli/traceViewer/screenshotGenerator.ts View 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
- 0
src/cli/traceViewer/snapshotRouter.ts View 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
- 0
src/cli/traceViewer/traceModel.ts View 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
- 0
src/cli/traceViewer/traceViewer.ts View 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
- 0
src/cli/traceViewer/videoTileGenerator.ts View 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
- 0
src/cli/traceViewer/web/common.css View 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
- 0
src/cli/traceViewer/web/geometry.ts View 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
- 0
src/cli/traceViewer/web/index.html View 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
- 0
src/cli/traceViewer/web/index.tsx View 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
- 0
src/cli/traceViewer/web/third_party/vscode/LICENSE.txt View 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
- 0
src/cli/traceViewer/web/third_party/vscode/codicon.css View 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'; }