@lanternajs/cli 0.0.1

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/dist/args.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ export interface MeasureArgs {
2
+ package: string;
3
+ duration: number;
4
+ platform?: "ios" | "android";
5
+ device?: string;
6
+ output?: string;
7
+ baseline?: string;
8
+ }
9
+ export interface TestArgs extends MeasureArgs {
10
+ maestro: string;
11
+ }
12
+ export interface MonitorArgs {
13
+ port: number;
14
+ }
15
+ export interface ParsedCommand {
16
+ command: "measure" | "test" | "monitor";
17
+ args: MeasureArgs | TestArgs | MonitorArgs;
18
+ }
19
+ declare const HELP_TEXT: string;
20
+ export declare function parseArgs(argv: string[]): ParsedCommand | null;
21
+ export { HELP_TEXT };
22
+ //# sourceMappingURL=args.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"args.d.ts","sourceRoot":"","sources":["../src/args.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,QAAS,SAAQ,WAAW;IAC5C,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,aAAa;IAC7B,OAAO,EAAE,SAAS,GAAG,MAAM,GAAG,SAAS,CAAC;IACxC,IAAI,EAAE,WAAW,GAAG,QAAQ,GAAG,WAAW,CAAC;CAC3C;AAED,QAAA,MAAM,SAAS,QAuCP,CAAC;AA2IT,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,aAAa,GAAG,IAAI,CAyB9D;AAED,OAAO,EAAE,SAAS,EAAE,CAAC"}
package/dist/args.js ADDED
@@ -0,0 +1,203 @@
1
+ const HELP_TEXT = `
2
+ lanterna — Performance profiler for React Native apps
3
+
4
+ Usage:
5
+ lanterna measure <package> [options]
6
+ lanterna test --maestro <flow.yaml> [options]
7
+ lanterna monitor [options]
8
+
9
+ Commands:
10
+ measure Collect performance metrics for a running app
11
+ test Run a Maestro E2E flow and collect performance metrics
12
+ monitor Start live monitoring dashboard (WebSocket server)
13
+
14
+ Options (measure):
15
+ --duration <seconds> Measurement duration (default: 10)
16
+ --platform <ios|android> Force platform (auto-detect if omitted)
17
+ --device <id> Target device ID (auto-select if omitted)
18
+ --output <path> Export JSON report to file
19
+ --baseline <path> Compare against a previous JSON report
20
+ --help Show this help message
21
+
22
+ Options (test):
23
+ --maestro <flow.yaml> Path to Maestro flow YAML (required)
24
+ --duration <seconds> Measurement duration (default: 10)
25
+ --platform <ios|android> Force platform (auto-detect if omitted)
26
+ --device <id> Target device ID (auto-select if omitted)
27
+ --output <path> Export JSON report to file
28
+
29
+ Options (monitor):
30
+ --port <number> WebSocket server port (default: 8347)
31
+
32
+ Examples:
33
+ lanterna measure com.example.app
34
+ lanterna measure com.example.app --duration 15
35
+ lanterna measure com.example.app --platform ios --output report.json
36
+ lanterna test --maestro login-flow.yaml
37
+ lanterna test --maestro login-flow.yaml --duration 30 --output report.json
38
+ lanterna monitor
39
+ lanterna monitor --port 9000
40
+ `.trim();
41
+ function parseDuration(value) {
42
+ const stripped = value.replace(/s$/, "");
43
+ const num = Number(stripped);
44
+ if (Number.isNaN(num) || num <= 0) {
45
+ throw new Error(`Invalid duration: "${value}". Must be a positive number (e.g., 10 or 10s).`);
46
+ }
47
+ return num;
48
+ }
49
+ function parseMeasureCommand(args) {
50
+ const result = {
51
+ package: "",
52
+ duration: 10,
53
+ };
54
+ let i = 0;
55
+ while (i < args.length) {
56
+ const arg = args[i];
57
+ if (arg === "--duration" || arg === "-d") {
58
+ i++;
59
+ if (i >= args.length)
60
+ throw new Error("--duration requires a value");
61
+ result.duration = parseDuration(args[i]);
62
+ }
63
+ else if (arg === "--platform" || arg === "-p") {
64
+ i++;
65
+ if (i >= args.length)
66
+ throw new Error("--platform requires a value");
67
+ const val = args[i];
68
+ if (val !== "ios" && val !== "android") {
69
+ throw new Error(`Invalid platform: "${val}". Must be "ios" or "android".`);
70
+ }
71
+ result.platform = val;
72
+ }
73
+ else if (arg === "--device") {
74
+ i++;
75
+ if (i >= args.length)
76
+ throw new Error("--device requires a value");
77
+ result.device = args[i];
78
+ }
79
+ else if (arg === "--output" || arg === "-o") {
80
+ i++;
81
+ if (i >= args.length)
82
+ throw new Error("--output requires a value");
83
+ result.output = args[i];
84
+ }
85
+ else if (arg === "--baseline" || arg === "-b") {
86
+ i++;
87
+ if (i >= args.length)
88
+ throw new Error("--baseline requires a value");
89
+ result.baseline = args[i];
90
+ }
91
+ else if (!arg.startsWith("-") && !result.package) {
92
+ result.package = arg;
93
+ }
94
+ else {
95
+ throw new Error(`Unknown option: "${arg}"`);
96
+ }
97
+ i++;
98
+ }
99
+ if (!result.package) {
100
+ throw new Error("Package name is required. Usage: lanterna measure <package>\n e.g., lanterna measure com.example.app");
101
+ }
102
+ return result;
103
+ }
104
+ function parseTestCommand(args) {
105
+ const result = {
106
+ package: "",
107
+ duration: 10,
108
+ maestro: "",
109
+ };
110
+ let i = 0;
111
+ while (i < args.length) {
112
+ const arg = args[i];
113
+ if (arg === "--maestro" || arg === "-m") {
114
+ i++;
115
+ if (i >= args.length)
116
+ throw new Error("--maestro requires a value");
117
+ result.maestro = args[i];
118
+ }
119
+ else if (arg === "--duration" || arg === "-d") {
120
+ i++;
121
+ if (i >= args.length)
122
+ throw new Error("--duration requires a value");
123
+ result.duration = parseDuration(args[i]);
124
+ }
125
+ else if (arg === "--platform" || arg === "-p") {
126
+ i++;
127
+ if (i >= args.length)
128
+ throw new Error("--platform requires a value");
129
+ const val = args[i];
130
+ if (val !== "ios" && val !== "android") {
131
+ throw new Error(`Invalid platform: "${val}". Must be "ios" or "android".`);
132
+ }
133
+ result.platform = val;
134
+ }
135
+ else if (arg === "--device") {
136
+ i++;
137
+ if (i >= args.length)
138
+ throw new Error("--device requires a value");
139
+ result.device = args[i];
140
+ }
141
+ else if (arg === "--output" || arg === "-o") {
142
+ i++;
143
+ if (i >= args.length)
144
+ throw new Error("--output requires a value");
145
+ result.output = args[i];
146
+ }
147
+ else if (!arg.startsWith("-") && !result.package) {
148
+ result.package = arg;
149
+ }
150
+ else {
151
+ throw new Error(`Unknown option: "${arg}"`);
152
+ }
153
+ i++;
154
+ }
155
+ if (!result.maestro) {
156
+ throw new Error("--maestro flag is required for test command.\n Usage: lanterna test --maestro <flow.yaml>");
157
+ }
158
+ // package can be auto-populated from the flow's appId, so it's not required here
159
+ return result;
160
+ }
161
+ function parseMonitorCommand(args) {
162
+ const result = { port: 8347 };
163
+ let i = 0;
164
+ while (i < args.length) {
165
+ const arg = args[i];
166
+ if (arg === "--port") {
167
+ i++;
168
+ if (i >= args.length)
169
+ throw new Error("--port requires a value");
170
+ const port = Number(args[i]);
171
+ if (Number.isNaN(port) || port <= 0 || port > 65535) {
172
+ throw new Error(`Invalid port: "${args[i]}". Must be 1-65535.`);
173
+ }
174
+ result.port = port;
175
+ }
176
+ else {
177
+ throw new Error(`Unknown option: "${arg}"`);
178
+ }
179
+ i++;
180
+ }
181
+ return result;
182
+ }
183
+ export function parseArgs(argv) {
184
+ const args = argv.slice(0);
185
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
186
+ console.log(HELP_TEXT);
187
+ return null;
188
+ }
189
+ const command = args.shift();
190
+ if (command === "measure") {
191
+ return { command: "measure", args: parseMeasureCommand(args) };
192
+ }
193
+ if (command === "test") {
194
+ return { command: "test", args: parseTestCommand(args) };
195
+ }
196
+ if (command === "monitor") {
197
+ return { command: "monitor", args: parseMonitorCommand(args) };
198
+ }
199
+ console.log(`Unknown command: "${command}"\n`);
200
+ console.log(HELP_TEXT);
201
+ return null;
202
+ }
203
+ export { HELP_TEXT };
@@ -0,0 +1,4 @@
1
+ import { type CommandRunner } from "@lanternajs/core";
2
+ import type { MeasureArgs } from "../args";
3
+ export declare function runMeasure(args: MeasureArgs, runner?: CommandRunner): Promise<number>;
4
+ //# sourceMappingURL=measure.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"measure.d.ts","sourceRoot":"","sources":["../../src/commands/measure.ts"],"names":[],"mappings":"AACA,OAAO,EACN,KAAK,aAAa,EAOlB,MAAM,kBAAkB,CAAC;AAG1B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AA4B3C,wBAAsB,UAAU,CAC/B,IAAI,EAAE,WAAW,EACjB,MAAM,GAAE,aAA6B,GACnC,OAAO,CAAC,MAAM,CAAC,CAsGjB"}
@@ -0,0 +1,99 @@
1
+ import { collectAndroidMetrics } from "@lanternajs/android";
2
+ import { calculateScore, compareScores, defaultRunner, detectDevices, ScoreCategory, } from "@lanternajs/core";
3
+ import { collectIosMetrics } from "@lanternajs/ios";
4
+ import { exportJson, renderComparison, renderReport } from "@lanternajs/report";
5
+ import { Spinner } from "../spinner";
6
+ function selectDevice(devices, args) {
7
+ if (args.device) {
8
+ const match = devices.find((d) => d.id === args.device);
9
+ if (!match) {
10
+ throw new Error(`Device "${args.device}" not found. Available devices:\n${devices.map((d) => ` ${d.id} (${d.name}, ${d.platform})`).join("\n")}`);
11
+ }
12
+ return match;
13
+ }
14
+ if (args.platform) {
15
+ const platformDevices = devices.filter((d) => d.platform === args.platform);
16
+ if (platformDevices.length === 0) {
17
+ throw new Error(`No ${args.platform} devices found. Run "lanterna devices" to see available devices.`);
18
+ }
19
+ return platformDevices[0];
20
+ }
21
+ // Auto-select: first available device (physical devices sorted first by detectDevices)
22
+ return devices[0];
23
+ }
24
+ export async function runMeasure(args, runner = defaultRunner) {
25
+ const spinner = new Spinner();
26
+ try {
27
+ // 1. Detect devices
28
+ spinner.start("Detecting devices...");
29
+ const devices = await detectDevices(runner);
30
+ if (devices.length === 0) {
31
+ spinner.fail("No devices found");
32
+ console.error("\nMake sure you have a device connected or simulator/emulator running.\n" +
33
+ " iOS: Open Simulator or connect a physical device\n" +
34
+ " Android: Start an emulator or connect via ADB");
35
+ return 1;
36
+ }
37
+ const device = selectDevice(devices, args);
38
+ spinner.stop(`Device: ${device.name} (${device.platform}, ${device.type})`);
39
+ // 2. Collect metrics
40
+ spinner.start(`Collecting metrics for ${args.duration}s on ${device.name}...`);
41
+ const session = device.platform === "android"
42
+ ? await collectAndroidMetrics(runner, device, args.package, args.duration)
43
+ : await collectIosMetrics(runner, device, args.package, args.duration);
44
+ spinner.stop(`Collection complete (${session.samples.length} samples)`);
45
+ // 3. Score
46
+ const score = calculateScore(session);
47
+ // 4. Report
48
+ const report = renderReport(session, score);
49
+ console.log(report);
50
+ // 5. Baseline comparison if requested
51
+ if (args.baseline) {
52
+ const baselineFile = Bun.file(args.baseline);
53
+ if (!(await baselineFile.exists())) {
54
+ console.error(`\nBaseline file not found: ${args.baseline}`);
55
+ return 1;
56
+ }
57
+ const baselineData = JSON.parse(await baselineFile.text());
58
+ const baselineScore = baselineData.score;
59
+ const comparison = compareScores({
60
+ overall: baselineScore.overall,
61
+ category: baselineScore.category,
62
+ perMetric: baselineScore.metrics.map((m) => ({
63
+ type: m.type,
64
+ value: m.value,
65
+ score: m.score,
66
+ category: m.category,
67
+ weight: 0,
68
+ })),
69
+ }, score);
70
+ console.log(renderComparison({
71
+ overall: baselineScore.overall,
72
+ category: baselineScore.category,
73
+ perMetric: baselineScore.metrics.map((m) => ({
74
+ type: m.type,
75
+ value: m.value,
76
+ score: m.score,
77
+ category: m.category,
78
+ weight: 0,
79
+ })),
80
+ }, score, comparison));
81
+ if (comparison.hasRegression) {
82
+ console.error(`\n⚠ ${comparison.regressionCount} regression(s) detected`);
83
+ return 1;
84
+ }
85
+ }
86
+ // 6. Export JSON if requested
87
+ if (args.output) {
88
+ await exportJson(session, score, args.output);
89
+ console.log(`\nJSON report saved to ${args.output}`);
90
+ }
91
+ // 7. Exit code
92
+ return score.category === ScoreCategory.POOR ? 1 : 0;
93
+ }
94
+ catch (error) {
95
+ spinner.fail("Measurement failed");
96
+ console.error(`\n${error instanceof Error ? error.message : String(error)}`);
97
+ return 1;
98
+ }
99
+ }
@@ -0,0 +1,9 @@
1
+ import type { MonitorArgs } from "../args";
2
+ /**
3
+ * Run the `lanterna monitor` command.
4
+ *
5
+ * Starts a WebSocket server and displays a live terminal dashboard
6
+ * showing connected apps and their real-time metrics.
7
+ */
8
+ export declare function runMonitor(args: MonitorArgs): Promise<number>;
9
+ //# sourceMappingURL=monitor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"monitor.d.ts","sourceRoot":"","sources":["../../src/commands/monitor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAI3C;;;;;GAKG;AACH,wBAAsB,UAAU,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAsCnE"}
@@ -0,0 +1,47 @@
1
+ import { renderDashboard } from "../live-dashboard";
2
+ import { LanternaServer } from "../ws-server";
3
+ /**
4
+ * Run the `lanterna monitor` command.
5
+ *
6
+ * Starts a WebSocket server and displays a live terminal dashboard
7
+ * showing connected apps and their real-time metrics.
8
+ */
9
+ export async function runMonitor(args) {
10
+ const server = new LanternaServer(args.port);
11
+ server.on((event) => {
12
+ switch (event.type) {
13
+ case "appConnected":
14
+ // Dashboard will auto-update
15
+ break;
16
+ case "appDisconnected":
17
+ // Dashboard will auto-update
18
+ break;
19
+ case "metricsReceived":
20
+ refreshDashboard(server);
21
+ break;
22
+ case "error":
23
+ console.error(`Error: ${event.message}`);
24
+ break;
25
+ }
26
+ });
27
+ server.startListening();
28
+ console.log(renderDashboard(server.connectedApps, server.serverPort, true));
29
+ console.log(`\nWebSocket server listening on ws://localhost:${args.port}`);
30
+ console.log("Waiting for apps to connect...\n");
31
+ // Keep process alive
32
+ await new Promise((resolve) => {
33
+ const handler = () => {
34
+ server.stopListening();
35
+ console.log("\nMonitor stopped.");
36
+ resolve();
37
+ };
38
+ process.on("SIGINT", handler);
39
+ process.on("SIGTERM", handler);
40
+ });
41
+ return 0;
42
+ }
43
+ function refreshDashboard(server) {
44
+ // Clear and redraw
45
+ process.stdout.write("\x1b[2J\x1b[H");
46
+ console.log(renderDashboard(server.connectedApps, server.serverPort, true));
47
+ }
@@ -0,0 +1,4 @@
1
+ import { type CommandRunner } from "@lanternajs/core";
2
+ import type { TestArgs } from "../args";
3
+ export declare function runTest(args: TestArgs, runner?: CommandRunner): Promise<number>;
4
+ //# sourceMappingURL=test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test.d.ts","sourceRoot":"","sources":["../../src/commands/test.ts"],"names":[],"mappings":"AACA,OAAO,EAEN,KAAK,aAAa,EAQlB,MAAM,kBAAkB,CAAC;AAG1B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AA6BxC,wBAAsB,OAAO,CAC5B,IAAI,EAAE,QAAQ,EACd,MAAM,GAAE,aAA6B,GACnC,OAAO,CAAC,MAAM,CAAC,CAmJjB"}
@@ -0,0 +1,148 @@
1
+ import { collectAndroidMetrics } from "@lanternajs/android";
2
+ import { analyzeSession, calculateScore, defaultRunner, detectDevices, MetricType, ScoreCategory, } from "@lanternajs/core";
3
+ import { collectIosMetrics } from "@lanternajs/ios";
4
+ import { exportJson, renderReport } from "@lanternajs/report";
5
+ import { parseMaestroFlow, runMaestro } from "../maestro";
6
+ import { Spinner } from "../spinner";
7
+ import { LanternaServer, WS_DEFAULT_PORT } from "../ws-server";
8
+ function selectDevice(devices, args) {
9
+ if (args.device) {
10
+ const match = devices.find((d) => d.id === args.device);
11
+ if (!match) {
12
+ throw new Error(`Device "${args.device}" not found. Available devices:\n${devices.map((d) => ` ${d.id} (${d.name}, ${d.platform})`).join("\n")}`);
13
+ }
14
+ return match;
15
+ }
16
+ if (args.platform) {
17
+ const platformDevices = devices.filter((d) => d.platform === args.platform);
18
+ if (platformDevices.length === 0) {
19
+ throw new Error(`No ${args.platform} devices found. Run "lanterna devices" to see available devices.`);
20
+ }
21
+ return platformDevices[0];
22
+ }
23
+ return devices[0];
24
+ }
25
+ export async function runTest(args, runner = defaultRunner) {
26
+ const spinner = new Spinner();
27
+ try {
28
+ // 1. Read and parse the Maestro flow YAML
29
+ spinner.start(`Reading Maestro flow: ${args.maestro}...`);
30
+ const flowFile = Bun.file(args.maestro);
31
+ if (!(await flowFile.exists())) {
32
+ spinner.fail(`Maestro flow file not found: ${args.maestro}`);
33
+ return 1;
34
+ }
35
+ const yaml = await flowFile.text();
36
+ const flow = parseMaestroFlow(yaml);
37
+ spinner.stop(`Maestro flow: ${flow.name} (${flow.steps.length} step${flow.steps.length !== 1 ? "s" : ""})`);
38
+ // 2. Extract appId — use as package name unless --package was provided
39
+ const packageName = args.package || flow.appId;
40
+ if (!packageName) {
41
+ spinner.fail("No app ID found. Provide a package name or include appId in the Maestro flow.");
42
+ return 1;
43
+ }
44
+ // 3. Detect devices
45
+ spinner.start("Detecting devices...");
46
+ const devices = await detectDevices(runner);
47
+ if (devices.length === 0) {
48
+ spinner.fail("No devices found");
49
+ console.error("\nMake sure you have a device connected or simulator/emulator running.\n" +
50
+ " iOS: Open Simulator or connect a physical device\n" +
51
+ " Android: Start an emulator or connect via ADB");
52
+ return 1;
53
+ }
54
+ const device = selectDevice(devices, args);
55
+ spinner.stop(`Device: ${device.name} (${device.platform}, ${device.type})`);
56
+ // 4. Start WebSocket server for Tier 2 data from in-app module
57
+ const wsServer = new LanternaServer(WS_DEFAULT_PORT);
58
+ const tier2Samples = [];
59
+ wsServer.on((event) => {
60
+ if (event.type === "metricsReceived") {
61
+ const app = wsServer.getApp(event.sessionId);
62
+ if (!app)
63
+ return;
64
+ const now = Date.now();
65
+ if (app.fps) {
66
+ tier2Samples.push({
67
+ type: MetricType.UI_FPS,
68
+ value: app.fps.ui,
69
+ timestamp: now,
70
+ unit: "fps",
71
+ });
72
+ if (app.fps.droppedFrames > 0) {
73
+ tier2Samples.push({
74
+ type: MetricType.FRAME_DROPS,
75
+ value: app.fps.droppedFrames,
76
+ timestamp: now,
77
+ unit: "frames",
78
+ });
79
+ }
80
+ }
81
+ if (app.memory) {
82
+ tier2Samples.push({
83
+ type: MetricType.MEMORY,
84
+ value: app.memory.usedMb,
85
+ timestamp: now,
86
+ unit: "MB",
87
+ });
88
+ }
89
+ }
90
+ });
91
+ wsServer.startListening();
92
+ // 5. Start metric collection (Tier 1) and Maestro in parallel
93
+ spinner.start(`Running Maestro flow and collecting metrics on ${device.name}...`);
94
+ const collectMetrics = device.platform === "android"
95
+ ? collectAndroidMetrics(runner, device, packageName, args.duration)
96
+ : collectIosMetrics(runner, device, packageName, args.duration);
97
+ const maestroResult = runMaestro(runner, args.maestro);
98
+ const [session, maestro] = await Promise.all([collectMetrics, maestroResult]);
99
+ // 6. Stop WebSocket server and merge Tier 2 data
100
+ wsServer.stopListening();
101
+ if (tier2Samples.length > 0) {
102
+ session.samples.push(...tier2Samples);
103
+ }
104
+ spinner.stop(`Collection complete (${session.samples.length} samples, ${tier2Samples.length} from Tier 2)`);
105
+ // 7. Score the session
106
+ const score = calculateScore(session);
107
+ // 8. Render report
108
+ const report = renderReport(session, score);
109
+ console.log(report);
110
+ // 9. Show heuristic recommendations
111
+ const recommendations = analyzeSession(session, score);
112
+ if (recommendations.length > 0) {
113
+ console.log("\nRecommendations:");
114
+ for (const rec of recommendations) {
115
+ const icon = rec.severity === "critical" ? "!!" : rec.severity === "warning" ? "!" : "i";
116
+ console.log(` [${icon}] ${rec.title}`);
117
+ console.log(` ${rec.message}`);
118
+ if (rec.suggestion)
119
+ console.log(` Fix: ${rec.suggestion}`);
120
+ }
121
+ }
122
+ // 10. Show maestro pass/fail status
123
+ const maestroPassed = maestro.exitCode === 0;
124
+ if (maestroPassed) {
125
+ console.log("\nMaestro: PASSED");
126
+ }
127
+ else {
128
+ console.error("\nMaestro: FAILED");
129
+ if (maestro.output) {
130
+ console.error(maestro.output);
131
+ }
132
+ }
133
+ // 11. Export JSON if --output specified
134
+ if (args.output) {
135
+ await exportJson(session, score, args.output);
136
+ console.log(`\nJSON report saved to ${args.output}`);
137
+ }
138
+ // 12. Return exit code (0 if both maestro passed and score is not poor)
139
+ if (!maestroPassed)
140
+ return 1;
141
+ return score.category === ScoreCategory.POOR ? 1 : 0;
142
+ }
143
+ catch (error) {
144
+ spinner.fail("Test failed");
145
+ console.error(`\n${error instanceof Error ? error.message : String(error)}`);
146
+ return 1;
147
+ }
148
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bun
2
+ import { parseArgs } from "./args";
3
+ import { runMeasure } from "./commands/measure";
4
+ import { runMonitor } from "./commands/monitor";
5
+ import { runTest } from "./commands/test";
6
+ const argv = process.argv.slice(2);
7
+ const parsed = parseArgs(argv);
8
+ if (parsed) {
9
+ let exitCode;
10
+ if (parsed.command === "test") {
11
+ exitCode = await runTest(parsed.args);
12
+ }
13
+ else if (parsed.command === "monitor") {
14
+ exitCode = await runMonitor(parsed.args);
15
+ }
16
+ else {
17
+ exitCode = await runMeasure(parsed.args);
18
+ }
19
+ process.exit(exitCode);
20
+ }
@@ -0,0 +1,9 @@
1
+ import type { ConnectedApp } from "./ws-server";
2
+ /**
3
+ * Render a live terminal dashboard showing connected apps and their metrics.
4
+ *
5
+ * Returns a string that can be printed to the terminal.
6
+ * Uses ANSI escape codes for formatting.
7
+ */
8
+ export declare function renderDashboard(apps: ConnectedApp[], serverPort: number, isRunning: boolean): string;
9
+ //# sourceMappingURL=live-dashboard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"live-dashboard.d.ts","sourceRoot":"","sources":["../src/live-dashboard.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD;;;;;GAKG;AACH,wBAAgB,eAAe,CAC9B,IAAI,EAAE,YAAY,EAAE,EACpB,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,OAAO,GAChB,MAAM,CAoCR"}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Render a live terminal dashboard showing connected apps and their metrics.
3
+ *
4
+ * Returns a string that can be printed to the terminal.
5
+ * Uses ANSI escape codes for formatting.
6
+ */
7
+ export function renderDashboard(apps, serverPort, isRunning) {
8
+ const lines = [];
9
+ lines.push("╭─────────────────────────────────────────────╮");
10
+ lines.push(`│ \x1b[1mlanterna monitor\x1b[0m │`);
11
+ lines.push(`│ Port: ${serverPort} Status: ${isRunning ? "\x1b[32m● running\x1b[0m" : "\x1b[31m○ stopped\x1b[0m"} │`);
12
+ lines.push("├─────────────────────────────────────────────┤");
13
+ if (apps.length === 0) {
14
+ lines.push("│ Waiting for apps to connect... │");
15
+ lines.push("│ │");
16
+ lines.push("│ Add lanterna-react-native to your app │");
17
+ lines.push("│ and it will auto-connect. │");
18
+ }
19
+ else {
20
+ for (const app of apps) {
21
+ lines.push(`│ \x1b[1m${padRight(app.appId, 40)}\x1b[0m │`);
22
+ lines.push(`│ ${padRight(`${app.deviceName} (${app.platform})`, 40)} │`);
23
+ if (app.currentScreen) {
24
+ lines.push(`│ ${padRight(`\x1b[36m${app.currentScreen}\x1b[0m`, 40)} │`);
25
+ }
26
+ const metricLines = formatMetrics(app);
27
+ for (const ml of metricLines) {
28
+ lines.push(`│ ${padRight(ml, 40)} │`);
29
+ }
30
+ lines.push("│ │");
31
+ }
32
+ }
33
+ lines.push("│ Press Ctrl+C to stop │");
34
+ lines.push("╰─────────────────────────────────────────────╯");
35
+ return lines.join("\n");
36
+ }
37
+ function formatMetrics(app) {
38
+ const lines = [];
39
+ const m = app.latestMetrics;
40
+ if (app.fps) {
41
+ const uiColor = app.fps.ui >= 55 ? "\x1b[32m" : app.fps.ui >= 40 ? "\x1b[33m" : "\x1b[31m";
42
+ lines.push(`${uiColor}UI FPS: ${app.fps.ui.toFixed(1)}\x1b[0m Drops: ${app.fps.droppedFrames}`);
43
+ }
44
+ if (m.ui_fps !== undefined && !app.fps) {
45
+ const color = m.ui_fps >= 55 ? "\x1b[32m" : m.ui_fps >= 40 ? "\x1b[33m" : "\x1b[31m";
46
+ lines.push(`${color}UI FPS: ${m.ui_fps.toFixed(1)}\x1b[0m`);
47
+ }
48
+ if (m.js_fps !== undefined) {
49
+ const color = m.js_fps >= 55 ? "\x1b[32m" : m.js_fps >= 40 ? "\x1b[33m" : "\x1b[31m";
50
+ lines.push(`${color}JS FPS: ${m.js_fps.toFixed(1)}\x1b[0m`);
51
+ }
52
+ if (m.cpu !== undefined) {
53
+ const color = m.cpu <= 30 ? "\x1b[32m" : m.cpu <= 60 ? "\x1b[33m" : "\x1b[31m";
54
+ lines.push(`${color}CPU: ${m.cpu.toFixed(1)}%\x1b[0m`);
55
+ }
56
+ if (app.memory) {
57
+ const color = app.memory.usedMb <= 300 ? "\x1b[32m" : app.memory.usedMb <= 500 ? "\x1b[33m" : "\x1b[31m";
58
+ lines.push(`${color}Memory: ${app.memory.usedMb} MB\x1b[0m`);
59
+ }
60
+ else if (m.memory !== undefined) {
61
+ const color = m.memory <= 300 ? "\x1b[32m" : m.memory <= 500 ? "\x1b[33m" : "\x1b[31m";
62
+ lines.push(`${color}Memory: ${m.memory.toFixed(0)} MB\x1b[0m`);
63
+ }
64
+ if (lines.length === 0) {
65
+ lines.push("Awaiting metrics...");
66
+ }
67
+ return lines;
68
+ }
69
+ function padRight(str, len) {
70
+ // Strip ANSI escape codes for length calculation
71
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes are intentional
72
+ const stripped = str.replace(/\x1b\[[0-9;]*m/g, "");
73
+ const padding = Math.max(0, len - stripped.length);
74
+ return str + " ".repeat(padding);
75
+ }
@@ -0,0 +1,23 @@
1
+ import type { CommandRunner } from "@lanternajs/core";
2
+ export interface MaestroStep {
3
+ name: string;
4
+ lineNumber: number;
5
+ }
6
+ export interface MaestroFlow {
7
+ name: string;
8
+ appId: string;
9
+ steps: MaestroStep[];
10
+ }
11
+ /**
12
+ * Parse a Maestro YAML flow file into a structured MaestroFlow.
13
+ * Extracts appId and recognized Maestro command steps.
14
+ */
15
+ export declare function parseMaestroFlow(yaml: string): MaestroFlow;
16
+ /**
17
+ * Run a Maestro flow file using the `maestro test` CLI command.
18
+ */
19
+ export declare function runMaestro(runner: CommandRunner, flowPath: string): Promise<{
20
+ exitCode: number;
21
+ output: string;
22
+ }>;
23
+ //# sourceMappingURL=maestro.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"maestro.d.ts","sourceRoot":"","sources":["../src/maestro.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,WAAW,EAAE,CAAC;CACrB;AAgBD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAmC1D;AAED;;GAEG;AACH,wBAAsB,UAAU,CAC/B,MAAM,EAAE,aAAa,EACrB,QAAQ,EAAE,MAAM,GACd,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAM/C"}
@@ -0,0 +1,57 @@
1
+ const KNOWN_COMMANDS = new Set([
2
+ "tapOn",
3
+ "assertVisible",
4
+ "inputText",
5
+ "scrollUntilVisible",
6
+ "back",
7
+ "swipe",
8
+ "waitForAnimationToEnd",
9
+ "launchApp",
10
+ "clearState",
11
+ "runFlow",
12
+ "openLink",
13
+ ]);
14
+ /**
15
+ * Parse a Maestro YAML flow file into a structured MaestroFlow.
16
+ * Extracts appId and recognized Maestro command steps.
17
+ */
18
+ export function parseMaestroFlow(yaml) {
19
+ const lines = yaml.split("\n");
20
+ let appId = "";
21
+ const steps = [];
22
+ for (let i = 0; i < lines.length; i++) {
23
+ const line = lines[i];
24
+ const lineNumber = i + 1;
25
+ // Extract appId from lines like `appId: "com.example.app"` or `appId: com.example.app`
26
+ const appIdMatch = line.match(/^\s*appId:\s*"?([^"\s]+)"?\s*$/);
27
+ if (appIdMatch) {
28
+ appId = appIdMatch[1];
29
+ continue;
30
+ }
31
+ // Extract steps: lines starting with `- ` that contain known Maestro commands
32
+ const stepMatch = line.match(/^\s*-\s+(\w+)(?::\s*(.*))?$/);
33
+ if (!stepMatch)
34
+ continue;
35
+ const command = stepMatch[1];
36
+ if (!KNOWN_COMMANDS.has(command))
37
+ continue;
38
+ const rawArg = stepMatch[2]?.trim() ?? "";
39
+ // Strip surrounding quotes from the argument
40
+ const arg = rawArg.replace(/^"(.*)"$/, "$1");
41
+ const name = arg ? `${command} "${arg}"` : command;
42
+ steps.push({ name, lineNumber });
43
+ }
44
+ // Derive flow name from appId or default
45
+ const name = appId ? `Flow: ${appId}` : "Unnamed flow";
46
+ return { name, appId, steps };
47
+ }
48
+ /**
49
+ * Run a Maestro flow file using the `maestro test` CLI command.
50
+ */
51
+ export async function runMaestro(runner, flowPath) {
52
+ const result = await runner("maestro", ["test", flowPath]);
53
+ return {
54
+ exitCode: result.exitCode,
55
+ output: result.stdout,
56
+ };
57
+ }
@@ -0,0 +1,9 @@
1
+ export declare class Spinner {
2
+ private interval;
3
+ private frameIndex;
4
+ start(message: string): void;
5
+ update(message: string): void;
6
+ stop(message?: string): void;
7
+ fail(message: string): void;
8
+ }
9
+ //# sourceMappingURL=spinner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spinner.d.ts","sourceRoot":"","sources":["../src/spinner.ts"],"names":[],"mappings":"AAEA,qBAAa,OAAO;IACnB,OAAO,CAAC,QAAQ,CAA+C;IAC/D,OAAO,CAAC,UAAU,CAAK;IAEvB,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAS5B,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAM7B,IAAI,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI;IAY5B,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;CAO3B"}
@@ -0,0 +1,37 @@
1
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2
+ export class Spinner {
3
+ interval = null;
4
+ frameIndex = 0;
5
+ start(message) {
6
+ this.frameIndex = 0;
7
+ process.stdout.write(`\r${FRAMES[0]} ${message}`);
8
+ this.interval = setInterval(() => {
9
+ this.frameIndex = (this.frameIndex + 1) % FRAMES.length;
10
+ process.stdout.write(`\r${FRAMES[this.frameIndex]} ${message}`);
11
+ }, 80);
12
+ }
13
+ update(message) {
14
+ if (this.interval) {
15
+ process.stdout.write(`\r${FRAMES[this.frameIndex]} ${message}`);
16
+ }
17
+ }
18
+ stop(message) {
19
+ if (this.interval) {
20
+ clearInterval(this.interval);
21
+ this.interval = null;
22
+ }
23
+ if (message) {
24
+ process.stdout.write(`\r✓ ${message}\n`);
25
+ }
26
+ else {
27
+ process.stdout.write("\r\x1b[K");
28
+ }
29
+ }
30
+ fail(message) {
31
+ if (this.interval) {
32
+ clearInterval(this.interval);
33
+ this.interval = null;
34
+ }
35
+ process.stdout.write(`\r✗ ${message}\n`);
36
+ }
37
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * WebSocket server for receiving real-time metrics from lanterna-react-native apps.
3
+ */
4
+ /** Message types from the protocol (duplicated here to avoid dependency on react-native package). */
5
+ export type ServerMessageType = "handshake" | "handshake_ack" | "metrics" | "control" | "disconnect" | "error";
6
+ /** Parsed message from a connected app. */
7
+ export interface ServerMessage {
8
+ type: ServerMessageType;
9
+ timestamp: number;
10
+ [key: string]: unknown;
11
+ }
12
+ /** A connected app session. */
13
+ export interface ConnectedApp {
14
+ sessionId: string;
15
+ appId: string;
16
+ platform: "ios" | "android";
17
+ deviceName: string;
18
+ connectedAt: number;
19
+ lastMetricsAt: number;
20
+ latestMetrics: Record<string, number>;
21
+ fps?: {
22
+ ui: number;
23
+ js: number;
24
+ droppedFrames: number;
25
+ };
26
+ memory?: {
27
+ usedMb: number;
28
+ };
29
+ currentScreen?: string;
30
+ }
31
+ /** Server event listener. */
32
+ export type ServerEventListener = (event: ServerEvent) => void;
33
+ /** Server events. */
34
+ export type ServerEvent = {
35
+ type: "appConnected";
36
+ app: ConnectedApp;
37
+ } | {
38
+ type: "appDisconnected";
39
+ sessionId: string;
40
+ reason: string;
41
+ } | {
42
+ type: "metricsReceived";
43
+ sessionId: string;
44
+ metrics: Record<string, number>;
45
+ } | {
46
+ type: "serverStarted";
47
+ port: number;
48
+ } | {
49
+ type: "serverStopped";
50
+ } | {
51
+ type: "error";
52
+ message: string;
53
+ };
54
+ /** Default port for the Lanterna WebSocket server. */
55
+ export declare const WS_DEFAULT_PORT = 8347;
56
+ /**
57
+ * Lanterna WebSocket metric server.
58
+ *
59
+ * Accepts connections from instrumented apps, processes handshakes,
60
+ * and collects streaming metrics. Pure logic — no WebSocket runtime
61
+ * dependency, allowing full testing in Bun.
62
+ */
63
+ export declare class LanternaServer {
64
+ private apps;
65
+ private listeners;
66
+ private running;
67
+ private port;
68
+ private httpServer;
69
+ constructor(port?: number);
70
+ /** Whether the server is running. */
71
+ get isRunning(): boolean;
72
+ /** Server port. */
73
+ get serverPort(): number;
74
+ /** All currently connected apps. */
75
+ get connectedApps(): ConnectedApp[];
76
+ /** Get a connected app by session ID. */
77
+ getApp(sessionId: string): ConnectedApp | null;
78
+ /**
79
+ * Start the server (sets running state).
80
+ */
81
+ start(): void;
82
+ /**
83
+ * Stop the server.
84
+ */
85
+ stop(): void;
86
+ /**
87
+ * Start the server with a real WebSocket listener bound to the port.
88
+ * Uses Bun's native WebSocket support — no external library needed.
89
+ */
90
+ startListening(): void;
91
+ /**
92
+ * Stop the server and close the WebSocket listener.
93
+ */
94
+ stopListening(): void;
95
+ /**
96
+ * Process an incoming message from a client.
97
+ * Returns a response message to send back, or null if no response needed.
98
+ */
99
+ handleMessage(data: string): string | null;
100
+ /** Subscribe to server events. Returns unsubscribe function. */
101
+ on(listener: ServerEventListener): () => void;
102
+ /** Remove all listeners. */
103
+ removeAllListeners(): void;
104
+ private handleHandshake;
105
+ private handleMetrics;
106
+ private handleDisconnect;
107
+ private emit;
108
+ }
109
+ /**
110
+ * Parse a raw message string into a ServerMessage.
111
+ * Returns null for invalid messages.
112
+ */
113
+ export declare function parseServerMessage(data: string): ServerMessage | null;
114
+ //# sourceMappingURL=ws-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ws-server.d.ts","sourceRoot":"","sources":["../src/ws-server.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,206 @@
1
+ /**
2
+ * WebSocket server for receiving real-time metrics from lanterna-react-native apps.
3
+ */
4
+ /** Default port for the Lanterna WebSocket server. */
5
+ export const WS_DEFAULT_PORT = 8347;
6
+ let sessionCounter = 0;
7
+ function generateSessionId() {
8
+ sessionCounter++;
9
+ return `lanterna-${Date.now()}-${sessionCounter}`;
10
+ }
11
+ /**
12
+ * Lanterna WebSocket metric server.
13
+ *
14
+ * Accepts connections from instrumented apps, processes handshakes,
15
+ * and collects streaming metrics. Pure logic — no WebSocket runtime
16
+ * dependency, allowing full testing in Bun.
17
+ */
18
+ export class LanternaServer {
19
+ apps = new Map();
20
+ listeners = new Set();
21
+ running = false;
22
+ port;
23
+ httpServer = null;
24
+ constructor(port = WS_DEFAULT_PORT) {
25
+ this.port = port;
26
+ }
27
+ /** Whether the server is running. */
28
+ get isRunning() {
29
+ return this.running;
30
+ }
31
+ /** Server port. */
32
+ get serverPort() {
33
+ return this.port;
34
+ }
35
+ /** All currently connected apps. */
36
+ get connectedApps() {
37
+ return [...this.apps.values()];
38
+ }
39
+ /** Get a connected app by session ID. */
40
+ getApp(sessionId) {
41
+ return this.apps.get(sessionId) ?? null;
42
+ }
43
+ /**
44
+ * Start the server (sets running state).
45
+ */
46
+ start() {
47
+ if (this.running)
48
+ return;
49
+ this.running = true;
50
+ this.emit({ type: "serverStarted", port: this.port });
51
+ }
52
+ /**
53
+ * Stop the server.
54
+ */
55
+ stop() {
56
+ if (!this.running)
57
+ return;
58
+ this.running = false;
59
+ this.apps.clear();
60
+ this.emit({ type: "serverStopped" });
61
+ }
62
+ /**
63
+ * Start the server with a real WebSocket listener bound to the port.
64
+ * Uses Bun's native WebSocket support — no external library needed.
65
+ */
66
+ startListening() {
67
+ if (this.running)
68
+ return;
69
+ const server = this;
70
+ this.httpServer = Bun.serve({
71
+ port: this.port,
72
+ fetch(req, bunServer) {
73
+ if (bunServer.upgrade(req, { data: {} }))
74
+ return undefined;
75
+ return new Response("Lanterna WebSocket Server", { status: 200 });
76
+ },
77
+ websocket: {
78
+ message(ws, data) {
79
+ const response = server.handleMessage(String(data));
80
+ if (response)
81
+ ws.send(response);
82
+ },
83
+ close(_ws) {
84
+ // App disconnected without sending disconnect message
85
+ },
86
+ },
87
+ });
88
+ this.start();
89
+ }
90
+ /**
91
+ * Stop the server and close the WebSocket listener.
92
+ */
93
+ stopListening() {
94
+ this.httpServer?.stop();
95
+ this.httpServer = null;
96
+ this.stop();
97
+ }
98
+ /**
99
+ * Process an incoming message from a client.
100
+ * Returns a response message to send back, or null if no response needed.
101
+ */
102
+ handleMessage(data) {
103
+ const message = parseServerMessage(data);
104
+ if (!message) {
105
+ this.emit({ type: "error", message: "Invalid message received" });
106
+ return null;
107
+ }
108
+ switch (message.type) {
109
+ case "handshake":
110
+ return this.handleHandshake(message);
111
+ case "metrics":
112
+ this.handleMetrics(message);
113
+ return null;
114
+ case "disconnect":
115
+ this.handleDisconnect(message);
116
+ return null;
117
+ default:
118
+ return null;
119
+ }
120
+ }
121
+ /** Subscribe to server events. Returns unsubscribe function. */
122
+ on(listener) {
123
+ this.listeners.add(listener);
124
+ return () => this.listeners.delete(listener);
125
+ }
126
+ /** Remove all listeners. */
127
+ removeAllListeners() {
128
+ this.listeners.clear();
129
+ }
130
+ handleHandshake(message) {
131
+ const sessionId = generateSessionId();
132
+ const app = {
133
+ sessionId,
134
+ appId: message.appId ?? "unknown",
135
+ platform: message.platform ?? "android",
136
+ deviceName: message.deviceName ?? "unknown",
137
+ connectedAt: Date.now(),
138
+ lastMetricsAt: 0,
139
+ latestMetrics: {},
140
+ };
141
+ this.apps.set(sessionId, app);
142
+ this.emit({ type: "appConnected", app });
143
+ const ack = {
144
+ type: "handshake_ack",
145
+ timestamp: Date.now(),
146
+ sessionId,
147
+ config: { intervalMs: 1000, metrics: [] },
148
+ };
149
+ return JSON.stringify(ack);
150
+ }
151
+ handleMetrics(message) {
152
+ const sessionId = message.sessionId;
153
+ const app = this.apps.get(sessionId);
154
+ if (!app)
155
+ return;
156
+ const metrics = message.metrics ?? {};
157
+ app.lastMetricsAt = Date.now();
158
+ app.latestMetrics = metrics;
159
+ if (message.fps) {
160
+ app.fps = message.fps;
161
+ }
162
+ if (message.memory) {
163
+ app.memory = message.memory;
164
+ }
165
+ if (typeof message.currentScreen === "string") {
166
+ app.currentScreen = message.currentScreen;
167
+ }
168
+ this.emit({ type: "metricsReceived", sessionId, metrics });
169
+ }
170
+ handleDisconnect(message) {
171
+ const sessionId = message.sessionId;
172
+ const reason = message.reason ?? "client disconnected";
173
+ // Find and remove by sessionId or by matching
174
+ for (const [id, _app] of this.apps) {
175
+ if (id === sessionId) {
176
+ this.apps.delete(id);
177
+ this.emit({ type: "appDisconnected", sessionId: id, reason });
178
+ return;
179
+ }
180
+ }
181
+ }
182
+ emit(event) {
183
+ for (const listener of this.listeners) {
184
+ listener(event);
185
+ }
186
+ }
187
+ }
188
+ /**
189
+ * Parse a raw message string into a ServerMessage.
190
+ * Returns null for invalid messages.
191
+ */
192
+ export function parseServerMessage(data) {
193
+ try {
194
+ const parsed = JSON.parse(data);
195
+ if (!parsed || typeof parsed !== "object")
196
+ return null;
197
+ if (typeof parsed.type !== "string")
198
+ return null;
199
+ if (typeof parsed.timestamp !== "number")
200
+ return null;
201
+ return parsed;
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@lanternajs/cli",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "bin": {
8
+ "lanterna": "dist/index.js"
9
+ },
10
+ "files": ["dist"],
11
+ "license": "Apache-2.0",
12
+ "description": "CLI entry point for Lanterna — Lighthouse for mobile apps",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/nicepkg/lanterna.git",
16
+ "directory": "packages/cli"
17
+ },
18
+ "publishConfig": { "access": "public" },
19
+ "dependencies": {
20
+ "@lanternajs/core": "workspace:*",
21
+ "@lanternajs/android": "workspace:*",
22
+ "@lanternajs/ios": "workspace:*",
23
+ "@lanternajs/report": "workspace:*"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc --project tsconfig.build.json",
27
+ "prepublishOnly": "npm run build",
28
+ "test": "bun test"
29
+ }
30
+ }