@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.
Files changed (55) hide show
  1. package/README.md +123 -0
  2. package/dist/adb.js +430 -21
  3. package/dist/adb.js.map +1 -1
  4. package/dist/args.js +144 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/capture.js +139 -30
  7. package/dist/capture.js.map +1 -1
  8. package/dist/cli.js +221 -62
  9. package/dist/cli.js.map +1 -1
  10. package/dist/device.js +337 -4
  11. package/dist/device.js.map +1 -1
  12. package/dist/index.js +2030 -377
  13. package/dist/index.js.map +1 -1
  14. package/dist/moment.js +240 -0
  15. package/dist/moment.js.map +1 -0
  16. package/dist/perfetto.js +826 -0
  17. package/dist/perfetto.js.map +1 -0
  18. package/dist/report.js +68 -7
  19. package/dist/report.js.map +1 -1
  20. package/dist/save.js +252 -0
  21. package/dist/save.js.map +1 -0
  22. package/dist/sessions.js +704 -0
  23. package/dist/sessions.js.map +1 -0
  24. package/dist/system.js +169 -0
  25. package/dist/system.js.map +1 -0
  26. package/dist/systrace.js +198 -0
  27. package/dist/systrace.js.map +1 -0
  28. package/dist/timeline.js +731 -29
  29. package/dist/timeline.js.map +1 -1
  30. package/dist/trace.js +317 -27
  31. package/dist/trace.js.map +1 -1
  32. package/dist/watermark.js +220 -0
  33. package/dist/watermark.js.map +1 -0
  34. package/package.json +10 -4
  35. package/src/adb.ts +583 -0
  36. package/src/args.ts +177 -0
  37. package/src/capture.ts +292 -0
  38. package/src/cli.ts +367 -0
  39. package/src/device.ts +635 -0
  40. package/src/index.ts +2545 -0
  41. package/src/moment.ts +306 -0
  42. package/src/perfetto.ts +972 -0
  43. package/src/report.ts +285 -0
  44. package/src/save.ts +322 -0
  45. package/src/sessions.ts +894 -0
  46. package/src/system.ts +221 -0
  47. package/src/systrace.ts +258 -0
  48. package/src/timeline.ts +1036 -0
  49. package/src/trace.ts +769 -0
  50. package/src/watermark.ts +337 -0
  51. package/ui/dist/assets/index-BzqwnvoU.js +70 -0
  52. package/ui/dist/assets/index-DtnyBXCM.css +1 -0
  53. package/ui/dist/index.html +2 -2
  54. package/ui/dist/assets/index--1mlZuNZ.css +0 -1
  55. 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
+ }