@gravitylabsllc/porthole 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -0
- package/dist/adb.js +430 -21
- package/dist/adb.js.map +1 -1
- package/dist/args.js +144 -0
- package/dist/args.js.map +1 -0
- package/dist/capture.js +139 -30
- package/dist/capture.js.map +1 -1
- package/dist/cli.js +221 -62
- package/dist/cli.js.map +1 -1
- package/dist/device.js +337 -4
- package/dist/device.js.map +1 -1
- package/dist/index.js +2030 -377
- package/dist/index.js.map +1 -1
- package/dist/moment.js +240 -0
- package/dist/moment.js.map +1 -0
- package/dist/perfetto.js +826 -0
- package/dist/perfetto.js.map +1 -0
- package/dist/report.js +68 -7
- package/dist/report.js.map +1 -1
- package/dist/save.js +252 -0
- package/dist/save.js.map +1 -0
- package/dist/sessions.js +704 -0
- package/dist/sessions.js.map +1 -0
- package/dist/system.js +169 -0
- package/dist/system.js.map +1 -0
- package/dist/systrace.js +198 -0
- package/dist/systrace.js.map +1 -0
- package/dist/timeline.js +731 -29
- package/dist/timeline.js.map +1 -1
- package/dist/trace.js +317 -27
- package/dist/trace.js.map +1 -1
- package/dist/watermark.js +220 -0
- package/dist/watermark.js.map +1 -0
- package/package.json +10 -4
- package/src/adb.ts +583 -0
- package/src/args.ts +177 -0
- package/src/capture.ts +292 -0
- package/src/cli.ts +367 -0
- package/src/device.ts +635 -0
- package/src/index.ts +2545 -0
- package/src/moment.ts +306 -0
- package/src/perfetto.ts +972 -0
- package/src/report.ts +285 -0
- package/src/save.ts +322 -0
- package/src/sessions.ts +894 -0
- package/src/system.ts +221 -0
- package/src/systrace.ts +258 -0
- package/src/timeline.ts +1036 -0
- package/src/trace.ts +769 -0
- package/src/watermark.ts +337 -0
- package/ui/dist/assets/index-BzqwnvoU.js +70 -0
- package/ui/dist/assets/index-DtnyBXCM.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index--1mlZuNZ.css +0 -1
- package/ui/dist/assets/index-BeVGHRFm.js +0 -68
package/src/args.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Copyright 2026 Gravity Labs
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { TRACE_VERSION, type Trace } from "./trace.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Argument validators shared by cli.ts (`porthole ui`, `porthole capture`)
|
|
8
|
+
* and capture.ts (`capture`, `report`, `compare`).
|
|
9
|
+
*
|
|
10
|
+
* GRA-93 fixed the CLI's silent argument handling but, inside that ticket's
|
|
11
|
+
* `Owns`, wrote `parsePort` twice — once per file that needed it — rather
|
|
12
|
+
* than add a third file neither ticket owned. That was the right call for
|
|
13
|
+
* that ticket alone. It is exactly the shape that produced GRA-87
|
|
14
|
+
* (`findAdb` existed twice and neither copy read `local.properties`): two
|
|
15
|
+
* copies agree until the day someone changes one of them, and the
|
|
16
|
+
* disagreement is silent because each copy has its own tests. This module
|
|
17
|
+
* is that follow-up — one definition per validator, imported by both
|
|
18
|
+
* commands, so 0.2.0's new commands reach for the same rules everyone else
|
|
19
|
+
* did.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A parse failure that names what was wrong, for the caller to print and exit on. */
|
|
23
|
+
export interface ParseError {
|
|
24
|
+
message: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A port a device or a browser can plausibly reach. `--port` used to be
|
|
29
|
+
* `Number(argv[++i])` with no check at all: a missing value is `NaN`, and a
|
|
30
|
+
* `NaN` port is not a refusal, it is a listener that never connects to
|
|
31
|
+
* anything while printing nothing to say why. Below 1024 needs privileges
|
|
32
|
+
* this process does not have on most platforms; above 65535 does not exist;
|
|
33
|
+
* a fraction is a typo, not a port.
|
|
34
|
+
*/
|
|
35
|
+
export function parsePort(raw: string | undefined, option: string): number | ParseError {
|
|
36
|
+
if (raw === undefined || raw === "") {
|
|
37
|
+
return { message: `${option} needs a port number` };
|
|
38
|
+
}
|
|
39
|
+
// A port is a run of decimal digits, nothing else: `Number()` also accepts
|
|
40
|
+
// " 8677 " (trims whitespace), "1e4" (scientific notation) and "0x2000"
|
|
41
|
+
// (hex) as finite integers, none of which anyone typed on purpose.
|
|
42
|
+
if (/^-?\d+$/.test(raw)) {
|
|
43
|
+
const value = Number(raw);
|
|
44
|
+
if (value < 1024 || value > 65535) {
|
|
45
|
+
return { message: `${option} ${raw} is out of range (must be 1024-65535)` };
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
if (/^-?\d+\.\d+$/.test(raw)) {
|
|
50
|
+
return { message: `${option} ${raw} must be a whole number` };
|
|
51
|
+
}
|
|
52
|
+
return { message: `${option} ${JSON.stringify(raw)} is not a number` };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A device-uptime millisecond value — `porthole save`'s `--from`/`--to`.
|
|
57
|
+
* Same shape discipline as `parsePort`: a run of decimal digits only, so
|
|
58
|
+
* `"1e4"` or `" 100 "` (both finite per `Number()`) are refused rather than
|
|
59
|
+
* silently accepted. Unlike a port, 0 is a legitimate value (the very start
|
|
60
|
+
* of the uptime clock) and there is no upper bound — a session can run for
|
|
61
|
+
* days.
|
|
62
|
+
*/
|
|
63
|
+
export function parseMillis(raw: string | undefined, option: string): number | ParseError {
|
|
64
|
+
if (raw === undefined || raw === "") {
|
|
65
|
+
return { message: `${option} needs a millisecond value` };
|
|
66
|
+
}
|
|
67
|
+
if (/^\d+$/.test(raw)) return Number(raw);
|
|
68
|
+
if (/^\d+\.\d+$/.test(raw)) {
|
|
69
|
+
return { message: `${option} ${raw} must be a whole number` };
|
|
70
|
+
}
|
|
71
|
+
return { message: `${option} ${JSON.stringify(raw)} is not a number` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A relative lookback for `porthole save --since`: `10m`, `90s`, `2h`, or a
|
|
76
|
+
* bare millisecond count — the ticket's own examples, all a single unit, so
|
|
77
|
+
* a compound duration like `1h30m` is refused rather than half-parsed.
|
|
78
|
+
* Returns milliseconds.
|
|
79
|
+
*/
|
|
80
|
+
export function parseDuration(raw: string | undefined, option: string): number | ParseError {
|
|
81
|
+
if (raw === undefined || raw === "") {
|
|
82
|
+
return { message: `${option} needs a duration (e.g. 10m, 90s, 2h, or a millisecond count)` };
|
|
83
|
+
}
|
|
84
|
+
const MULTIPLIER_MS: Record<string, number> = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 };
|
|
85
|
+
const bare = /^\d+$/.test(raw) ? { value: Number(raw), unitMs: 1 } : null;
|
|
86
|
+
const suffixed = /^(\d+)(ms|s|m|h)$/.exec(raw);
|
|
87
|
+
const parsed = bare ?? (suffixed ? { value: Number(suffixed[1]), unitMs: MULTIPLIER_MS[suffixed[2]] } : null);
|
|
88
|
+
if (!parsed) {
|
|
89
|
+
return {
|
|
90
|
+
message: `${option} ${JSON.stringify(raw)} is not a duration (use 10m, 90s, 2h, or a millisecond count)`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (parsed.value <= 0) {
|
|
94
|
+
return { message: `${option} ${raw} must be positive` };
|
|
95
|
+
}
|
|
96
|
+
return parsed.value * parsed.unitMs;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const FAIL_ON_VALUES = ["nothing", "error", "regression"] as const;
|
|
100
|
+
|
|
101
|
+
export type FailOn = (typeof FAIL_ON_VALUES)[number];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `--fail-on` used to be `argv[++i] as CaptureOptions["failOn"]` — a cast, not
|
|
105
|
+
* a check. A typo like `regresion` compiled, ran, and turned the CI gate off
|
|
106
|
+
* without saying a word: the worst failure mode here is a green build. This
|
|
107
|
+
* validates against the real union and names the accepted values so the typo
|
|
108
|
+
* is caught at the command line instead of at the postmortem.
|
|
109
|
+
*/
|
|
110
|
+
export function parseFailOn(raw: string | undefined): FailOn | ParseError {
|
|
111
|
+
if (raw !== undefined && (FAIL_ON_VALUES as readonly string[]).includes(raw)) {
|
|
112
|
+
return raw as FailOn;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
message: `--fail-on must be one of: ${FAIL_ON_VALUES.join(", ")} (got ${JSON.stringify(raw ?? null)})`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A required string option's value must actually be there — and must not be
|
|
121
|
+
* the next flag left dangling because this one's value was omitted.
|
|
122
|
+
* `--scenario` at the end of the command line and `--scenario --port 8677`
|
|
123
|
+
* were both silently accepted before: the first became `undefined` with no
|
|
124
|
+
* complaint, the second swallowed `--port` as the scenario name and left
|
|
125
|
+
* `8677` to be rejected later as a nonsense option, blaming the wrong flag.
|
|
126
|
+
*/
|
|
127
|
+
export function requiredValue(raw: string | undefined, option: string): string | ParseError {
|
|
128
|
+
if (raw === undefined || raw === "--" || raw.startsWith("--")) {
|
|
129
|
+
return { message: `${option} needs a value` };
|
|
130
|
+
}
|
|
131
|
+
return raw;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Thrown by readTrace; the message is written straight to stderr, so it earns its keep alone. */
|
|
135
|
+
export class TraceReadError extends Error {}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Reads and validates a trace file, refusing anything that is not one, rather
|
|
139
|
+
* than letting a missing file, truncated JSON, or a trace from a version this
|
|
140
|
+
* build does not understand fall through as an unhandled rejection and a raw
|
|
141
|
+
* stack trace — which is what a CI operator would have gotten instead of the
|
|
142
|
+
* one sentence they need.
|
|
143
|
+
*/
|
|
144
|
+
export async function readTrace(file: string): Promise<Trace> {
|
|
145
|
+
let content: string;
|
|
146
|
+
try {
|
|
147
|
+
content = await readFile(file, "utf8");
|
|
148
|
+
} catch (error) {
|
|
149
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
150
|
+
if (code === "ENOENT") throw new TraceReadError(`no such file: ${file}`);
|
|
151
|
+
throw new TraceReadError(`could not read ${file}: ${(error as Error).message}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let parsed: unknown;
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(content);
|
|
157
|
+
} catch {
|
|
158
|
+
throw new TraceReadError(`${file} is not valid JSON`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
typeof parsed !== "object" ||
|
|
163
|
+
parsed === null ||
|
|
164
|
+
typeof (parsed as Record<string, unknown>).porthole !== "number"
|
|
165
|
+
) {
|
|
166
|
+
throw new TraceReadError(`${file} is not a porthole trace (missing "porthole" version field)`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const version = (parsed as Trace).porthole;
|
|
170
|
+
if (version !== TRACE_VERSION) {
|
|
171
|
+
throw new TraceReadError(
|
|
172
|
+
`${file} is trace version ${version}, which this build (version ${TRACE_VERSION}) does not understand`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return parsed as Trace;
|
|
177
|
+
}
|
package/src/capture.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Copyright 2026 Gravity Labs
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { writeFile } from "node:fs/promises";
|
|
5
|
+
import { DeviceClient, isConnected, type ConnectionState, type DeviceEvent } from "./device.js";
|
|
6
|
+
import { renderComparison, renderReport, shouldColor } from "./report.js";
|
|
7
|
+
import { buildTrace, resolveProfile, type Trace } from "./trace.js";
|
|
8
|
+
import { parseFailOn, parsePort, readTrace, requiredValue, type FailOn } from "./args.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Recording a run with nobody watching.
|
|
12
|
+
*
|
|
13
|
+
* Wrapping a child process is the shape because it asks nothing of whatever is
|
|
14
|
+
* driving the app: connectedAndroidTest, Maestro, a shell script and an agentic
|
|
15
|
+
* driver are all just a command. The process lifetime is the capture window,
|
|
16
|
+
* which means there is no protocol to version and no way to leave a capture
|
|
17
|
+
* running.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface CaptureOptions {
|
|
21
|
+
port: number;
|
|
22
|
+
serial?: string;
|
|
23
|
+
scenario: string;
|
|
24
|
+
out: string;
|
|
25
|
+
driver?: string;
|
|
26
|
+
withEvents: boolean;
|
|
27
|
+
failOn: FailOn;
|
|
28
|
+
forward: boolean;
|
|
29
|
+
baseline?: string;
|
|
30
|
+
command: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const CAPTURE_USAGE = `
|
|
34
|
+
porthole capture — record a run and write a trace
|
|
35
|
+
|
|
36
|
+
porthole capture --scenario <name> [options] -- <command to run>
|
|
37
|
+
|
|
38
|
+
--scenario <name> what this run is, and what a baseline is matched against
|
|
39
|
+
--out <file> where to write the trace (default porthole-trace.json)
|
|
40
|
+
--driver <name> what drove the app; compare warns when two runs differ
|
|
41
|
+
--with-events include the raw event stream. Large.
|
|
42
|
+
--fail-on <what> nothing (default), error, or regression
|
|
43
|
+
--baseline <file> compare against this trace when done
|
|
44
|
+
--port <n> device port (default 8677)
|
|
45
|
+
--serial <id> adb device serial
|
|
46
|
+
--no-forward skip 'adb forward'; use it if the bridge is already up
|
|
47
|
+
|
|
48
|
+
porthole report <trace.json>
|
|
49
|
+
porthole compare <baseline.json> <trace.json>
|
|
50
|
+
|
|
51
|
+
The command runs to completion with the porthole recording. Its exit code is
|
|
52
|
+
passed through unless --fail-on fires first.
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Waits for the device to answer, so a capture does not silently record
|
|
57
|
+
* nothing.
|
|
58
|
+
*
|
|
59
|
+
* GRA-157 AC5: this used to resolve the instant the socket connected, before
|
|
60
|
+
* hello had a chance to land — DeviceClient set state = "connected" on
|
|
61
|
+
* socket connect and issued the hello request without awaiting it, so a
|
|
62
|
+
* capture that started recording right here could finish with `hello: null`
|
|
63
|
+
* for a run it had already reported as connected. That is now structurally
|
|
64
|
+
* impossible without any change to this function: DeviceClient's "state"
|
|
65
|
+
* event does not fire "connected" until hello has actually resolved (see
|
|
66
|
+
* device.ts's setState()/connect()), and this only resolves `true` on that
|
|
67
|
+
* exact event, so by the time it does, `device.hello` below is guaranteed
|
|
68
|
+
* non-null. Waiting through the new "handshaking" state in between is free —
|
|
69
|
+
* this function was never told which non-"connected" states exist, and does
|
|
70
|
+
* not need to be now either.
|
|
71
|
+
*/
|
|
72
|
+
async function awaitConnection(device: DeviceClient, timeoutMs = 10_000): Promise<boolean> {
|
|
73
|
+
// GRA-162: routed through isConnected() rather than `=== "connected"` so
|
|
74
|
+
// that a fifth ConnectionState fails `tsc` here instead of this function
|
|
75
|
+
// just never resolving true for it.
|
|
76
|
+
if (isConnected(device.state)) return true;
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const timer = setTimeout(() => resolve(false), timeoutMs);
|
|
79
|
+
device.on("state", (state: ConnectionState) => {
|
|
80
|
+
if (isConnected(state)) {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
resolve(true);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function capture(options: CaptureOptions): Promise<number> {
|
|
89
|
+
const device = new DeviceClient("127.0.0.1", options.port);
|
|
90
|
+
const events: DeviceEvent[] = [];
|
|
91
|
+
device.on("event", (event: DeviceEvent) => events.push(event));
|
|
92
|
+
device.start();
|
|
93
|
+
|
|
94
|
+
const connected = await awaitConnection(device);
|
|
95
|
+
if (!connected) {
|
|
96
|
+
device.stop();
|
|
97
|
+
process.stderr.write(device.notConnectedMessage() + "\n");
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
process.stderr.write(`recording "${options.scenario}"\n`);
|
|
101
|
+
|
|
102
|
+
const startedAt = Date.now();
|
|
103
|
+
const exitCode = await run(options.command);
|
|
104
|
+
const durationMs = Date.now() - startedAt;
|
|
105
|
+
|
|
106
|
+
// The last events are still in flight when the child exits.
|
|
107
|
+
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
108
|
+
// Non-null here unless the app disconnected again during the run — a real,
|
|
109
|
+
// separate risk (the process under test crashed or was reinstalled mid-run)
|
|
110
|
+
// that this ticket does not attempt to paper over. It is no longer possible
|
|
111
|
+
// for this to be null merely because we asked too early: awaitConnection()
|
|
112
|
+
// above only returns once DeviceClient has actually set hello (GRA-157).
|
|
113
|
+
const hello = device.hello as Record<string, unknown> | null;
|
|
114
|
+
device.stop();
|
|
115
|
+
|
|
116
|
+
// GRA-185: `capture` has no window narrower than the whole run, so
|
|
117
|
+
// `windowTo: Infinity` — a profile emitted anywhere in `events` (in
|
|
118
|
+
// practice, `DeviceCollector`'s one startup event) counts, exactly as it
|
|
119
|
+
// always has here.
|
|
120
|
+
const profile = resolveProfile({ liveEvents: events, windowTo: Number.POSITIVE_INFINITY, sessionProfile: null, hello });
|
|
121
|
+
const trace = buildTrace({
|
|
122
|
+
scenario: options.scenario,
|
|
123
|
+
driver: options.driver,
|
|
124
|
+
events,
|
|
125
|
+
hello,
|
|
126
|
+
durationMs,
|
|
127
|
+
withEvents: options.withEvents,
|
|
128
|
+
profile,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await writeFile(options.out, JSON.stringify(trace, null, 2));
|
|
132
|
+
// stderr, not stdout — this is the summary `porthole capture` prints
|
|
133
|
+
// alongside the child command's own output, so it colours against stderr's
|
|
134
|
+
// own TTY-ness, which can differ from stdout's (e.g. `capture ... | tee log`).
|
|
135
|
+
process.stderr.write(`\n${renderReport(trace, { color: shouldColor(process.stderr) })}`);
|
|
136
|
+
process.stderr.write(`\nwrote ${options.out} (${events.length} events)\n`);
|
|
137
|
+
|
|
138
|
+
let regressed = false;
|
|
139
|
+
if (options.baseline) {
|
|
140
|
+
try {
|
|
141
|
+
const before = await readTrace(options.baseline);
|
|
142
|
+
const comparison = renderComparison(before, trace);
|
|
143
|
+
process.stderr.write(`\n${comparison.text}`);
|
|
144
|
+
regressed = comparison.regressed;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
// Same hazard as `porthole compare` reading its two files, just reached
|
|
147
|
+
// from a capture that asked to be checked against a baseline inline. A
|
|
148
|
+
// baseline we could not read is not evidence either way, so it is
|
|
149
|
+
// reported and the comparison is skipped rather than crashing the run
|
|
150
|
+
// that was otherwise recorded successfully.
|
|
151
|
+
process.stderr.write(`${(error as Error).message}\n`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const hasError = trace.findings.some((finding) => finding.severity === "error");
|
|
156
|
+
if (options.failOn === "error" && hasError) return 1;
|
|
157
|
+
if (options.failOn === "regression" && regressed) return 1;
|
|
158
|
+
return exitCode;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Runs the child with its output passed straight through. */
|
|
162
|
+
function run(command: string[]): Promise<number> {
|
|
163
|
+
if (command.length === 0) return Promise.resolve(0);
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
// No shell. Passing an argv array through one concatenates it unescaped,
|
|
166
|
+
// which mangles any argument containing a space and turns the command into
|
|
167
|
+
// an injection point. A caller who wants a shell asks for one by name:
|
|
168
|
+
// `-- bash -c "..."`.
|
|
169
|
+
const child = spawn(command[0], command.slice(1), { stdio: "inherit" });
|
|
170
|
+
child.on("error", (error) => {
|
|
171
|
+
process.stderr.write(`could not run ${command[0]}: ${error.message}\n`);
|
|
172
|
+
resolve(127);
|
|
173
|
+
});
|
|
174
|
+
child.on("close", (code) => resolve(code ?? 0));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function report(file: string): Promise<number> {
|
|
179
|
+
let trace: Trace;
|
|
180
|
+
try {
|
|
181
|
+
trace = await readTrace(file);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
process.stderr.write(`${(error as Error).message}\n`);
|
|
184
|
+
return 2;
|
|
185
|
+
}
|
|
186
|
+
process.stdout.write(renderReport(trace, { color: shouldColor(process.stdout) }));
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function compare(baseline: string, file: string): Promise<number> {
|
|
191
|
+
let before: Trace;
|
|
192
|
+
let after: Trace;
|
|
193
|
+
try {
|
|
194
|
+
before = await readTrace(baseline);
|
|
195
|
+
after = await readTrace(file);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
process.stderr.write(`${(error as Error).message}\n`);
|
|
198
|
+
return 2;
|
|
199
|
+
}
|
|
200
|
+
const comparison = renderComparison(before, after);
|
|
201
|
+
process.stdout.write(comparison.text);
|
|
202
|
+
// A refusal is not a pass. Exiting 0 would turn a gate that compared nothing
|
|
203
|
+
// into a green build, which is the one outcome worse than a red one.
|
|
204
|
+
if (comparison.refused) return 2;
|
|
205
|
+
return comparison.regressed ? 1 : 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function parseCapture(argv: string[]): CaptureOptions {
|
|
209
|
+
const options: CaptureOptions = {
|
|
210
|
+
port: 8677,
|
|
211
|
+
scenario: "capture",
|
|
212
|
+
out: "porthole-trace.json",
|
|
213
|
+
withEvents: false,
|
|
214
|
+
failOn: "nothing",
|
|
215
|
+
forward: true,
|
|
216
|
+
command: [],
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
for (let i = 0; i < argv.length; i++) {
|
|
220
|
+
const arg = argv[i];
|
|
221
|
+
if (arg === "--") {
|
|
222
|
+
options.command = argv.slice(i + 1);
|
|
223
|
+
break;
|
|
224
|
+
} else if (arg === "--scenario") {
|
|
225
|
+
const value = requiredValue(argv[++i], "--scenario");
|
|
226
|
+
if (typeof value !== "string") {
|
|
227
|
+
process.stderr.write(`${value.message}\n`);
|
|
228
|
+
process.exit(2);
|
|
229
|
+
}
|
|
230
|
+
options.scenario = value;
|
|
231
|
+
} else if (arg === "--out") {
|
|
232
|
+
// Validated here, before `capture()` is ever called: a bad --out used
|
|
233
|
+
// to fail only after the recording had already happened, so the run was
|
|
234
|
+
// lost *and* the operator got a raw stack trace instead of a sentence.
|
|
235
|
+
const value = requiredValue(argv[++i], "--out");
|
|
236
|
+
if (typeof value !== "string") {
|
|
237
|
+
process.stderr.write(`${value.message}\n`);
|
|
238
|
+
process.exit(2);
|
|
239
|
+
}
|
|
240
|
+
options.out = value;
|
|
241
|
+
} else if (arg === "--driver") {
|
|
242
|
+
// Previously `argv[++i]` raw: `--driver --serial abc` swallowed
|
|
243
|
+
// "--serial" as the driver name and left "abc" to be rejected next as
|
|
244
|
+
// a nonsense option — blaming the wrong token for the actual mistake.
|
|
245
|
+
const value = requiredValue(argv[++i], "--driver");
|
|
246
|
+
if (typeof value !== "string") {
|
|
247
|
+
process.stderr.write(`${value.message}\n`);
|
|
248
|
+
process.exit(2);
|
|
249
|
+
}
|
|
250
|
+
options.driver = value;
|
|
251
|
+
} else if (arg === "--baseline") {
|
|
252
|
+
const value = requiredValue(argv[++i], "--baseline");
|
|
253
|
+
if (typeof value !== "string") {
|
|
254
|
+
process.stderr.write(`${value.message}\n`);
|
|
255
|
+
process.exit(2);
|
|
256
|
+
}
|
|
257
|
+
options.baseline = value;
|
|
258
|
+
} else if (arg === "--with-events") options.withEvents = true;
|
|
259
|
+
else if (arg === "--fail-on") {
|
|
260
|
+
const value = parseFailOn(argv[++i]);
|
|
261
|
+
if (typeof value !== "string") {
|
|
262
|
+
process.stderr.write(`${value.message}\n`);
|
|
263
|
+
process.exit(2);
|
|
264
|
+
}
|
|
265
|
+
options.failOn = value;
|
|
266
|
+
} else if (arg === "--port") {
|
|
267
|
+
const value = parsePort(argv[++i], "--port");
|
|
268
|
+
if (typeof value !== "number") {
|
|
269
|
+
process.stderr.write(`${value.message}\n`);
|
|
270
|
+
process.exit(2);
|
|
271
|
+
}
|
|
272
|
+
options.port = value;
|
|
273
|
+
} else if (arg === "--serial") {
|
|
274
|
+
// Same hazard as --driver above: a raw argv[++i] blames the wrong
|
|
275
|
+
// token when the value is missing or is actually the next flag.
|
|
276
|
+
const value = requiredValue(argv[++i], "--serial");
|
|
277
|
+
if (typeof value !== "string") {
|
|
278
|
+
process.stderr.write(`${value.message}\n`);
|
|
279
|
+
process.exit(2);
|
|
280
|
+
}
|
|
281
|
+
options.serial = value;
|
|
282
|
+
} else if (arg === "--no-forward") options.forward = false;
|
|
283
|
+
else if (arg === "--help" || arg === "-h") {
|
|
284
|
+
process.stdout.write(CAPTURE_USAGE);
|
|
285
|
+
process.exit(0);
|
|
286
|
+
} else {
|
|
287
|
+
process.stderr.write(`unknown option: ${arg}\n${CAPTURE_USAGE}`);
|
|
288
|
+
process.exit(2);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return options;
|
|
292
|
+
}
|