@ -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(); | |||
} | |||
} | |||
} |
@ -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; | |||
} | |||
} |
@ -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 }; | |||
} |
@ -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', | |||
}; |
@ -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 | |||
}; | |||
} |
@ -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); | |||
} |
@ -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; | |||
}; |
@ -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> |
@ -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')); | |||
})(); |
@ -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. |
@ -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'; } | |||