1 | import { ProcessCov, ScriptCov } from "@c88/v8-coverage";
|
2 | import assert from "assert";
|
3 | import cp from "child_process";
|
4 | import cri from "chrome-remote-interface";
|
5 | import { ChildProcessProxy, observeSpawn, ObserveSpawnOptions, SpawnEvent } from "demurgos-spawn-wrap";
|
6 | import Protocol from "devtools-protocol";
|
7 | import events from "events";
|
8 | import { SourceType } from "istanbulize";
|
9 | import { CoverageFilter } from "./filter";
|
10 |
|
11 | const DEBUGGER_URI_RE: RegExp = /ws:\/\/.*?:(\d+)\//;
|
12 |
|
13 | const GET_DEBUGGER_PORT_TIMEOUT: number = 1000;
|
14 |
|
15 | const GET_COVERAGE_TIMEOUT: number = 10000;
|
16 |
|
17 | export interface ScriptMeta {
|
18 | sourceText: string;
|
19 | sourceType: SourceType;
|
20 | sourceMapUrl?: string;
|
21 | }
|
22 |
|
23 | export interface RichScriptCov extends ScriptCov, ScriptMeta {
|
24 | }
|
25 |
|
26 | export interface RichProcessCov extends ProcessCov {
|
27 | result: RichScriptCov[];
|
28 | }
|
29 |
|
30 | export interface SpawnInspectedOptions extends ObserveSpawnOptions {
|
31 | filter?: CoverageFilter;
|
32 |
|
33 | onRootProcess?(process: cp.ChildProcess): any;
|
34 | }
|
35 |
|
36 | export async function spawnInspected(
|
37 | file: string,
|
38 | args: ReadonlyArray<string>,
|
39 | options: SpawnInspectedOptions,
|
40 | ): Promise<RichProcessCov[]> {
|
41 | const processCovs: RichProcessCov[] = [];
|
42 |
|
43 | return new Promise<RichProcessCov[]>((resolve, reject) => {
|
44 | observeSpawn(file, args, options)
|
45 | .subscribe(
|
46 | async (ev: SpawnEvent) => {
|
47 | try {
|
48 | if (ev.rootProcess !== undefined && options.onRootProcess !== undefined) {
|
49 | options.onRootProcess(ev.rootProcess);
|
50 | }
|
51 | const args: ReadonlyArray<string> = ["--inspect=0", ...ev.args];
|
52 | const proxy: ChildProcessProxy = ev.proxySpawn(args);
|
53 | const debuggerPort: number = await getDebuggerPort(proxy);
|
54 | const processCov: RichProcessCov = await getCoverage(debuggerPort, options.filter);
|
55 | processCovs.push(processCov);
|
56 | } catch (err) {
|
57 | reject(err);
|
58 | }
|
59 | },
|
60 | reject,
|
61 | () => resolve(processCovs),
|
62 | );
|
63 | });
|
64 | }
|
65 |
|
66 | export async function getDebuggerPort(proc: ChildProcessProxy): Promise<number> {
|
67 | return new Promise<number>((resolve, reject) => {
|
68 | const timeoutId: NodeJS.Timer = setTimeout(onTimeout, GET_DEBUGGER_PORT_TIMEOUT * 100);
|
69 | let stderrBuffer: Buffer = Buffer.alloc(0);
|
70 | proc.stderr.on("data", onStderrData);
|
71 | proc.stderr.on("close", onClose);
|
72 |
|
73 | function onStderrData(chunk: Buffer): void {
|
74 | stderrBuffer = Buffer.concat([stderrBuffer, chunk]);
|
75 | const stderrStr: string = stderrBuffer.toString("UTF-8");
|
76 | const match: RegExpExecArray | null = DEBUGGER_URI_RE.exec(stderrStr);
|
77 | if (match === null) {
|
78 | return;
|
79 | }
|
80 | const result: number = parseInt(match[1], 10);
|
81 | removeListeners();
|
82 | resolve(result);
|
83 | }
|
84 |
|
85 | function onClose(code: number | null, signal: string | null): void {
|
86 | removeListeners();
|
87 | reject(new Error(`Unable to hook inspector (early exit, ${code}, ${signal})`));
|
88 | }
|
89 |
|
90 | function onTimeout(): void {
|
91 | removeListeners();
|
92 | reject(new Error("Unable to hook inspector (timeout)"));
|
93 |
|
94 | }
|
95 |
|
96 | function removeListeners(): void {
|
97 | proc.stderr.removeListener("data", onStderrData);
|
98 | proc.stderr.removeListener("close", onClose);
|
99 | clearTimeout(timeoutId);
|
100 | }
|
101 | });
|
102 | }
|
103 |
|
104 | async function getCoverage(port: number, filter?: CoverageFilter): Promise<RichProcessCov> {
|
105 | return new Promise<RichProcessCov>(async (resolve, reject) => {
|
106 | const timeoutId: NodeJS.Timer = setTimeout(onTimeout, GET_COVERAGE_TIMEOUT);
|
107 | let client: any;
|
108 | let mainExecutionContextId: Protocol.Runtime.ExecutionContextId | undefined;
|
109 | const scriptIdToMeta: Map<Protocol.Runtime.ScriptId, Partial<ScriptMeta>> = new Map();
|
110 | let state: string = "WaitingForMainContext";
|
111 | try {
|
112 | client = await cri({port});
|
113 |
|
114 | await client.Profiler.enable();
|
115 | await client.Profiler.startPreciseCoverage({callCount: true, detailed: true});
|
116 | await client.Debugger.enable();
|
117 |
|
118 | (client as any as events.EventEmitter).once("Runtime.executionContextCreated", onMainContextCreation);
|
119 | (client as any as events.EventEmitter).on("Runtime.executionContextDestroyed", onContextDestruction);
|
120 | (client as any as events.EventEmitter).on("Debugger.scriptParsed", onScriptParsed);
|
121 |
|
122 | await client.Runtime.enable();
|
123 | } catch (err) {
|
124 | removeListeners();
|
125 | reject(err);
|
126 | }
|
127 |
|
128 | function onMainContextCreation(ev: Protocol.Runtime.ExecutionContextCreatedEvent) {
|
129 | assert(state === "WaitingForMainContext");
|
130 | mainExecutionContextId = ev.context.id;
|
131 | state = "WaitingForMainContextDestruction";
|
132 | }
|
133 |
|
134 | function onScriptParsed(ev: Protocol.Debugger.ScriptParsedEvent) {
|
135 | const collect: boolean = filter !== undefined ? filter(ev) : true;
|
136 | if (collect) {
|
137 | let sourceType: SourceType = SourceType.Script;
|
138 | if (ev.isModule !== undefined) {
|
139 | sourceType = ev.isModule ? SourceType.Module : SourceType.Script;
|
140 | }
|
141 | let sourceMapUrl: string | undefined;
|
142 | if (ev.sourceMapURL !== undefined && ev.sourceMapURL !== "") {
|
143 | sourceMapUrl = ev.sourceMapURL;
|
144 | }
|
145 | scriptIdToMeta.set(
|
146 | ev.scriptId,
|
147 | {
|
148 | sourceType,
|
149 | sourceMapUrl,
|
150 | },
|
151 | );
|
152 | }
|
153 | }
|
154 |
|
155 | async function onContextDestruction(ev: Protocol.Runtime.ExecutionContextDestroyedEvent): Promise<void> {
|
156 | assert(state === "WaitingForMainContextDestruction");
|
157 | if (ev.executionContextId !== mainExecutionContextId) {
|
158 | return;
|
159 | }
|
160 | state = "WaitingForCoverage";
|
161 |
|
162 | try {
|
163 |
|
164 | await client.HeapProfiler.collectGarbage();
|
165 | const {result: scriptCovs} = await client.Profiler.takePreciseCoverage();
|
166 | const result: RichScriptCov[] = [];
|
167 | for (const scriptCov of scriptCovs) {
|
168 | const meta: Partial<ScriptMeta> | undefined = scriptIdToMeta.get(scriptCov.scriptId);
|
169 | if (meta === undefined) {
|
170 |
|
171 | continue;
|
172 | }
|
173 | const {scriptSource} = await client.Debugger.getScriptSource({scriptId: scriptCov.scriptId});
|
174 | result.push({
|
175 | ...scriptCov,
|
176 | sourceText: scriptSource,
|
177 | ...meta,
|
178 | } as RichScriptCov);
|
179 | }
|
180 | resolve({result});
|
181 | } catch (err) {
|
182 | reject(err);
|
183 | } finally {
|
184 | removeListeners();
|
185 | }
|
186 | }
|
187 |
|
188 | function onTimeout(): void {
|
189 | removeListeners();
|
190 | reject(new Error("Unable to get V8 coverage (timeout)"));
|
191 | }
|
192 |
|
193 | function removeListeners(): void {
|
194 | (client as any as events.EventEmitter).removeListener("Runtime.executionContextCreated", onMainContextCreation);
|
195 | (client as any as events.EventEmitter).removeListener("Runtime.executionContextDestroyed", onContextDestruction);
|
196 | (client as any as events.EventEmitter).removeListener("Runtime.scriptParsed", onScriptParsed);
|
197 | clearTimeout(timeoutId);
|
198 | (client as any).close();
|
199 | }
|
200 | });
|
201 | }
|
202 |
|
\ | No newline at end of file |