@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 +22 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +203 -0
- package/dist/commands/measure.d.ts +4 -0
- package/dist/commands/measure.d.ts.map +1 -0
- package/dist/commands/measure.js +99 -0
- package/dist/commands/monitor.d.ts +9 -0
- package/dist/commands/monitor.d.ts.map +1 -0
- package/dist/commands/monitor.js +47 -0
- package/dist/commands/test.d.ts +4 -0
- package/dist/commands/test.d.ts.map +1 -0
- package/dist/commands/test.js +148 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/live-dashboard.d.ts +9 -0
- package/dist/live-dashboard.d.ts.map +1 -0
- package/dist/live-dashboard.js +75 -0
- package/dist/maestro.d.ts +23 -0
- package/dist/maestro.d.ts.map +1 -0
- package/dist/maestro.js +57 -0
- package/dist/spinner.d.ts +9 -0
- package/dist/spinner.d.ts.map +1 -0
- package/dist/spinner.js +37 -0
- package/dist/ws-server.d.ts +114 -0
- package/dist/ws-server.d.ts.map +1 -0
- package/dist/ws-server.js +206 -0
- package/package.json +30 -0
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 @@
|
|
|
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 @@
|
|
|
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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"}
|
package/dist/maestro.js
ADDED
|
@@ -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 @@
|
|
|
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"}
|
package/dist/spinner.js
ADDED
|
@@ -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
|
+
}
|