diff --git a/application.tsx b/application.tsx index 52d7cc3..4153a76 100644 --- a/application.tsx +++ b/application.tsx @@ -2,16 +2,7 @@ import {h} from 'preact' import {StyleSheet, css} from 'aphrodite' import {ReloadableComponent, SerializedComponent} from './reloadable' -// TODO(jlfwong): Load these async, since none of them are required for initial render -import {importFromBGFlameGraph} from './import/bg-flamegraph' -import {importFromStackprof} from './import/stackprof' -import {importFromChromeTimeline, importFromChromeCPUProfile} from './import/chrome' -import {importFromFirefox} from './import/firefox' -import { - importFromInstrumentsDeepCopy, - importFromInstrumentsTrace, - FileSystemDirectoryEntry, -} from './import/instruments' +import {FileSystemDirectoryEntry} from './import/file-system-entry' import {FlamechartRenderer, FlamechartRowAtlasKey} from './flamechart-renderer' import {CanvasContext} from './canvas-context' @@ -28,6 +19,16 @@ import {RowAtlas} from './row-atlas' import {importAsmJsSymbolMap} from './asm-js' import {SandwichView} from './sandwich-view' +const importModule = import('./import') +// Force eager loading of the module +importModule.then(() => {}) +async function importProfile(fileName: string, contents: string): Promise { + return (await importModule).importProfile(fileName, contents) +} +async function importFromFileSystemDirectoryEntry(entry: FileSystemDirectoryEntry) { + return (await importModule).importFromFileSystemDirectoryEntry(entry) +} + declare function require(x: string): any const exampleProfileURL = require('./sample/profiles/stackcollapse/perf-vertx-stacks-01-collapsed-all.txt') @@ -60,68 +61,6 @@ interface ToolbarProps extends ApplicationState { setViewMode(order: ViewMode): void } -function importProfile(fileName: string, contents: string): Profile | null { - try { - // First pass: Check known file format names to infer the file type - if (fileName.endsWith('.cpuprofile')) { - console.log('Importing as Chrome CPU Profile') - return importFromChromeCPUProfile(JSON.parse(contents)) - } else if (fileName.endsWith('.chrome.json') || /Profile-\d{8}T\d{6}/.exec(fileName)) { - console.log('Importing as Chrome Timeline') - return importFromChromeTimeline(JSON.parse(contents)) - } else if (fileName.endsWith('.stackprof.json')) { - console.log('Importing as stackprof profile') - return importFromStackprof(JSON.parse(contents)) - } else if (fileName.endsWith('.instruments.txt')) { - console.log('Importing as Instruments.app deep copy') - return importFromInstrumentsDeepCopy(contents) - } else if (fileName.endsWith('.collapsedstack.txt')) { - console.log('Importing as collapsed stack format') - return importFromBGFlameGraph(contents) - } - - // Second pass: Try to guess what file format it is based on structure - try { - const parsed = JSON.parse(contents) - if (parsed['systemHost'] && parsed['systemHost']['name'] == 'Firefox') { - console.log('Importing as Firefox profile') - return importFromFirefox(parsed) - } else if (Array.isArray(parsed) && parsed[parsed.length - 1].name === 'CpuProfile') { - console.log('Importing as Chrome CPU Profile') - return importFromChromeTimeline(parsed) - } else if ('nodes' in parsed && 'samples' in parsed && 'timeDeltas' in parsed) { - console.log('Importing as Chrome Timeline') - return importFromChromeCPUProfile(parsed) - } else if ('mode' in parsed && 'frames' in parsed) { - console.log('Importing as stackprof profile') - return importFromStackprof(parsed) - } - } catch (e) { - // Format is not JSON - - // If the first line contains "Symbol Name", preceded by a tab, it's probably - // a deep copy from OS X Instruments.app - if (/^[\w \t\(\)]*\tSymbol Name/.exec(contents)) { - console.log('Importing as Instruments.app deep copy') - return importFromInstrumentsDeepCopy(contents) - } - - // If every line ends with a space followed by a number, it's probably - // the collapsed stack format. - const lineCount = contents.split(/\n/).length - if (lineCount >= 1 && lineCount === contents.split(/ \d+\n/).length) { - console.log('Importing as collapsed stack format') - return importFromBGFlameGraph(contents) - } - } - - return null - } catch (e) { - console.error(e) - return null - } -} - export class Toolbar extends ReloadableComponent { setTimeOrder = () => { this.props.setViewMode(ViewMode.CHRONO_FLAME_CHART) @@ -408,8 +347,8 @@ export class Application extends ReloadableComponent<{}, ApplicationState> { () => new Promise((resolve, reject) => { const reader = new FileReader() - reader.addEventListener('loadend', () => { - const profile = importProfile(file.name, reader.result) + reader.addEventListener('loadend', async () => { + const profile = await importProfile(file.name, reader.result) if (profile) { if (!profile.getName()) { profile.setName(file.name) @@ -460,7 +399,7 @@ export class Application extends ReloadableComponent<{}, ApplicationState> { // Instrument.app file format is actually a directory. if (webkitEntry.isDirectory && webkitEntry.name.endsWith('.trace')) { console.log('Importing as Instruments.app .trace file') - this.loadProfile(async () => await importFromInstrumentsTrace(webkitEntry)) + this.loadProfile(async () => await importFromFileSystemDirectoryEntry(webkitEntry)) return } } diff --git a/import/bg-flamegraph.test.ts b/import/bg-flamegraph.test.ts index 651375b..7d81c15 100644 --- a/import/bg-flamegraph.test.ts +++ b/import/bg-flamegraph.test.ts @@ -1,9 +1,5 @@ -import * as fs from 'fs' -import {importFromBGFlameGraph} from './bg-flamegraph' -import {dumpProfile} from '../test-utils' +import {checkProfileSnapshot} from '../test-utils' -test('importFromBGFlameGraph', () => { - const input = fs.readFileSync('./sample/profiles/stackcollapse/simple.txt', 'utf8') - const profile = importFromBGFlameGraph(input) - expect(dumpProfile(profile)).toMatchSnapshot() +test('importFromBGFlameGraph', async () => { + await checkProfileSnapshot('./sample/profiles/stackcollapse/simple.txt') }) diff --git a/import/chrome.test.ts b/import/chrome.test.ts index ebb524c..4ae52bc 100644 --- a/import/chrome.test.ts +++ b/import/chrome.test.ts @@ -1,15 +1,9 @@ -import * as fs from 'fs' -import {dumpProfile} from '../test-utils' -import {importFromChromeCPUProfile, importFromChromeTimeline} from './chrome' +import {checkProfileSnapshot} from '../test-utils' -test('importFromChromeCPUProfile', () => { - const input = fs.readFileSync('./sample/profiles/Chrome/65/simple.cpuprofile', 'utf8') - const profile = importFromChromeCPUProfile(JSON.parse(input)) - expect(dumpProfile(profile)).toMatchSnapshot() +test('importFromChromeCPUProfile', async () => { + await checkProfileSnapshot('./sample/profiles/Chrome/65/simple.cpuprofile') }) -test('importFromChromeTimeline', () => { - const input = fs.readFileSync('./sample/profiles/Chrome/65/simple-timeline.json', 'utf8') - const profile = importFromChromeTimeline(JSON.parse(input)) - expect(dumpProfile(profile)).toMatchSnapshot() +test('importFromChromeTimeline', async () => { + await checkProfileSnapshot('./sample/profiles/Chrome/65/simple-timeline.json') }) diff --git a/import/file-system-entry.ts b/import/file-system-entry.ts new file mode 100644 index 0000000..95138e1 --- /dev/null +++ b/import/file-system-entry.ts @@ -0,0 +1,17 @@ +// The bits of this API that we care about. This is implemented by WebKitEntry +// https://wicg.github.io/entries-api/#api-entry +export interface FileSystemDirectoryReader { + readEntries(cb: (entries: FileSystemEntry[]) => void, error: (err: Error) => void): void +} +export interface FileSystemEntry { + readonly isFile: boolean + readonly isDirectory: boolean + readonly name: string + readonly fullPath: string +} +export interface FileSystemDirectoryEntry extends FileSystemEntry { + createReader(): FileSystemDirectoryReader +} +export interface FileSystemFileEntry extends FileSystemEntry { + file(cb: (file: File) => void, errCb: (err: Error) => void): void +} diff --git a/import/firefox.test.ts b/import/firefox.test.ts index 9f66975..984ad03 100644 --- a/import/firefox.test.ts +++ b/import/firefox.test.ts @@ -1,9 +1,5 @@ -import * as fs from 'fs' -import {dumpProfile} from '../test-utils' -import {importFromFirefox} from './firefox' +import {checkProfileSnapshot} from '../test-utils' -test('importFromFirefox', () => { - const input = fs.readFileSync('./sample/profiles/Firefox/59/simple-firefox.json', 'utf8') - const profile = importFromFirefox(JSON.parse(input)) - expect(dumpProfile(profile)).toMatchSnapshot() +test('importFromFirefox', async () => { + await checkProfileSnapshot('./sample/profiles/Firefox/59/simple-firefox.json') }) diff --git a/import/index.ts b/import/index.ts new file mode 100644 index 0000000..6ab6f11 --- /dev/null +++ b/import/index.ts @@ -0,0 +1,74 @@ +import {Profile} from '../profile' +import {FileSystemDirectoryEntry} from './file-system-entry' + +import {importFromChromeCPUProfile, importFromChromeTimeline} from './chrome' +import {importFromStackprof} from './stackprof' +import {importFromInstrumentsDeepCopy, importFromInstrumentsTrace} from './instruments' +import {importFromBGFlameGraph} from './bg-flamegraph' +import {importFromFirefox} from './firefox' + +export async function importProfile(fileName: string, contents: string): Promise { + try { + // First pass: Check known file format names to infer the file type + if (fileName.endsWith('.cpuprofile')) { + console.log('Importing as Chrome CPU Profile') + return importFromChromeCPUProfile(JSON.parse(contents)) + } else if (fileName.endsWith('.chrome.json') || /Profile-\d{8}T\d{6}/.exec(fileName)) { + console.log('Importing as Chrome Timeline') + return importFromChromeTimeline(JSON.parse(contents)) + } else if (fileName.endsWith('.stackprof.json')) { + console.log('Importing as stackprof profile') + return importFromStackprof(JSON.parse(contents)) + } else if (fileName.endsWith('.instruments.txt')) { + console.log('Importing as Instruments.app deep copy') + return importFromInstrumentsDeepCopy(contents) + } else if (fileName.endsWith('.collapsedstack.txt')) { + console.log('Importing as collapsed stack format') + return importFromBGFlameGraph(contents) + } + + // Second pass: Try to guess what file format it is based on structure + try { + const parsed = JSON.parse(contents) + if (parsed['systemHost'] && parsed['systemHost']['name'] == 'Firefox') { + console.log('Importing as Firefox profile') + return importFromFirefox(parsed) + } else if (Array.isArray(parsed) && parsed[parsed.length - 1].name === 'CpuProfile') { + console.log('Importing as Chrome CPU Profile') + return importFromChromeTimeline(parsed) + } else if ('nodes' in parsed && 'samples' in parsed && 'timeDeltas' in parsed) { + console.log('Importing as Chrome Timeline') + return importFromChromeCPUProfile(parsed) + } else if ('mode' in parsed && 'frames' in parsed) { + console.log('Importing as stackprof profile') + return importFromStackprof(parsed) + } + } catch (e) { + // Format is not JSON + + // If the first line contains "Symbol Name", preceded by a tab, it's probably + // a deep copy from OS X Instruments.app + if (/^[\w \t\(\)]*\tSymbol Name/.exec(contents)) { + console.log('Importing as Instruments.app deep copy') + return importFromInstrumentsDeepCopy(contents) + } + + // If every line ends with a space followed by a number, it's probably + // the collapsed stack format. + const lineCount = contents.split(/\n/).length + if (lineCount >= 1 && lineCount === contents.split(/ \d+\n/).length) { + console.log('Importing as collapsed stack format') + return importFromBGFlameGraph(contents) + } + } + + return null + } catch (e) { + console.error(e) + return null + } +} + +export async function importFromFileSystemDirectoryEntry(entry: FileSystemDirectoryEntry) { + return importFromInstrumentsTrace(entry) +} diff --git a/import/instruments.test.ts b/import/instruments.test.ts index 0859613..fe5a9f8 100644 --- a/import/instruments.test.ts +++ b/import/instruments.test.ts @@ -1,31 +1,22 @@ import * as fs from 'fs' import * as path from 'path' -import {dumpProfile} from '../test-utils' -import { - importFromInstrumentsDeepCopy, - FileSystemEntry, - importFromInstrumentsTrace, -} from './instruments' +import {dumpProfile, checkProfileSnapshot} from '../test-utils' import * as JSZip from 'jszip' +import {FileSystemEntry} from './file-system-entry' +import {importFromFileSystemDirectoryEntry} from '.' describe('importFromInstrumentsDeepCopy', () => { - test('time profile', () => { - const input = fs.readFileSync( + test('time profile', async () => { + await checkProfileSnapshot( './sample/profiles/Instruments/7.3.1/simple-time-profile-deep-copy.txt', - 'utf8', ) - const profile = importFromInstrumentsDeepCopy(input) - expect(dumpProfile(profile)).toMatchSnapshot() }) - test('allocations profile', () => { - const input = fs.readFileSync( + test('allocations profile', async () => { + await checkProfileSnapshot( './sample/profiles/Instruments/7.3.1/random-allocations-deep-copy.txt', - 'utf8', ) - const profile = importFromInstrumentsDeepCopy(input) - expect(dumpProfile(profile)).toMatchSnapshot() }) }) @@ -35,7 +26,7 @@ class ZipBackedFileSystemEntry implements FileSystemEntry { readonly name: string readonly fullPath: string - private zipDir: JSZip | null + private zipDir: any | null private zipFile: JSZip.JSZipObject | null constructor(private zip: JSZip, fullPath: string) { @@ -76,7 +67,7 @@ class ZipBackedFileSystemEntry implements FileSystemEntry { readEntries: (cb: (entries: FileSystemEntry[]) => void, errCb: (error: Error) => void) => { if (!this.zipDir) return errCb(new Error('Failed to read folder entries')) const ret: FileSystemEntry[] = [] - this.zipDir.forEach((relativePath, file) => { + this.zipDir.forEach((relativePath: string, file: {name: string}) => { if (relativePath.split('/').length === (relativePath.endsWith('/') ? 2 : 1)) { ret.push(new ZipBackedFileSystemEntry(this.zip, file.name)) } @@ -89,14 +80,14 @@ class ZipBackedFileSystemEntry implements FileSystemEntry { describe('importFromInstrumentsTrace', () => { async function importFromTrace(tracePath: string) { - const zip = await new Promise((resolve, reject) => { + const zip = await new Promise((resolve, reject) => { fs.readFile(tracePath, (err, data) => { if (err) return reject(err) JSZip.loadAsync(data).then(resolve) }) }) const root = new ZipBackedFileSystemEntry(zip, 'simple-time-profile.trace') - const profile = await importFromInstrumentsTrace(root) + const profile = await importFromFileSystemDirectoryEntry(root) expect(dumpProfile(profile)).toMatchSnapshot() } diff --git a/import/instruments.ts b/import/instruments.ts index 8829af4..7ccf854 100644 --- a/import/instruments.ts +++ b/import/instruments.ts @@ -5,6 +5,7 @@ import {Profile, FrameInfo, CallTreeProfileBuilder, StackListProfileBuilder} fro import {sortBy, getOrThrow, getOrInsert, lastOf, getOrElse, zeroPad} from '../utils' import * as pako from 'pako' import {ByteFormatter, TimeFormatter} from '../value-formatters' +import {FileSystemDirectoryEntry, FileSystemEntry, FileSystemFileEntry} from './file-system-entry' function parseTSV(contents: string): T[] { const lines = contents.split('\n').map(l => l.split('\t')) @@ -148,24 +149,6 @@ interface TraceDirectoryTree { subdirectories: Map } -// The bits of this API that we care about. This is implemented by WebKitEntry -// https://wicg.github.io/entries-api/#api-entry -export interface FileSystemDirectoryReader { - readEntries(cb: (entries: FileSystemEntry[]) => void, error: (err: Error) => void): void -} -export interface FileSystemEntry { - readonly isFile: boolean - readonly isDirectory: boolean - readonly name: string - readonly fullPath: string -} -export interface FileSystemDirectoryEntry extends FileSystemEntry { - createReader(): FileSystemDirectoryReader -} -export interface FileSystemFileEntry extends FileSystemEntry { - file(cb: (file: File) => void, errCb: (err: Error) => void): void -} - async function extractDirectoryTree(entry: FileSystemDirectoryEntry): Promise { const node: TraceDirectoryTree = { name: entry.name, diff --git a/import/stackprof.test.ts b/import/stackprof.test.ts index d3708c0..d862c68 100644 --- a/import/stackprof.test.ts +++ b/import/stackprof.test.ts @@ -1,9 +1,5 @@ -import * as fs from 'fs' -import {dumpProfile} from '../test-utils' -import {importFromStackprof} from './stackprof' +import {checkProfileSnapshot} from '../test-utils' -test('importFromStackprof', () => { - const input = fs.readFileSync('./sample/profiles/stackprof/simple-stackprof.json', 'utf8') - const profile = importFromStackprof(JSON.parse(input)) - expect(dumpProfile(profile)).toMatchSnapshot() +test('importFromStackprof', async () => { + await checkProfileSnapshot('./sample/profiles/stackprof/simple-stackprof.json') }) diff --git a/test-utils.ts b/test-utils.ts index b23785e..c1e1283 100644 --- a/test-utils.ts +++ b/test-utils.ts @@ -1,4 +1,7 @@ +import * as fs from 'fs' +import * as path from 'path' import {Profile, CallTreeNode, Frame} from './profile' +import {importProfile} from './import' interface DumpedProfile { stacks: string[] @@ -39,3 +42,13 @@ export function dumpProfile(profile: Profile): any { return dump } + +export async function checkProfileSnapshot(filepath: string) { + const input = fs.readFileSync(filepath, 'utf8') + const profile = await importProfile(path.basename(filepath), input) + if (profile) { + expect(dumpProfile(profile)).toMatchSnapshot() + } else { + fail('Failed to extract profile') + } +} diff --git a/tsconfig.json b/tsconfig.json index 96cd14b..69ec122 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,12 @@ { "compilerOptions": { - "module": "commonjs", + "module": "esnext", + "moduleResolution": "Node", + "esModuleInterop": true, "strict": true, "jsx": "react", "noUnusedLocals": true, "jsxFactory": "h", "target": "es2015" } -} \ No newline at end of file +}