everyscreen 0.0.0-stage → 0.1.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -2
  3. package/dist/cli.d.mts +1 -0
  4. package/dist/cli.mjs +2958 -0
  5. package/dist/commands-logic-DVWfSe3O.mjs +217 -0
  6. package/dist/dashboard/assets/button-N-Nucq_K.js +1 -0
  7. package/dist/dashboard/assets/cn-DMYzmXtt.js +1 -0
  8. package/dist/dashboard/assets/device-status-logic-Silf3YXA.js +1 -0
  9. package/dist/dashboard/assets/devices-5pEUg6rA.js +1 -0
  10. package/dist/dashboard/assets/dist-CnPFbxes.js +1 -0
  11. package/dist/dashboard/assets/focus._deviceId-DGel-kWw.js +1 -0
  12. package/dist/dashboard/assets/frame-layout-logic-H_x71NvW.js +1 -0
  13. package/dist/dashboard/assets/index-DNHZ-i1B.css +2 -0
  14. package/dist/dashboard/assets/index-DwPQ0eY_.js +10 -0
  15. package/dist/dashboard/assets/modal-dialog-BaO9xkRr.js +1 -0
  16. package/dist/dashboard/assets/rolldown-runtime-WkwZ5CU-.js +1 -0
  17. package/dist/dashboard/assets/routes-CUFa9tL0.js +1 -0
  18. package/dist/dashboard/assets/run-origin-badge-BeFqg0AC.js +1 -0
  19. package/dist/dashboard/assets/runs-queries-BYBE7R9S.js +2 -0
  20. package/dist/dashboard/assets/runs._runId-CokpJ1Rr.js +1 -0
  21. package/dist/dashboard/assets/runs.index-ZsynzkKU.js +1 -0
  22. package/dist/dashboard/assets/schemas-BPFqmBjG.js +55 -0
  23. package/dist/dashboard/assets/screen-boundary-BauyD9pu.js +1 -0
  24. package/dist/dashboard/assets/status-chip-yqTTTuyZ.js +1 -0
  25. package/dist/dashboard/assets/tile-status-chip-DisgbNnH.js +1 -0
  26. package/dist/dashboard/assets/toggle-nciug_di.js +1 -0
  27. package/dist/dashboard/assets/use-run-totals-B3G4yamQ.js +1 -0
  28. package/dist/dashboard/assets/useNavigate-8mkM-ffY.js +1 -0
  29. package/dist/dashboard/assets/useParams-D2lK5BEg.js +1 -0
  30. package/dist/dashboard/index.html +29 -0
  31. package/dist/devices-store-edScJDie.mjs +1228 -0
  32. package/dist/playwright-logic-QFlBFspE.d.mts +138 -0
  33. package/dist/playwright.d.mts +2 -0
  34. package/dist/playwright.mjs +18 -0
  35. package/dist/read-hub-file-N4rZ-6Wr.mjs +180 -0
  36. package/dist/report-hub-trouble-BjbcuK21.d.mts +2 -0
  37. package/dist/report-hub-trouble-CaUiyteI.mjs +41 -0
  38. package/dist/reporter.d.mts +47 -0
  39. package/dist/reporter.mjs +250 -0
  40. package/dist/run-one-at-a-time-BeV_a3Fm.mjs +48 -0
  41. package/dist/runs-store-Bd_ITPFj.mjs +283 -0
  42. package/dist/test.d.mts +11 -0
  43. package/dist/test.mjs +69 -0
  44. package/package.json +106 -4
  45. package/src/dashboard/ui/theme.css +28 -0
  46. package/src/skill/SKILL.md +15 -0
package/dist/cli.mjs ADDED
@@ -0,0 +1,2958 @@
1
+ #!/usr/bin/env node
2
+ import { _ as gridUpdateSchema, a as checkNewDevice, c as listCatalogReplies, d as pickGridChanges, f as pickGridDevices, h as customDeviceSchema, i as checkDeviceRemoval, l as pickDeviceScreen, m as DEVICE_CATALOG, n as readDevicesFile, o as createPresetFromCustomDevice, p as replaceGrid, r as updateDevicesFile, s as findCatalogPreset, u as pickDeviceViewport } from "./devices-store-edScJDie.mjs";
3
+ import { C as SCREENCAST, D as writeProblem, E as rejectInvalidRequest, T as ZOOM_SCREENCAST, a as createStepTitle, c as pickChosenPages, d as pickFailureMessage, f as pickOpenApp, h as hubFileSchema, i as createShotPath, l as pickDeviceIds, m as countDeviceStatuses, n as createCommandRunStep, o as listDeviceFailures, p as summarizeCommand, r as createReplyLine, s as pickCheckDevice, t as countUnits, u as pickErrorMessage, v as HUB_FILE_PATH, w as STORE_FOLDER, x as LIVE_PATH, y as HUB_HOST } from "./commands-logic-DVWfSe3O.mjs";
4
+ import { i as writeStoredFile, n as readStoredBytes, r as readStoredFile, t as runOneAtATime } from "./run-one-at-a-time-BeV_a3Fm.mjs";
5
+ import { _ as listFindings, a as readRun, c as runOriginSchema, d as isRunOpen, f as pickStepId, g as isRequestFailureEcho, h as isFaviconUrl, i as listRuns, l as RUN_ID_PATTERN, m as createRequestFinding, n as closeRun, o as saveRunEvent, p as createConsoleFinding, r as createRun, s as runEventSchema, t as closeLeftOpenRuns, u as createStepId } from "./runs-store-Bd_ITPFj.mjs";
6
+ import { parseArgs } from "node:util";
7
+ import { HTTPException } from "hono/http-exception";
8
+ import { z } from "zod";
9
+ import { join } from "node:path";
10
+ import { hc } from "hono/client";
11
+ import { glob, mkdir, open, readFile, rm } from "node:fs/promises";
12
+ import { spawn } from "node:child_process";
13
+ import { once } from "node:events";
14
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
15
+ import { fileURLToPath } from "node:url";
16
+ import { serve, upgradeWebSocket } from "@hono/node-server";
17
+ import { serveStatic } from "@hono/node-server/serve-static";
18
+ import { Hono } from "hono";
19
+ import { WebSocketServer } from "ws";
20
+ import { chromium, errors, firefox, webkit } from "playwright";
21
+ import { parse } from "yaml";
22
+ import { zValidator } from "@hono/zod-validator";
23
+ // src/cli/cli-flags.ts
24
+ const FLAG_TYPES = {
25
+ json: { type: "boolean" },
26
+ help: { type: "boolean" },
27
+ foreground: { type: "boolean" },
28
+ touch: { type: "boolean" },
29
+ yes: { type: "boolean" },
30
+ port: { type: "string" },
31
+ device: { type: "string" },
32
+ devices: { type: "string" },
33
+ before: { type: "string" },
34
+ "max-routes": { type: "string" },
35
+ url: { type: "string" },
36
+ role: { type: "string" },
37
+ name: { type: "string" },
38
+ with: { type: "string" },
39
+ to: { type: "string" },
40
+ text: { type: "string" },
41
+ flows: { type: "string" },
42
+ parallel: { type: "string" },
43
+ agent: { type: "string" },
44
+ width: { type: "string" },
45
+ height: { type: "string" },
46
+ "pixel-ratio": { type: "string" }
47
+ };
48
+ const parseCommandLine = (commandLineWords) => parseArgs({
49
+ args: commandLineWords,
50
+ options: FLAG_TYPES,
51
+ allowPositionals: true,
52
+ strict: true
53
+ });
54
+ const readCommandLine = (commandLineWords) => {
55
+ try {
56
+ const { positionals, values } = parseCommandLine(commandLineWords);
57
+ return {
58
+ kind: "read",
59
+ commandWords: positionals,
60
+ flags: values
61
+ };
62
+ } catch (error) {
63
+ const [reason] = (error instanceof Error ? error.message : "unreadable flags").split(". ");
64
+ return {
65
+ kind: "unreadable",
66
+ message: `bad usage · ${reason} · run everyscreen --help`
67
+ };
68
+ }
69
+ };
70
+ // src/cli/cli-help-pages.ts
71
+ const CLI_COMMAND_NAMES = [
72
+ "start",
73
+ "stop",
74
+ "status",
75
+ "open",
76
+ "tap",
77
+ "fill",
78
+ "scroll",
79
+ "check",
80
+ "shot",
81
+ "expect",
82
+ "sweep",
83
+ "devices list",
84
+ "devices add",
85
+ "devices remove",
86
+ "run",
87
+ "skill install"
88
+ ];
89
+ const DEVICE_FLAG_HELP = {
90
+ flag: "--device <id>",
91
+ description: "Run on one device. Default: every device"
92
+ };
93
+ const YES_FLAG_HELP = {
94
+ flag: "--yes",
95
+ description: "Go ahead without asking. Needed when there is no terminal"
96
+ };
97
+ const COMMAND_HELPS = {
98
+ start: {
99
+ summary: "Start the hub and one browser per device, in the background",
100
+ usage: "everyscreen start [--port <number>] [--foreground]",
101
+ flagHelps: [{
102
+ flag: "--port <number>",
103
+ description: "Port for the hub. Default: 4100"
104
+ }, {
105
+ flag: "--foreground",
106
+ description: "Keep the hub in this terminal instead of the background"
107
+ }]
108
+ },
109
+ stop: {
110
+ summary: "Stop the hub and close every device browser",
111
+ usage: "everyscreen stop",
112
+ flagHelps: []
113
+ },
114
+ status: {
115
+ summary: "Show whether the hub is running and how many devices it has",
116
+ usage: "everyscreen status",
117
+ flagHelps: []
118
+ },
119
+ open: {
120
+ summary: "Open a url on every device",
121
+ usage: "everyscreen open --url <url> [--device <id>]",
122
+ flagHelps: [{
123
+ flag: "--url <url>",
124
+ description: "The page to open"
125
+ }, DEVICE_FLAG_HELP]
126
+ },
127
+ tap: {
128
+ summary: "Tap an element on every device",
129
+ usage: "everyscreen tap --role <role> --name <name> [--device <id>]",
130
+ flagHelps: [
131
+ {
132
+ flag: "--role <role>",
133
+ description: "Aria role: button, link, textbox, checkbox, tab..."
134
+ },
135
+ {
136
+ flag: "--name <name>",
137
+ description: "Accessible name of the element"
138
+ },
139
+ DEVICE_FLAG_HELP
140
+ ]
141
+ },
142
+ fill: {
143
+ summary: "Type into a field on every device",
144
+ usage: "everyscreen fill --role <role> --name <name> --with <text> [--device <id>]",
145
+ flagHelps: [
146
+ {
147
+ flag: "--role <role>",
148
+ description: "Aria role of the field: textbox, searchbox, combobox..."
149
+ },
150
+ {
151
+ flag: "--name <name>",
152
+ description: "Accessible name of the field"
153
+ },
154
+ {
155
+ flag: "--with <text>",
156
+ description: "What to type"
157
+ },
158
+ DEVICE_FLAG_HELP
159
+ ]
160
+ },
161
+ scroll: {
162
+ summary: "Scroll to the top or the bottom of the page",
163
+ usage: "everyscreen scroll --to top|bottom [--device <id>]",
164
+ flagHelps: [{
165
+ flag: "--to top|bottom",
166
+ description: "Where to scroll"
167
+ }, DEVICE_FLAG_HELP]
168
+ },
169
+ check: {
170
+ summary: "Run the six checks on the current page",
171
+ usage: "everyscreen check [--device <id>]",
172
+ flagHelps: [DEVICE_FLAG_HELP]
173
+ },
174
+ shot: {
175
+ summary: "Save a screenshot of every device",
176
+ usage: "everyscreen shot [--device <id>]",
177
+ flagHelps: [DEVICE_FLAG_HELP]
178
+ },
179
+ expect: {
180
+ summary: "Check that some text shows on every device",
181
+ usage: "everyscreen expect --text <text> [--device <id>]",
182
+ flagHelps: [{
183
+ flag: "--text <text>",
184
+ description: "The text that must show"
185
+ }, DEVICE_FLAG_HELP]
186
+ },
187
+ sweep: {
188
+ summary: "Visit every route of your app and save a shot on every device",
189
+ usage: "everyscreen sweep --url <url> [--devices <id,id,...>|all] [--max-routes <number>] [--before <flow.yaml>]",
190
+ flagHelps: [
191
+ {
192
+ flag: "--url <url>",
193
+ description: "The start page of your app"
194
+ },
195
+ {
196
+ flag: "--devices <id,id,...>|all",
197
+ description: "Which devices to sweep. all adds every catalog device. Default: the grid"
198
+ },
199
+ {
200
+ flag: "--max-routes <number>",
201
+ description: "Most routes to visit, up to 500. Default: 50"
202
+ },
203
+ {
204
+ flag: "--before <flow.yaml>",
205
+ description: "Run this flow first on each device, for example a login. Default: none"
206
+ }
207
+ ]
208
+ },
209
+ "devices list": {
210
+ summary: "List the devices in the grid",
211
+ usage: "everyscreen devices list",
212
+ flagHelps: []
213
+ },
214
+ "devices add": {
215
+ summary: "Add a custom device to everyscreen.yaml and start it on the running hub",
216
+ usage: "everyscreen devices add --name <name> --width <px> --height <px> [--pixel-ratio <number>] [--touch]",
217
+ flagHelps: [
218
+ {
219
+ flag: "--name <name>",
220
+ description: "Device name. Its id is made from it"
221
+ },
222
+ {
223
+ flag: "--width <px>",
224
+ description: "Viewport width"
225
+ },
226
+ {
227
+ flag: "--height <px>",
228
+ description: "Viewport height"
229
+ },
230
+ {
231
+ flag: "--pixel-ratio <number>",
232
+ description: "Pixel ratio, 1 to 4. Default: 1"
233
+ },
234
+ {
235
+ flag: "--touch",
236
+ description: "The device has a touch screen"
237
+ }
238
+ ]
239
+ },
240
+ "devices remove": {
241
+ summary: "Remove a custom device from everyscreen.yaml and stop it on the running hub",
242
+ usage: "everyscreen devices remove --device <id> [--yes]",
243
+ flagHelps: [{
244
+ flag: "--device <id>",
245
+ description: "The custom device to remove"
246
+ }, YES_FLAG_HELP]
247
+ },
248
+ run: {
249
+ summary: "Run YAML flows on the devices they name",
250
+ usage: "everyscreen run [--flows <glob>] [--parallel <number>]",
251
+ flagHelps: [{
252
+ flag: "--flows <glob>",
253
+ description: "Which flow files to run. Default: flows/*.yaml"
254
+ }, {
255
+ flag: "--parallel <number>",
256
+ description: "Flows to run at the same time. Default: 1"
257
+ }]
258
+ },
259
+ "skill install": {
260
+ summary: "Install the Everyscreen skill for a coding agent",
261
+ usage: "everyscreen skill install [--agent claude|codex|cursor] [--yes]",
262
+ flagHelps: [{
263
+ flag: "--agent <name>",
264
+ description: "Which agent reads it. Default: claude"
265
+ }, YES_FLAG_HELP]
266
+ }
267
+ };
268
+ // src/commands/commands-schema.ts
269
+ const ARIA_ROLES = [
270
+ "button",
271
+ "checkbox",
272
+ "combobox",
273
+ "dialog",
274
+ "heading",
275
+ "img",
276
+ "link",
277
+ "listitem",
278
+ "menuitem",
279
+ "option",
280
+ "radio",
281
+ "searchbox",
282
+ "slider",
283
+ "spinbutton",
284
+ "switch",
285
+ "tab",
286
+ "textbox"
287
+ ];
288
+ const DEFAULT_MAX_ROUTES = 50;
289
+ const MAX_SWEEP_ROUTES = 500;
290
+ const URL_MESSAGE = "needs --url <web address, like https://example.com>";
291
+ const ROLE_MESSAGE = `needs --role <one of ${ARIA_ROLES.join(", ")}>`;
292
+ const NAME_MESSAGE = "needs --name <text>";
293
+ const MAX_ROUTES_MINIMUM_MESSAGE = "--max-routes must be a whole number of 1 or more";
294
+ const DEVICES_MESSAGE = "--devices must be all or a list of device ids, like iphone-17,pixel-10";
295
+ const deviceIdsSchema = z.array(z.string()).nullable();
296
+ const openCommandSchema = z.object({
297
+ commandName: z.literal("open"),
298
+ url: z.url({ error: URL_MESSAGE }),
299
+ deviceIds: deviceIdsSchema
300
+ });
301
+ const tapCommandSchema = z.object({
302
+ commandName: z.literal("tap"),
303
+ role: z.enum(ARIA_ROLES, { error: ROLE_MESSAGE }),
304
+ name: z.string({ error: NAME_MESSAGE }),
305
+ deviceIds: deviceIdsSchema
306
+ });
307
+ const fillCommandSchema = z.object({
308
+ commandName: z.literal("fill"),
309
+ role: z.enum(ARIA_ROLES, { error: ROLE_MESSAGE }),
310
+ name: z.string({ error: NAME_MESSAGE }),
311
+ with: z.string({ error: "needs --with <text>" }),
312
+ deviceIds: deviceIdsSchema
313
+ });
314
+ const scrollCommandSchema = z.object({
315
+ commandName: z.literal("scroll"),
316
+ to: z.enum(["top", "bottom"], { error: "needs --to top or bottom" }),
317
+ deviceIds: deviceIdsSchema
318
+ });
319
+ const checkCommandSchema = z.object({
320
+ commandName: z.literal("check"),
321
+ deviceIds: deviceIdsSchema
322
+ });
323
+ const shotCommandSchema = z.object({
324
+ commandName: z.literal("shot"),
325
+ deviceIds: deviceIdsSchema
326
+ });
327
+ const expectCommandSchema = z.object({
328
+ commandName: z.literal("expect"),
329
+ text: z.string({ error: "needs --text <text>" }),
330
+ deviceIds: deviceIdsSchema
331
+ });
332
+ const beforeStepSchema = z.discriminatedUnion("commandName", [
333
+ openCommandSchema,
334
+ tapCommandSchema,
335
+ fillCommandSchema,
336
+ scrollCommandSchema,
337
+ checkCommandSchema,
338
+ shotCommandSchema,
339
+ expectCommandSchema
340
+ ]);
341
+ const sweepCommandSchema = z.object({
342
+ commandName: z.literal("sweep"),
343
+ url: z.url({ error: URL_MESSAGE }),
344
+ deviceIds: z.union([z.literal("all"), z.array(z.string().min(1, { error: DEVICES_MESSAGE })).min(1, { error: DEVICES_MESSAGE })], { error: DEVICES_MESSAGE }).nullable().default(null),
345
+ maxRoutes: z.number({ error: MAX_ROUTES_MINIMUM_MESSAGE }).int({ error: MAX_ROUTES_MINIMUM_MESSAGE }).positive({ error: MAX_ROUTES_MINIMUM_MESSAGE }).max(MAX_SWEEP_ROUTES, { error: `--max-routes must be ${MAX_SWEEP_ROUTES} or less` }).default(DEFAULT_MAX_ROUTES),
346
+ beforeSteps: z.array(beforeStepSchema).nullable().default(null)
347
+ });
348
+ const hubCommandSchema = z.discriminatedUnion("commandName", [
349
+ openCommandSchema,
350
+ sweepCommandSchema,
351
+ tapCommandSchema,
352
+ fillCommandSchema,
353
+ scrollCommandSchema,
354
+ checkCommandSchema,
355
+ shotCommandSchema,
356
+ expectCommandSchema
357
+ ]);
358
+ // src/skill/skill-agents.ts
359
+ const SKILL_AGENTS = [
360
+ "claude",
361
+ "codex",
362
+ "cursor"
363
+ ];
364
+ // src/cli/cli-logic.ts
365
+ const DEFAULT_FLOWS_GLOB = "flows/*.yaml";
366
+ const DEFAULT_PARALLEL = 1;
367
+ const MAX_PORT = 65535;
368
+ const COLUMN_GAP = " ";
369
+ const PRODUCT_LINE = "Run your web app on many device sizes at once.";
370
+ const HUB_DOWN_LINE = "hub is not running · run npx everyscreen start";
371
+ const PORT_MESSAGE = `--port must be a whole number from 0 to ${MAX_PORT}`;
372
+ const FLOWS_MESSAGE = "--flows must be a file pattern, like flows/*.yaml";
373
+ const PARALLEL_MESSAGE = "--parallel must be a whole number of 1 or more";
374
+ const BEFORE_MESSAGE = "--before needs a flow file path";
375
+ const startFlagsSchema = z.object({
376
+ port: z.number({ error: PORT_MESSAGE }).int({ error: PORT_MESSAGE }).min(0, { error: PORT_MESSAGE }).max(MAX_PORT, { error: PORT_MESSAGE }).optional(),
377
+ foreground: z.boolean().default(false)
378
+ });
379
+ const runFlagsSchema = z.object({
380
+ flows: z.string({ error: FLOWS_MESSAGE }).min(1, { error: FLOWS_MESSAGE }).default(DEFAULT_FLOWS_GLOB),
381
+ parallel: z.number({ error: PARALLEL_MESSAGE }).int({ error: PARALLEL_MESSAGE }).min(1, { error: PARALLEL_MESSAGE }).default(DEFAULT_PARALLEL)
382
+ });
383
+ const sweepFlagsSchema = z.object({
384
+ url: sweepCommandSchema.shape.url,
385
+ devices: sweepCommandSchema.shape.deviceIds,
386
+ "max-routes": sweepCommandSchema.shape.maxRoutes,
387
+ before: z.string({ error: BEFORE_MESSAGE }).min(1, { error: BEFORE_MESSAGE }).nullable().default(null)
388
+ });
389
+ const skillFlagsSchema = z.object({ agent: z.enum(SKILL_AGENTS, { error: `--agent must be one of ${SKILL_AGENTS.join(", ")}` }).default("claude") });
390
+ const removeDeviceFlagsSchema = z.object({ device: z.string({ error: "needs --device <device id>" }) });
391
+ const COMMON_FLAG_HELPS = [{
392
+ flag: "--json",
393
+ description: "Print one JSON object"
394
+ }, {
395
+ flag: "--help",
396
+ description: "Show this help"
397
+ }];
398
+ const createReply = (status, line) => ({
399
+ status,
400
+ line,
401
+ runId: null,
402
+ failures: []
403
+ });
404
+ const createHubDownReply = () => createReply("failed", HUB_DOWN_LINE);
405
+ const createReadyLine = (hubUrl, deviceCount) => `hub ready at ${hubUrl} · ${deviceCount} devices`;
406
+ const createRunningLine = (hubUrl, deviceCount) => `hub running at ${hubUrl} · ${deviceCount} devices`;
407
+ const createDeviceListLine = (deviceIds) => `${deviceIds.length} devices · ${deviceIds.join(", ")}`;
408
+ const createBadUsageLine = (detail) => `bad usage · ${detail}`;
409
+ const pickBadUsageDetail = (error) => {
410
+ return error instanceof HTTPException && error.status === 400 ? error.message : null;
411
+ };
412
+ const isPortConflict = (requestedPort, runningPort) => requestedPort !== null && requestedPort !== runningPort;
413
+ const createPortConflictReply = (hubUrl) => createReply("failed", `hub already running at ${hubUrl} · stop it first`);
414
+ const canWriteFinalReply = (cliRequest, isJsonWanted, isTerminal) => {
415
+ return !(cliRequest.kind === "start" && cliRequest.isForeground && (isJsonWanted || !isTerminal));
416
+ };
417
+ const createPrintedReply = (commandReply, isJsonWanted, isTerminal) => {
418
+ if (isJsonWanted || !isTerminal) return JSON.stringify(commandReply);
419
+ const failureLines = commandReply.failures.map(({ deviceId, message, durationMs }) => `${deviceId} failed ${message} ${durationMs} ms`);
420
+ return [commandReply.line, ...failureLines].join("\n");
421
+ };
422
+ const createHelpLines = (helpLines) => {
423
+ const flagLengths = helpLines.map(({ flag }) => flag.length);
424
+ const widestName = Math.max(...flagLengths);
425
+ return helpLines.map(({ flag, description }) => ` ${flag.padEnd(widestName)}${COLUMN_GAP}${description}`);
426
+ };
427
+ const createHelpPage = (cliCommandName) => {
428
+ if (cliCommandName === null) {
429
+ const commandHelps = CLI_COMMAND_NAMES.map((commandName) => ({
430
+ flag: commandName,
431
+ description: COMMAND_HELPS[commandName].summary
432
+ }));
433
+ const commandLines = createHelpLines(commandHelps);
434
+ return [
435
+ `everyscreen · ${PRODUCT_LINE}`,
436
+ "",
437
+ "Usage: everyscreen <command> [flags]",
438
+ "",
439
+ "Commands:",
440
+ ...commandLines,
441
+ "",
442
+ "Every command takes --json (print one JSON object) and --help.",
443
+ "Run everyscreen <command> --help for the flags of one command."
444
+ ].join("\n");
445
+ }
446
+ const commandHelp = COMMAND_HELPS[cliCommandName];
447
+ const flagLines = createHelpLines([...commandHelp.flagHelps, ...COMMON_FLAG_HELPS]);
448
+ return [
449
+ `everyscreen ${cliCommandName} · ${commandHelp.summary}`,
450
+ "",
451
+ `Usage: ${commandHelp.usage}`,
452
+ "",
453
+ "Flags:",
454
+ ...flagLines
455
+ ].join("\n");
456
+ };
457
+ const pickCommandName = (commandWords) => {
458
+ const joinedWords = commandWords.join(" ");
459
+ return CLI_COMMAND_NAMES.find((commandName) => commandName === joinedWords) ?? null;
460
+ };
461
+ const readNumberFlag = (flagWord) => {
462
+ if (flagWord === void 0) return void 0;
463
+ if (flagWord === "") return NaN;
464
+ return Number(flagWord);
465
+ };
466
+ const readDevicesFlag = (flagWord) => {
467
+ if (flagWord === void 0) return void 0;
468
+ if (flagWord === "all") return "all";
469
+ return flagWord.split(",").map((deviceId) => deviceId.trim());
470
+ };
471
+ const createBadUsage = (commandName, issue) => ({
472
+ kind: "bad-usage",
473
+ message: createBadUsageLine(`${commandName} ${issue.message}`)
474
+ });
475
+ const createReadyCheck = (cliRequest) => ({
476
+ kind: "ready",
477
+ cliRequest
478
+ });
479
+ const checkHubCommandFlags = (commandName, flags) => {
480
+ const isDeviceMissing = flags.device === void 0;
481
+ const hubCommandCandidate = {
482
+ commandName,
483
+ url: flags.url,
484
+ role: flags.role,
485
+ name: flags.name,
486
+ with: flags.with,
487
+ to: flags.to,
488
+ text: flags.text,
489
+ deviceIds: isDeviceMissing ? null : [flags.device]
490
+ };
491
+ const hubCommandCheck = hubCommandSchema.safeParse(hubCommandCandidate);
492
+ if (hubCommandCheck.success) return createReadyCheck({
493
+ kind: "send-command",
494
+ hubCommand: hubCommandCheck.data
495
+ });
496
+ return createBadUsage(commandName, hubCommandCheck.error.issues[0]);
497
+ };
498
+ const checkStartFlags = (flags) => {
499
+ const startCheck = startFlagsSchema.safeParse({
500
+ port: readNumberFlag(flags.port),
501
+ foreground: flags.foreground
502
+ });
503
+ if (startCheck.success) return createReadyCheck({
504
+ kind: "start",
505
+ port: startCheck.data.port ?? null,
506
+ isForeground: startCheck.data.foreground
507
+ });
508
+ return createBadUsage("start", startCheck.error.issues[0]);
509
+ };
510
+ const checkAddDeviceFlags = (flags) => {
511
+ const customDeviceCheck = customDeviceSchema.safeParse({
512
+ name: flags.name,
513
+ width: readNumberFlag(flags.width),
514
+ height: readNumberFlag(flags.height),
515
+ pixelRatio: readNumberFlag(flags["pixel-ratio"]),
516
+ hasTouch: flags.touch
517
+ });
518
+ if (customDeviceCheck.success) return createReadyCheck({
519
+ kind: "add-device",
520
+ customDevice: customDeviceCheck.data
521
+ });
522
+ return createBadUsage("devices add", customDeviceCheck.error.issues[0]);
523
+ };
524
+ const checkRemoveDeviceFlags = (flags, isConfirmed) => {
525
+ const removeCheck = removeDeviceFlagsSchema.safeParse(flags);
526
+ if (!removeCheck.success) return createBadUsage("devices remove", removeCheck.error.issues[0]);
527
+ if (!isConfirmed) return {
528
+ kind: "bad-usage",
529
+ message: createBadUsageLine("devices remove needs --yes when it does not run in a terminal")
530
+ };
531
+ return createReadyCheck({
532
+ kind: "remove-device",
533
+ deviceId: removeCheck.data.device
534
+ });
535
+ };
536
+ const checkRunFlowsFlags = (flags) => {
537
+ const runCheck = runFlagsSchema.safeParse({
538
+ flows: flags.flows,
539
+ parallel: readNumberFlag(flags.parallel)
540
+ });
541
+ if (runCheck.success) return createReadyCheck({
542
+ kind: "run-flows",
543
+ flowsGlob: runCheck.data.flows,
544
+ parallel: runCheck.data.parallel
545
+ });
546
+ return createBadUsage("run", runCheck.error.issues[0]);
547
+ };
548
+ const checkSweepFlags = (flags) => {
549
+ const sweepCheck = sweepFlagsSchema.safeParse({
550
+ url: flags.url,
551
+ devices: readDevicesFlag(flags.devices),
552
+ "max-routes": readNumberFlag(flags["max-routes"]),
553
+ before: flags.before
554
+ });
555
+ if (sweepCheck.success) {
556
+ const sweepFlags = sweepCheck.data;
557
+ return createReadyCheck({
558
+ kind: "run-sweep",
559
+ sweepCommand: {
560
+ commandName: "sweep",
561
+ url: sweepFlags.url,
562
+ deviceIds: sweepFlags.devices,
563
+ maxRoutes: sweepFlags["max-routes"],
564
+ beforeSteps: null
565
+ },
566
+ beforeFlowPath: sweepFlags.before
567
+ });
568
+ }
569
+ return createBadUsage("sweep", sweepCheck.error.issues[0]);
570
+ };
571
+ const checkInstallSkillFlags = (flags, isConfirmed) => {
572
+ const skillCheck = skillFlagsSchema.safeParse(flags);
573
+ if (skillCheck.success) return createReadyCheck({
574
+ kind: "install-skill",
575
+ agent: skillCheck.data.agent,
576
+ isConfirmed
577
+ });
578
+ return createBadUsage("skill install", skillCheck.error.issues[0]);
579
+ };
580
+ const checkRequestFlags = (commandName, flags, isConfirmed) => {
581
+ switch (commandName) {
582
+ case "start": return checkStartFlags(flags);
583
+ case "stop": return createReadyCheck({ kind: "stop" });
584
+ case "status": return createReadyCheck({ kind: "status" });
585
+ case "devices list": return createReadyCheck({ kind: "list-devices" });
586
+ case "devices add": return checkAddDeviceFlags(flags);
587
+ case "devices remove": return checkRemoveDeviceFlags(flags, isConfirmed);
588
+ case "run": return checkRunFlowsFlags(flags);
589
+ case "sweep": return checkSweepFlags(flags);
590
+ case "skill install": return checkInstallSkillFlags(flags, isConfirmed);
591
+ case "open":
592
+ case "tap":
593
+ case "fill":
594
+ case "scroll":
595
+ case "check":
596
+ case "shot":
597
+ case "expect": return checkHubCommandFlags(commandName, flags);
598
+ }
599
+ };
600
+ const planCli = (commandWords, flags, isTerminal) => {
601
+ const commandName = pickCommandName(commandWords);
602
+ const isHelpWanted = flags.help === true;
603
+ const isNothingTyped = commandWords.length === 0;
604
+ const isCommandUnknown = commandName === null;
605
+ if (isNothingTyped || isCommandUnknown && isHelpWanted) return {
606
+ kind: "show-help",
607
+ helpPage: createHelpPage(null)
608
+ };
609
+ if (isCommandUnknown) return {
610
+ kind: "bad-usage",
611
+ message: `bad usage · unknown command "${commandWords.join(" ")}" · run everyscreen --help`
612
+ };
613
+ if (isHelpWanted) return {
614
+ kind: "show-help",
615
+ helpPage: createHelpPage(commandName)
616
+ };
617
+ const isConfirmed = flags.yes === true || isTerminal;
618
+ const requestCheck = checkRequestFlags(commandName, flags, isConfirmed);
619
+ if (requestCheck.kind === "bad-usage") return requestCheck;
620
+ return {
621
+ kind: "run",
622
+ cliRequest: requestCheck.cliRequest
623
+ };
624
+ };
625
+ // src/cli/cli-reply.ts
626
+ const writeReply = (commandReply, isJsonWanted) => {
627
+ const isTerminal = process.stdout.isTTY === true;
628
+ const printedReply = createPrintedReply(commandReply, isJsonWanted, isTerminal);
629
+ process.stdout.write(`${printedReply}\n`);
630
+ };
631
+ // src/cli/hub-client.ts
632
+ const ANSWER_TIMEOUT_MS = 3e3;
633
+ const LOST_CONNECTION_CODES = ["ECONNREFUSED", "ECONNRESET"];
634
+ const problemSchema = z.object({ detail: z.string() });
635
+ const createHubClient = (hubUrl) => hc(hubUrl);
636
+ const createHubUrl = (hubFile) => `http://${HUB_HOST}:${hubFile.port}`;
637
+ const countDevices = (hubFile) => Object.keys(hubFile.devices).length;
638
+ const readHubFile = async (rootFolder) => {
639
+ const hubFilePath = join(rootFolder, HUB_FILE_PATH);
640
+ const serializedHubFile = await readStoredFile(hubFilePath);
641
+ if (serializedHubFile === null) return null;
642
+ const hubFileDocument = JSON.parse(serializedHubFile);
643
+ return hubFileSchema.parse(hubFileDocument);
644
+ };
645
+ const isConnectionLost = (error) => {
646
+ if (!(error instanceof TypeError)) return false;
647
+ const lostCause = error.cause;
648
+ if (!(lostCause instanceof Error && "code" in lostCause)) return false;
649
+ const lostCode = String(lostCause.code);
650
+ return LOST_CONNECTION_CODES.includes(lostCode);
651
+ };
652
+ const isAnswerTimeout = (error) => error instanceof DOMException && error.name === "TimeoutError";
653
+ const readProblemReply = async (problemReply) => {
654
+ const problemDocument = await problemReply.json();
655
+ const problem = problemSchema.parse(problemDocument);
656
+ if (problemReply.status === 400) throw new HTTPException(400, { message: problem.detail });
657
+ return createReply("failed", problem.detail);
658
+ };
659
+ const isHubAnswering = async (hubFile) => {
660
+ const hubUrl = createHubUrl(hubFile);
661
+ const hubClient = createHubClient(hubUrl);
662
+ try {
663
+ await hubClient.api.devices.$get({}, { init: { signal: AbortSignal.timeout(ANSWER_TIMEOUT_MS) } });
664
+ return true;
665
+ } catch (error) {
666
+ if (isConnectionLost(error) || isAnswerTimeout(error)) return false;
667
+ throw error;
668
+ }
669
+ };
670
+ const callHub = async (rootFolder, askHubClient) => {
671
+ const hubFile = await readHubFile(rootFolder);
672
+ if (hubFile === null) return createHubDownReply();
673
+ const hubUrl = createHubUrl(hubFile);
674
+ const hubClient = createHubClient(hubUrl);
675
+ try {
676
+ return await askHubClient(hubClient, hubUrl);
677
+ } catch (error) {
678
+ if (isConnectionLost(error)) return createHubDownReply();
679
+ throw error;
680
+ }
681
+ };
682
+ const sendHubCommand = (rootFolder, hubCommand) => callHub(rootFolder, async (hubClient) => {
683
+ const commandsReply = await hubClient.api.commands.$post({ json: hubCommand });
684
+ if (!commandsReply.ok) return readProblemReply(commandsReply);
685
+ return commandsReply.json();
686
+ });
687
+ // src/live/screen-priority-logic.ts
688
+ const pickScreenPhase = (testStatuses) => {
689
+ return testStatuses.includes("running") ? "following-hub" : "keeping-test-screen";
690
+ };
691
+ const isScreenAccepted = (screenSource, screenPhase) => {
692
+ return screenSource === "test-run" || screenPhase === "following-hub";
693
+ };
694
+ // src/live/live-feed.ts
695
+ const listeners = /* @__PURE__ */ new Set();
696
+ const lastScreens = /* @__PURE__ */ new Map();
697
+ const lastTests = /* @__PURE__ */ new Map();
698
+ const lastSteps = /* @__PURE__ */ new Map();
699
+ const screenPhases = /* @__PURE__ */ new Map();
700
+ let recentCommands = [];
701
+ const lastRunTotals = { runTotals: null };
702
+ const subscribe = (listener) => {
703
+ listeners.add(listener);
704
+ for (const lastScreen of lastScreens.values()) listener(lastScreen);
705
+ const { runTotals } = lastRunTotals;
706
+ if (runTotals !== null) listener({
707
+ kind: "run-totals",
708
+ ...runTotals
709
+ });
710
+ for (const [testId, testEvent] of lastTests) {
711
+ listener({
712
+ kind: "test",
713
+ ...testEvent
714
+ });
715
+ const testSteps = lastSteps.get(testId) ?? [];
716
+ for (const stepEvent of testSteps) listener({
717
+ kind: "step",
718
+ ...stepEvent
719
+ });
720
+ }
721
+ for (const recentCommand of recentCommands) listener(recentCommand);
722
+ return () => listeners.delete(listener);
723
+ };
724
+ const rememberTest = (testEvent) => {
725
+ if (testEvent.status === "running") lastSteps.delete(testEvent.testId);
726
+ lastTests.delete(testEvent.testId);
727
+ lastTests.set(testEvent.testId, testEvent);
728
+ };
729
+ const rememberStep = (stepEvent) => {
730
+ const otherSteps = (lastSteps.get(stepEvent.testId) ?? []).filter(({ stepIndex }) => stepIndex !== stepEvent.stepIndex);
731
+ lastSteps.set(stepEvent.testId, [...otherSteps, stepEvent]);
732
+ };
733
+ const rememberScreenPhase = (deviceId) => {
734
+ const deviceStatuses = [...lastTests.values()].filter((testEvent) => testEvent.deviceId === deviceId).map(({ status }) => status);
735
+ screenPhases.set(deviceId, pickScreenPhase(deviceStatuses));
736
+ };
737
+ const forgetAllTests = () => {
738
+ lastTests.clear();
739
+ lastSteps.clear();
740
+ };
741
+ const broadcast = (message, screenSource = "hub") => {
742
+ switch (message.kind) {
743
+ case "screen": {
744
+ const screenPhase = screenPhases.get(message.deviceId) ?? "following-hub";
745
+ if (!isScreenAccepted(screenSource, screenPhase)) return;
746
+ lastScreens.set(message.deviceId, message);
747
+ break;
748
+ }
749
+ case "test":
750
+ rememberTest(message);
751
+ rememberScreenPhase(message.deviceId);
752
+ break;
753
+ case "step":
754
+ rememberStep(message);
755
+ break;
756
+ case "run-totals":
757
+ if (message.phase === "start") {
758
+ forgetAllTests();
759
+ screenPhases.clear();
760
+ }
761
+ lastRunTotals.runTotals = message;
762
+ break;
763
+ case "command": recentCommands = [...recentCommands, message].slice(-8);
764
+ }
765
+ for (const listener of listeners) listener(message);
766
+ };
767
+ const forgetDeviceScreen = (deviceId) => {
768
+ lastScreens.delete(deviceId);
769
+ screenPhases.delete(deviceId);
770
+ };
771
+ const releaseDeviceScreen = (deviceId) => {
772
+ screenPhases.delete(deviceId);
773
+ };
774
+ const listRunningTests = () => [...lastTests.values()].filter(({ status }) => status === "running");
775
+ const readRunTotals = () => lastRunTotals.runTotals;
776
+ const forgetTests = (deviceIds) => {
777
+ const forgottenTestIds = [...lastTests.values()].filter(({ deviceId }) => deviceIds.includes(deviceId)).map(({ testId }) => testId);
778
+ for (const testId of forgottenTestIds) {
779
+ lastTests.delete(testId);
780
+ lastSteps.delete(testId);
781
+ }
782
+ lastRunTotals.runTotals = null;
783
+ };
784
+ // src/checks/page-watch.ts
785
+ const HTTP_ERROR_STATUS = 400;
786
+ const watchPage = async (page, deviceId) => {
787
+ const sentFindingKeys = /* @__PURE__ */ new Set();
788
+ const sendFinding = (finding) => {
789
+ const findingKey = `${finding.kind}|${finding.message}`;
790
+ if (sentFindingKeys.has(findingKey)) return;
791
+ sentFindingKeys.add(findingKey);
792
+ broadcast({
793
+ kind: "finding",
794
+ finding
795
+ });
796
+ };
797
+ const onConsole = (consoleMessage) => {
798
+ const consoleLine = consoleMessage.text();
799
+ if (!(consoleMessage.type() === "error" && !isRequestFailureEcho(consoleLine))) return;
800
+ sendFinding(createConsoleFinding(deviceId, consoleLine));
801
+ };
802
+ const onPageError = (error) => {
803
+ sendFinding(createConsoleFinding(deviceId, error.message));
804
+ };
805
+ const onRequestFailed = (request) => {
806
+ const failureReason = request.failure()?.errorText ?? null;
807
+ sendFinding(createRequestFinding(deviceId, request.url(), failureReason));
808
+ };
809
+ const onResponse = (httpResponse) => {
810
+ const isHttpError = httpResponse.status() >= HTTP_ERROR_STATUS;
811
+ const isFavicon = isFaviconUrl(httpResponse.url());
812
+ if (!isHttpError || isFavicon) return;
813
+ const failureReason = String(httpResponse.status());
814
+ const requestUrl = httpResponse.url();
815
+ sendFinding(createRequestFinding(deviceId, requestUrl, failureReason));
816
+ };
817
+ const onNavigated = (frame) => {
818
+ if (frame === page.mainFrame()) sentFindingKeys.clear();
819
+ };
820
+ page.on("console", onConsole);
821
+ page.on("pageerror", onPageError);
822
+ page.on("requestfailed", onRequestFailed);
823
+ page.on("response", onResponse);
824
+ page.on("framenavigated", onNavigated);
825
+ return async () => {
826
+ page.off("console", onConsole);
827
+ page.off("pageerror", onPageError);
828
+ page.off("requestfailed", onRequestFailed);
829
+ page.off("response", onResponse);
830
+ page.off("framenavigated", onNavigated);
831
+ };
832
+ };
833
+ // src/devices/device-emulation.ts
834
+ const emulateDevice = async (page, devicePreset) => {
835
+ const { deviceScaleFactor, isMobile, hasTouch, userAgent } = devicePreset.use;
836
+ const viewport = pickDeviceViewport(devicePreset);
837
+ const screen = pickDeviceScreen(devicePreset);
838
+ const { bottom, left, right } = devicePreset.safeAreaInsets;
839
+ const wholeInsets = {
840
+ top: 0,
841
+ bottom: Math.round(bottom),
842
+ left: Math.round(left),
843
+ right: Math.round(right)
844
+ };
845
+ const cdpSession = await page.context().newCDPSession(page);
846
+ await cdpSession.send("Emulation.setDeviceMetricsOverride", {
847
+ width: viewport.width,
848
+ height: viewport.height,
849
+ screenWidth: screen.width,
850
+ screenHeight: screen.height,
851
+ deviceScaleFactor: deviceScaleFactor ?? 1,
852
+ mobile: isMobile ?? false
853
+ });
854
+ await cdpSession.send("Emulation.setTouchEmulationEnabled", { enabled: hasTouch ?? false });
855
+ await cdpSession.send("Emulation.setSafeAreaInsetsOverride", { insets: wholeInsets });
856
+ if (userAgent !== void 0) await cdpSession.send("Emulation.setUserAgentOverride", { userAgent });
857
+ };
858
+ // src/estimates/estimates-logic.ts
859
+ const FULL_CPU_PERCENT = 100;
860
+ const PERCENT_SCALE = 100;
861
+ const BYTES_PER_MEGABYTE = 1e6;
862
+ const MILLISECONDS_PER_SECOND = 1e3;
863
+ const createIdleDeviceLoad = () => ({
864
+ label: "est.",
865
+ fps: 0,
866
+ cpuPercent: 0,
867
+ memoryMegabytes: 0,
868
+ layoutShift: 0
869
+ });
870
+ const estimateDeviceLoad = (samples) => {
871
+ const firstSample = samples.at(0);
872
+ const lastSample = samples.at(-1);
873
+ if (firstSample === void 0 || lastSample === void 0 || firstSample === lastSample) return createIdleDeviceLoad();
874
+ const wallSeconds = (lastSample.takenAt - firstSample.takenAt) / MILLISECONDS_PER_SECOND;
875
+ if (wallSeconds <= 0) return createIdleDeviceLoad();
876
+ const fps = (lastSample.frames - firstSample.frames) / wallSeconds;
877
+ const busyPercent = (lastSample.taskSeconds - firstSample.taskSeconds) / wallSeconds * PERCENT_SCALE;
878
+ return {
879
+ label: "est.",
880
+ fps,
881
+ cpuPercent: Math.min(FULL_CPU_PERCENT, busyPercent),
882
+ memoryMegabytes: lastSample.heapBytes / BYTES_PER_MEGABYTE,
883
+ layoutShift: lastSample.layoutShift
884
+ };
885
+ };
886
+ const pickSampleWindow = (recentSamples, newSample, windowSize) => {
887
+ const lastSample = recentSamples.at(-1);
888
+ if (lastSample !== void 0 && newSample.frames < lastSample.frames) return [newSample];
889
+ return [...recentSamples.slice(-(windowSize - 1)), newSample];
890
+ };
891
+ // src/estimates/estimates-sampler.ts
892
+ const SAMPLE_INTERVAL_MS = 1e3;
893
+ const SAMPLE_WINDOW = 5;
894
+ const COUNTERS_KEY = "everyscreenCounters";
895
+ const pageCountersSchema = z.object({
896
+ frames: z.number(),
897
+ layoutShift: z.number()
898
+ });
899
+ const counterReadingSchema = z.array(z.object({
900
+ name: z.string(),
901
+ value: z.number()
902
+ }));
903
+ const watchFrames = (countersKey) => {
904
+ const isLayoutShift = (performanceEntry) => performanceEntry.entryType === "layout-shift";
905
+ if (countersKey in window) return;
906
+ const counters = {
907
+ frames: 0,
908
+ layoutShift: 0
909
+ };
910
+ const countFrame = () => {
911
+ counters.frames += 1;
912
+ requestAnimationFrame(countFrame);
913
+ };
914
+ requestAnimationFrame(countFrame);
915
+ new PerformanceObserver((entryList) => {
916
+ for (const layoutShiftEntry of entryList.getEntries().filter(isLayoutShift)) if (!layoutShiftEntry.hadRecentInput) counters.layoutShift += layoutShiftEntry.value;
917
+ }).observe({
918
+ type: "layout-shift",
919
+ buffered: true
920
+ });
921
+ Object.assign(window, { [countersKey]: counters });
922
+ };
923
+ const findCounterReading = (counterReadings, counterName) => {
924
+ const counterReading = counterReadings.find((candidate) => candidate.name === counterName);
925
+ if (counterReading === void 0) throw new Error(`Chromium did not report the ${counterName} counter`);
926
+ return counterReading.value;
927
+ };
928
+ const readSample = async (cdpSession, page) => {
929
+ const performanceReply = await cdpSession.send("Performance.getMetrics");
930
+ const counterReadings = counterReadingSchema.parse(performanceReply.metrics);
931
+ const pageCountersReply = await page.evaluate((countersKey) => Reflect.get(window, countersKey), COUNTERS_KEY);
932
+ const pageCounters = pageCountersSchema.parse(pageCountersReply);
933
+ return {
934
+ takenAt: Date.now(),
935
+ taskSeconds: findCounterReading(counterReadings, "TaskDuration"),
936
+ layoutSeconds: findCounterReading(counterReadings, "LayoutDuration"),
937
+ heapBytes: findCounterReading(counterReadings, "JSHeapUsedSize"),
938
+ frames: pageCounters.frames,
939
+ layoutShift: pageCounters.layoutShift
940
+ };
941
+ };
942
+ const waitForNextSample = async (signal) => {
943
+ try {
944
+ await setTimeout$1(SAMPLE_INTERVAL_MS, null, { signal });
945
+ } catch (error) {
946
+ if (signal.aborted) return;
947
+ throw error;
948
+ }
949
+ };
950
+ const sampleUntilStopped = async (cdpSession, page, deviceId, signal) => {
951
+ const sampleWindow = { samples: [] };
952
+ while (!signal.aborted) {
953
+ await waitForNextSample(signal);
954
+ if (signal.aborted) break;
955
+ try {
956
+ const sample = await readSample(cdpSession, page);
957
+ sampleWindow.samples = pickSampleWindow(sampleWindow.samples, sample, SAMPLE_WINDOW);
958
+ const deviceLoad = estimateDeviceLoad(sampleWindow.samples);
959
+ broadcast({
960
+ kind: "estimate",
961
+ deviceId,
962
+ deviceLoad
963
+ });
964
+ } catch (error) {
965
+ if (!(page.isClosed() || signal.aborted)) console.error({
966
+ deviceId,
967
+ error
968
+ });
969
+ }
970
+ }
971
+ };
972
+ const startSampling = async (page, deviceId) => {
973
+ const cdpSession = await page.context().newCDPSession(page);
974
+ await cdpSession.send("Performance.enable");
975
+ await page.addInitScript(watchFrames, COUNTERS_KEY);
976
+ await page.evaluate(watchFrames, COUNTERS_KEY);
977
+ const abortController = new AbortController();
978
+ const samplingLoop = sampleUntilStopped(cdpSession, page, deviceId, abortController.signal);
979
+ const stopOnPageClose = () => abortController.abort();
980
+ page.on("close", stopOnPageClose);
981
+ return async () => {
982
+ abortController.abort();
983
+ page.off("close", stopOnPageClose);
984
+ await samplingLoop;
985
+ if (!page.isClosed()) {
986
+ await cdpSession.send("Performance.disable");
987
+ await cdpSession.detach();
988
+ }
989
+ };
990
+ };
991
+ // src/live/zoom-logic.ts
992
+ const UNKNOWN_ZOOM_DEVICE_STATUS = 422;
993
+ const pickZoomChange = (previousDeviceId, nextDeviceId, knownDeviceIds) => {
994
+ if (nextDeviceId !== null && !knownDeviceIds.includes(nextDeviceId)) throw new HTTPException(UNKNOWN_ZOOM_DEVICE_STATUS, { message: `zoom: no live device with id ${nextDeviceId}` });
995
+ if (previousDeviceId === nextDeviceId) return {
996
+ restoreDeviceId: null,
997
+ zoomDeviceId: null
998
+ };
999
+ return {
1000
+ restoreDeviceId: previousDeviceId,
1001
+ zoomDeviceId: nextDeviceId
1002
+ };
1003
+ };
1004
+ const pickZoomOwnerId = (nextDeviceId, clientId) => {
1005
+ if (nextDeviceId === null) return null;
1006
+ return clientId;
1007
+ };
1008
+ // src/live/zoom-store.ts
1009
+ const ZOOM_QUEUE_KEY = "zoom";
1010
+ const zoomControls = /* @__PURE__ */ new Map();
1011
+ let zoomedDeviceId = null;
1012
+ let zoomOwnerId = null;
1013
+ const registerZoomControl = (deviceId, setZoomed) => {
1014
+ zoomControls.set(deviceId, setZoomed);
1015
+ return () => {
1016
+ zoomControls.delete(deviceId);
1017
+ if (zoomedDeviceId === deviceId) {
1018
+ zoomedDeviceId = null;
1019
+ zoomOwnerId = null;
1020
+ }
1021
+ };
1022
+ };
1023
+ const applyZoomedDevice = async (nextDeviceId, clientId) => {
1024
+ const knownDeviceIds = [...zoomControls.keys()];
1025
+ const { restoreDeviceId, zoomDeviceId } = pickZoomChange(zoomedDeviceId, nextDeviceId, knownDeviceIds);
1026
+ const hasDeviceToRestore = restoreDeviceId !== null;
1027
+ const hasDeviceToZoom = zoomDeviceId !== null;
1028
+ if (hasDeviceToRestore) await zoomControls.get(restoreDeviceId)?.(false);
1029
+ if (hasDeviceToZoom) await zoomControls.get(zoomDeviceId)?.(true);
1030
+ zoomedDeviceId = nextDeviceId;
1031
+ zoomOwnerId = pickZoomOwnerId(nextDeviceId, clientId);
1032
+ };
1033
+ const changeZoomedDevice = (nextDeviceId, clientId) => runOneAtATime(ZOOM_QUEUE_KEY, () => applyZoomedDevice(nextDeviceId, clientId));
1034
+ const releaseClientZoom = (clientId) => runOneAtATime(ZOOM_QUEUE_KEY, async () => {
1035
+ if (!(zoomOwnerId === clientId)) return;
1036
+ await applyZoomedDevice(null, clientId);
1037
+ });
1038
+ // src/live/screencast.ts
1039
+ const PAINT_TIMEOUT_MS = 3e3;
1040
+ const waitForPaintedPage = async (page) => {
1041
+ try {
1042
+ await page.waitForFunction(() => {
1043
+ const { body } = document;
1044
+ if (body === null) return false;
1045
+ const hasText = body.innerText.trim().length > 0;
1046
+ const hasMedia = body.querySelector("img, canvas, svg, video") !== null;
1047
+ return hasText || hasMedia;
1048
+ }, void 0, { timeout: PAINT_TIMEOUT_MS });
1049
+ } catch (error) {
1050
+ if (!(error instanceof errors.TimeoutError)) throw error;
1051
+ }
1052
+ await page.evaluate(() => new Promise((onPainted) => {
1053
+ requestAnimationFrame(() => requestAnimationFrame(() => onPainted()));
1054
+ }));
1055
+ };
1056
+ const startScreencast = async (page, deviceId) => {
1057
+ const cdpSession = await page.context().newCDPSession(page);
1058
+ cdpSession.on("Page.screencastFrame", async (screencastFrame) => {
1059
+ try {
1060
+ await cdpSession.send("Page.screencastFrameAck", { sessionId: screencastFrame.sessionId });
1061
+ } catch (error) {
1062
+ if (page.isClosed()) return;
1063
+ console.error({
1064
+ deviceId,
1065
+ error
1066
+ });
1067
+ }
1068
+ broadcast({
1069
+ kind: "screen",
1070
+ deviceId,
1071
+ imageBase64: screencastFrame.data
1072
+ });
1073
+ });
1074
+ await cdpSession.send("Page.startScreencast", { ...SCREENCAST });
1075
+ let stillQuality = SCREENCAST.quality;
1076
+ const sendStillScreenImage = async () => {
1077
+ await waitForPaintedPage(page);
1078
+ const stillScreenImage = await cdpSession.send("Page.captureScreenshot", {
1079
+ format: SCREENCAST.format,
1080
+ quality: stillQuality
1081
+ });
1082
+ broadcast({
1083
+ kind: "screen",
1084
+ deviceId,
1085
+ imageBase64: stillScreenImage.data
1086
+ });
1087
+ };
1088
+ const sendStillScreenImageOnLoad = () => {
1089
+ sendStillScreenImage().catch((error) => {
1090
+ if (page.isClosed()) return;
1091
+ console.error({
1092
+ deviceId,
1093
+ error
1094
+ });
1095
+ });
1096
+ };
1097
+ page.on("load", sendStillScreenImageOnLoad);
1098
+ sendStillScreenImageOnLoad();
1099
+ const setZoomed = async (isZoomed) => {
1100
+ const screencastSize = isZoomed ? ZOOM_SCREENCAST : SCREENCAST;
1101
+ stillQuality = screencastSize.quality;
1102
+ await cdpSession.send("Page.stopScreencast");
1103
+ await cdpSession.send("Page.startScreencast", {
1104
+ ...SCREENCAST,
1105
+ ...screencastSize
1106
+ });
1107
+ sendStillScreenImageOnLoad();
1108
+ };
1109
+ const forgetZoomControl = registerZoomControl(deviceId, setZoomed);
1110
+ return async () => {
1111
+ forgetZoomControl();
1112
+ page.off("load", sendStillScreenImageOnLoad);
1113
+ try {
1114
+ await cdpSession.send("Page.stopScreencast");
1115
+ } finally {
1116
+ await cdpSession.detach();
1117
+ }
1118
+ };
1119
+ };
1120
+ const sendStepScreen = async (page, deviceId) => {
1121
+ await waitForPaintedPage(page);
1122
+ const stepScreenImage = await page.screenshot({
1123
+ type: SCREENCAST.format,
1124
+ quality: SCREENCAST.quality
1125
+ });
1126
+ broadcast({
1127
+ kind: "screen",
1128
+ deviceId,
1129
+ imageBase64: stepScreenImage.toString("base64")
1130
+ });
1131
+ };
1132
+ // src/devices/device-browsers.ts
1133
+ const DEFAULT_PAGE_WATCHERS = {
1134
+ chromium: [
1135
+ startScreencast,
1136
+ startSampling,
1137
+ watchPage
1138
+ ],
1139
+ webkit: [],
1140
+ firefox: []
1141
+ };
1142
+ const BROWSER_TYPES = {
1143
+ chromium,
1144
+ webkit,
1145
+ firefox
1146
+ };
1147
+ const DEVTOOLS_PORT_FILE = "DevToolsActivePort";
1148
+ const USER_DATA_DIR_FLAG = "--user-data-dir=";
1149
+ const DEVTOOLS_PORT_TIMEOUT_MS = 5e3;
1150
+ const DEVTOOLS_PORT_RETRY_MS = 50;
1151
+ const readCdpUrl = async (browserServer) => {
1152
+ const userDataFlag = browserServer.process().spawnargs.find((flag) => flag.startsWith(USER_DATA_DIR_FLAG));
1153
+ if (!(userDataFlag !== void 0)) throw new Error("Device browser started without a user data folder");
1154
+ const portFilePath = `${userDataFlag.slice(16)}/${DEVTOOLS_PORT_FILE}`;
1155
+ const timeout = AbortSignal.timeout(DEVTOOLS_PORT_TIMEOUT_MS);
1156
+ while (!timeout.aborted) {
1157
+ const portFile = await readStoredFile(portFilePath);
1158
+ if (portFile !== null) {
1159
+ const [cdpPort] = portFile.split("\n");
1160
+ return `http://${HUB_HOST}:${cdpPort}`;
1161
+ }
1162
+ await setTimeout$1(DEVTOOLS_PORT_RETRY_MS);
1163
+ }
1164
+ throw new Error("Device browser did not write its remote debugging port");
1165
+ };
1166
+ const stopPageWatchers = async (stopWatchers, deviceId) => {
1167
+ for (const stopWatching of stopWatchers) try {
1168
+ await stopWatching();
1169
+ } catch (error) {
1170
+ console.error({
1171
+ deviceId,
1172
+ error
1173
+ });
1174
+ }
1175
+ };
1176
+ const launchBrowserServer = async (browserName) => {
1177
+ const launchSettings = {
1178
+ headless: true,
1179
+ host: HUB_HOST,
1180
+ handleSIGINT: false,
1181
+ handleSIGTERM: false
1182
+ };
1183
+ if (browserName === "chromium") return chromium.launchServer({
1184
+ ...launchSettings,
1185
+ args: ["--remote-debugging-port=0"]
1186
+ });
1187
+ return BROWSER_TYPES[browserName].launchServer(launchSettings);
1188
+ };
1189
+ const connectHubBrowser = async (browserName, wsEndpoint, cdpUrl) => {
1190
+ if (cdpUrl !== null) return chromium.connectOverCDP(cdpUrl);
1191
+ return BROWSER_TYPES[browserName].connect(wsEndpoint);
1192
+ };
1193
+ const NAME_HELPER_SCRIPT = "globalThis.__name = (target) => target;";
1194
+ const startDeviceBrowser = async (devicePreset, pageWatchers = DEFAULT_PAGE_WATCHERS[devicePreset.browserName]) => {
1195
+ const { browserName } = devicePreset;
1196
+ const isChromium = browserName === "chromium";
1197
+ const browserServer = await launchBrowserServer(browserName);
1198
+ try {
1199
+ const wsEndpoint = browserServer.wsEndpoint();
1200
+ const cdpUrl = isChromium ? await readCdpUrl(browserServer) : null;
1201
+ const hubBrowser = await connectHubBrowser(browserName, wsEndpoint, cdpUrl);
1202
+ const deviceId = devicePreset.id;
1203
+ const openPages = [];
1204
+ const watchersByPage = /* @__PURE__ */ new Map();
1205
+ const startPageWatchers = async (page) => {
1206
+ const stopWatchers = [];
1207
+ watchersByPage.set(page, stopWatchers);
1208
+ for (const pageWatcher of pageWatchers) {
1209
+ const stopWatching = await pageWatcher(page, deviceId);
1210
+ stopWatchers.push(stopWatching);
1211
+ }
1212
+ };
1213
+ const forgetPage = async (page) => {
1214
+ const pageIndex = openPages.indexOf(page);
1215
+ if (!(pageIndex !== -1)) return;
1216
+ const wasCurrentPage = pageIndex === openPages.length - 1;
1217
+ openPages.splice(pageIndex, 1);
1218
+ watchersByPage.delete(page);
1219
+ const [newestPage] = openPages.slice(-1);
1220
+ if (wasCurrentPage && newestPage !== void 0) await startPageWatchers(newestPage);
1221
+ };
1222
+ const trackPage = async (page) => {
1223
+ if (openPages.includes(page)) return;
1224
+ if (isChromium) {
1225
+ await emulateDevice(page, devicePreset);
1226
+ await (await page.context().newCDPSession(page)).send("Emulation.setCPUThrottlingRate", { rate: devicePreset.cpuSlowdown });
1227
+ }
1228
+ const [currentPage] = openPages.slice(-1);
1229
+ if (currentPage !== void 0) {
1230
+ const currentStopWatchers = watchersByPage.get(currentPage) ?? [];
1231
+ watchersByPage.delete(currentPage);
1232
+ await stopPageWatchers(currentStopWatchers, deviceId);
1233
+ }
1234
+ openPages.push(page);
1235
+ page.on("close", () => runSafely(() => forgetPage(page)));
1236
+ await startPageWatchers(page);
1237
+ };
1238
+ const runSafely = (task) => {
1239
+ runOneAtATime(wsEndpoint, task).catch((error) => {
1240
+ console.error({
1241
+ deviceId,
1242
+ error
1243
+ });
1244
+ });
1245
+ };
1246
+ const watchBrowserContext = (browserContext) => {
1247
+ browserContext.on("page", (page) => runSafely(() => trackPage(page)));
1248
+ for (const page of browserContext.pages()) runSafely(() => trackPage(page));
1249
+ };
1250
+ if (isChromium) for (const browserContext of hubBrowser.contexts()) watchBrowserContext(browserContext);
1251
+ const openPage = async () => {
1252
+ const browserContext = await hubBrowser.newContext(devicePreset.use);
1253
+ await browserContext.addInitScript(NAME_HELPER_SCRIPT);
1254
+ const page = await browserContext.newPage();
1255
+ await runOneAtATime(wsEndpoint, () => trackPage(page));
1256
+ return page;
1257
+ };
1258
+ const stop = async () => {
1259
+ try {
1260
+ const allStopWatchers = [...watchersByPage.entries()].filter(([page]) => !page.isClosed()).flatMap(([, stopWatchers]) => stopWatchers);
1261
+ await stopPageWatchers(allStopWatchers, deviceId);
1262
+ await hubBrowser.close();
1263
+ } finally {
1264
+ await browserServer.close();
1265
+ }
1266
+ };
1267
+ return {
1268
+ deviceId,
1269
+ browserName,
1270
+ wsEndpoint,
1271
+ cdpUrl,
1272
+ openPage,
1273
+ stop
1274
+ };
1275
+ } catch (error) {
1276
+ await browserServer.close();
1277
+ throw new Error("Device browser failed to start", { cause: error });
1278
+ }
1279
+ };
1280
+ // src/checks/check-probes.ts
1281
+ const TAP_TAGS = "a[href], button, input, [role=button]";
1282
+ const readMeasurements = (measureRequest) => {
1283
+ const SELECTOR_DEPTH = 3;
1284
+ const { tapTags, deviceViewport } = measureRequest;
1285
+ const { width: viewportWidth, height: viewportHeight } = deviceViewport;
1286
+ const describeNode = (element) => {
1287
+ const tagName = element.tagName.toLowerCase();
1288
+ const firstClass = element.classList[0] ?? null;
1289
+ return `${tagName}${firstClass === null ? "" : `.${CSS.escape(firstClass)}`}:nth-of-type(${[...element.parentElement?.children ?? []].filter((sibling) => sibling.tagName === element.tagName).indexOf(element) + 1})`;
1290
+ };
1291
+ const describeElement = (element) => {
1292
+ const path = [];
1293
+ let node = element;
1294
+ while (node !== null && node !== document.body) {
1295
+ if (path.length >= SELECTOR_DEPTH) break;
1296
+ path.unshift(describeNode(node));
1297
+ node = node.parentElement;
1298
+ }
1299
+ return path.join(" > ");
1300
+ };
1301
+ const isInsideClippingBox = (element) => {
1302
+ let ancestor = element.parentElement;
1303
+ while (ancestor !== null && ancestor !== document.body) {
1304
+ const { overflowX } = getComputedStyle(ancestor);
1305
+ if (overflowX !== "visible") return true;
1306
+ ancestor = ancestor.parentElement;
1307
+ }
1308
+ return false;
1309
+ };
1310
+ const hasOwnText = (element) => [...element.childNodes].some((childNode) => {
1311
+ const isTextNode = childNode.nodeType === Node.TEXT_NODE;
1312
+ const hasLetters = (childNode.textContent ?? "").trim().length > 0;
1313
+ return isTextNode && hasLetters;
1314
+ });
1315
+ const readSheetRules = (sheet) => {
1316
+ try {
1317
+ return [...sheet.cssRules];
1318
+ } catch (error) {
1319
+ if (error instanceof DOMException && error.name === "SecurityError") return [];
1320
+ throw error;
1321
+ }
1322
+ };
1323
+ const listStyleRules = (rules) => rules.flatMap((rule) => {
1324
+ if (rule instanceof CSSStyleRule) return [rule];
1325
+ return rule instanceof CSSGroupingRule ? listStyleRules([...rule.cssRules]) : [];
1326
+ });
1327
+ const isRuleMatching = (rule, element) => {
1328
+ try {
1329
+ return element.matches(rule.selectorText);
1330
+ } catch (error) {
1331
+ if (error instanceof DOMException && error.name === "SyntaxError") return false;
1332
+ throw error;
1333
+ }
1334
+ };
1335
+ const styleRules = listStyleRules([...document.styleSheets].flatMap(readSheetRules));
1336
+ const listOwnDeclarations = (element) => {
1337
+ const inlineStyle = element.getAttribute("style");
1338
+ const hasInlineStyle = inlineStyle !== null;
1339
+ const ruleDeclarations = styleRules.filter((rule) => isRuleMatching(rule, element)).map((rule) => rule.style.cssText);
1340
+ return hasInlineStyle ? [inlineStyle, ...ruleDeclarations] : ruleDeclarations;
1341
+ };
1342
+ const isTiedToSafeArea = (element, side) => listOwnDeclarations(element).some((declarations) => declarations.includes(`safe-area-inset-${side}`));
1343
+ const viewportContent = document.querySelector("meta[name=\"viewport\"]")?.getAttribute("content") ?? null;
1344
+ const isViewportCover = viewportContent !== null && /viewport-fit\s*=\s*cover/i.test(viewportContent);
1345
+ const measured = [...document.querySelectorAll("body *")].map((element) => ({
1346
+ selector: describeElement(element),
1347
+ style: getComputedStyle(element),
1348
+ box: element.getBoundingClientRect(),
1349
+ element
1350
+ }));
1351
+ const isRightToLeft = getComputedStyle(document.documentElement).direction === "rtl";
1352
+ const measureOverflowPx = (box) => isRightToLeft ? -(box.left + window.scrollX) : box.right - viewportWidth;
1353
+ const pastEdge = measured.filter(({ element }) => !isInsideClippingBox(element)).map(({ selector, box }) => ({
1354
+ selector,
1355
+ overflowPx: Math.round(measureOverflowPx(box))
1356
+ })).filter((pastEdgeBox) => pastEdgeBox.overflowPx > 0).sort((first, second) => second.overflowPx - first.overflowPx);
1357
+ const clippedSelectors = measured.filter(({ style, element }) => {
1358
+ const hidesOverflow = style.overflowX === "hidden" || style.textOverflow === "ellipsis";
1359
+ const isWiderThanBox = element.scrollWidth > element.clientWidth;
1360
+ return hidesOverflow && isWiderThanBox && hasOwnText(element);
1361
+ }).map((clippedBox) => clippedBox.selector);
1362
+ const pinnedBoxes = measured.filter(({ style }) => ["fixed", "sticky"].includes(style.position)).map(({ selector, box, style, element }) => ({
1363
+ selector,
1364
+ top: box.top,
1365
+ bottom: box.bottom,
1366
+ paddingTop: Number.parseFloat(style.paddingTop),
1367
+ paddingBottom: Number.parseFloat(style.paddingBottom),
1368
+ cssSafeAreaSides: {
1369
+ top: isTiedToSafeArea(element, "top"),
1370
+ bottom: isTiedToSafeArea(element, "bottom")
1371
+ }
1372
+ }));
1373
+ const tapTargets = measured.filter(({ element, style, box }) => {
1374
+ const isHidden = style.display === "none" || style.visibility === "hidden";
1375
+ const hasNoSize = box.width === 0 || box.height === 0;
1376
+ const parentLength = element.parentElement?.textContent?.length ?? 0;
1377
+ const ownLength = element.textContent?.length ?? 0;
1378
+ const isInlineLink = element.tagName === "A" && style.display === "inline" && parentLength > ownLength;
1379
+ return element.matches(tapTags) && !isHidden && !hasNoSize && !isInlineLink;
1380
+ }).map(({ selector, box }) => ({
1381
+ selector,
1382
+ width: box.width,
1383
+ height: box.height
1384
+ }));
1385
+ return {
1386
+ viewportWidth,
1387
+ viewportHeight,
1388
+ widestPastEdge: pastEdge[0] ?? null,
1389
+ clippedSelectors,
1390
+ pinnedBoxes,
1391
+ tapTargets,
1392
+ isViewportCover
1393
+ };
1394
+ };
1395
+ const measurePage = (page, deviceViewport) => page.evaluate(readMeasurements, {
1396
+ tapTags: TAP_TAGS,
1397
+ deviceViewport
1398
+ });
1399
+ // src/live/navigation-retry.ts
1400
+ const NAVIGATION_SETTLE_TIMEOUT_MS = 3e3;
1401
+ const NAVIGATION_SETTLE_PAUSE_MS = 200;
1402
+ const NAVIGATION_RACE_MESSAGE = "Execution context was destroyed";
1403
+ const waitForNewPage = async (page) => {
1404
+ await page.waitForTimeout(NAVIGATION_SETTLE_PAUSE_MS);
1405
+ try {
1406
+ await page.waitForLoadState("domcontentloaded", { timeout: NAVIGATION_SETTLE_TIMEOUT_MS });
1407
+ } catch (error) {
1408
+ if (!(error instanceof errors.TimeoutError)) throw error;
1409
+ }
1410
+ };
1411
+ const retryAfterNavigation = async (page, readPage) => {
1412
+ try {
1413
+ return await readPage();
1414
+ } catch (error) {
1415
+ if (!pickErrorMessage(error).includes(NAVIGATION_RACE_MESSAGE)) throw error;
1416
+ await waitForNewPage(page);
1417
+ return readPage();
1418
+ }
1419
+ };
1420
+ // src/commands/command-steps.ts
1421
+ const ACTION_TIMEOUT_MS = 1e4;
1422
+ const performCommand = async (devicePage, hubCommand) => {
1423
+ const { page, device } = devicePage;
1424
+ const actionTimeout = { timeout: ACTION_TIMEOUT_MS };
1425
+ switch (hubCommand.commandName) {
1426
+ case "open":
1427
+ await page.goto(hubCommand.url, actionTimeout);
1428
+ return;
1429
+ case "sweep":
1430
+ await page.goto(hubCommand.url, actionTimeout);
1431
+ await waitForPaintedPage(page);
1432
+ return;
1433
+ case "tap": {
1434
+ const target = page.getByRole(hubCommand.role, { name: hubCommand.name });
1435
+ if (device.use.hasTouch === true) {
1436
+ await target.tap(actionTimeout);
1437
+ return;
1438
+ }
1439
+ await target.click(actionTimeout);
1440
+ return;
1441
+ }
1442
+ case "fill":
1443
+ await page.getByRole(hubCommand.role, { name: hubCommand.name }).fill(hubCommand.with, actionTimeout);
1444
+ return;
1445
+ case "scroll": {
1446
+ const isScrollToTop = hubCommand.to === "top";
1447
+ await page.evaluate((isTop) => {
1448
+ const bottomOffset = document.documentElement.scrollHeight;
1449
+ window.scrollTo(0, isTop ? 0 : bottomOffset);
1450
+ }, isScrollToTop);
1451
+ return;
1452
+ }
1453
+ case "expect":
1454
+ await page.getByText(hubCommand.text).first().waitFor({
1455
+ state: "visible",
1456
+ ...actionTimeout
1457
+ });
1458
+ return;
1459
+ case "check":
1460
+ case "shot": return;
1461
+ }
1462
+ };
1463
+ const tryCommand = async (devicePage, hubCommand) => {
1464
+ try {
1465
+ await performCommand(devicePage, hubCommand);
1466
+ return null;
1467
+ } catch (error) {
1468
+ return pickErrorMessage(error);
1469
+ }
1470
+ };
1471
+ const saveShot = async (devicePage, stepTarget) => {
1472
+ const { rootFolder, runId, stepId } = stepTarget;
1473
+ const shotPath = createShotPath(runId, stepId, devicePage.device.id);
1474
+ const shotBytes = await devicePage.page.screenshot({ type: "png" });
1475
+ await writeStoredFile(join(rootFolder, shotPath), shotBytes);
1476
+ return shotPath;
1477
+ };
1478
+ const runStepOnDevice = async (devicePage, stepTarget) => {
1479
+ const { page, device, hasLiveScreen } = devicePage;
1480
+ const { hubCommand } = stepTarget;
1481
+ const startedAt = Date.now();
1482
+ releaseDeviceScreen(device.id);
1483
+ const actionErrorMessage = await tryCommand(devicePage, hubCommand);
1484
+ const deviceViewport = pickDeviceViewport(device);
1485
+ const measurements = await retryAfterNavigation(page, () => measurePage(page, deviceViewport));
1486
+ const checkDevice = pickCheckDevice(device);
1487
+ const findings = listFindings(measurements, checkDevice);
1488
+ const durationMs = Date.now() - startedAt;
1489
+ const failureMessage = pickFailureMessage(actionErrorMessage, findings);
1490
+ const isStepFailed = failureMessage !== null;
1491
+ const isShotCommand = hubCommand.commandName === "shot";
1492
+ const isSweepCommand = hubCommand.commandName === "sweep";
1493
+ const shotPath = isStepFailed || isShotCommand || isSweepCommand ? await saveShot(devicePage, stepTarget) : null;
1494
+ if (!hasLiveScreen) await retryAfterNavigation(page, () => sendStepScreen(page, device.id));
1495
+ if (isStepFailed) return {
1496
+ deviceAnswer: {
1497
+ status: "failed",
1498
+ deviceId: device.id,
1499
+ durationMs,
1500
+ message: failureMessage,
1501
+ shotPath
1502
+ },
1503
+ findings
1504
+ };
1505
+ return {
1506
+ deviceAnswer: {
1507
+ status: "ok",
1508
+ deviceId: device.id,
1509
+ durationMs,
1510
+ shotPath
1511
+ },
1512
+ findings
1513
+ };
1514
+ };
1515
+ // src/live/live-command-schema.ts
1516
+ const commandNoticeSchema = z.object({
1517
+ commandName: z.string().min(1),
1518
+ status: z.enum(["ok", "failed"]),
1519
+ line: z.string(),
1520
+ sentAt: z.number().int()
1521
+ });
1522
+ // src/runs/interactive-run.ts
1523
+ const hubInteractiveRun = { runId: null };
1524
+ const createCommandQueueKey = (rootFolder) => `commands:${rootFolder}`;
1525
+ const readHubInteractiveRun = async (rootFolder) => {
1526
+ const heldRunId = hubInteractiveRun.runId;
1527
+ if (heldRunId === null) return null;
1528
+ return readRun(rootFolder, heldRunId);
1529
+ };
1530
+ const readInteractiveRun = async (rootFolder) => {
1531
+ const heldRun = await readHubInteractiveRun(rootFolder);
1532
+ if (isRunOpen(heldRun)) return heldRun;
1533
+ const openedRun = await createRun(rootFolder, "cli", "steps");
1534
+ hubInteractiveRun.runId = openedRun.runId;
1535
+ return openedRun;
1536
+ };
1537
+ const closeInteractiveRun = async (rootFolder) => {
1538
+ const commandQueueKey = createCommandQueueKey(rootFolder);
1539
+ await runOneAtATime(commandQueueKey, async () => {
1540
+ const heldRun = await readHubInteractiveRun(rootFolder);
1541
+ hubInteractiveRun.runId = null;
1542
+ if (!isRunOpen(heldRun)) return;
1543
+ await closeRun(rootFolder, heldRun.runId);
1544
+ });
1545
+ };
1546
+ // src/flows/flows-logic.ts
1547
+ const listFlowCommands = (flow) => flow.steps.map((step) => ({
1548
+ ...step,
1549
+ deviceIds: flow.devices
1550
+ }));
1551
+ const listFlowBatches = (flowPaths, flowsPerBatch) => {
1552
+ const batchCount = Math.ceil(flowPaths.length / flowsPerBatch);
1553
+ return [...Array(batchCount).keys()].map((batchIndex) => flowPaths.slice(batchIndex * flowsPerBatch, (batchIndex + 1) * flowsPerBatch));
1554
+ };
1555
+ const createFailedFlowAnswer = (flowPath, problemLine) => ({
1556
+ flowPath,
1557
+ status: "failed",
1558
+ runId: null,
1559
+ failures: [],
1560
+ problemLine
1561
+ });
1562
+ const createFlowAnswer = (flowPath, stepReplies) => {
1563
+ const runId = stepReplies.at(-1)?.runId ?? null;
1564
+ const failedReply = stepReplies.find(({ status }) => status === "failed");
1565
+ if (failedReply === void 0) return {
1566
+ flowPath,
1567
+ status: "ok",
1568
+ runId,
1569
+ failures: [],
1570
+ problemLine: null
1571
+ };
1572
+ const problemLine = failedReply.failures.length > 0 ? null : failedReply.line;
1573
+ return {
1574
+ flowPath,
1575
+ status: "failed",
1576
+ runId,
1577
+ failures: failedReply.failures,
1578
+ problemLine
1579
+ };
1580
+ };
1581
+ const summarizeFlows = (flowAnswers) => {
1582
+ const failedCount = flowAnswers.filter(({ status }) => status === "failed").length;
1583
+ const okCount = flowAnswers.length - failedCount;
1584
+ const runId = flowAnswers.at(-1)?.runId ?? null;
1585
+ const status = failedCount === 0 && okCount > 0 ? "ok" : "failed";
1586
+ const summaryLine = createReplyLine({
1587
+ unit: "flow",
1588
+ okCount,
1589
+ warnCount: 0,
1590
+ failedCount,
1591
+ runId
1592
+ });
1593
+ const hasProblemLine = ({ problemLine }) => problemLine !== null;
1594
+ return {
1595
+ status,
1596
+ line: [summaryLine, ...flowAnswers.filter(hasProblemLine).map(({ flowPath, problemLine }) => `${flowPath}: ${problemLine}`)].join(" · "),
1597
+ runId,
1598
+ failures: flowAnswers.flatMap((flowAnswer) => flowAnswer.failures)
1599
+ };
1600
+ };
1601
+ // src/sweep/sweep-extra-devices.ts
1602
+ const closeExtraBrowsers = async (extraBrowsers) => {
1603
+ const closeReplies = await Promise.allSettled([...extraBrowsers.values()].map((extraBrowser) => extraBrowser.close()));
1604
+ for (const closeReply of closeReplies) if (closeReply.status === "rejected") console.error({ error: closeReply.reason });
1605
+ };
1606
+ const withExtraBrowsers = async (extraPresets, visitWithBrowsers) => {
1607
+ const extraBrowsers = /* @__PURE__ */ new Map();
1608
+ const browserNames = new Set(extraPresets.map(({ browserName }) => browserName));
1609
+ try {
1610
+ for (const browserName of browserNames) {
1611
+ const extraBrowser = await BROWSER_TYPES[browserName].launch({ headless: true });
1612
+ extraBrowsers.set(browserName, extraBrowser);
1613
+ }
1614
+ return await visitWithBrowsers(extraBrowsers);
1615
+ } finally {
1616
+ await closeExtraBrowsers(extraBrowsers);
1617
+ }
1618
+ };
1619
+ const withExtraDevicePage = async (extraBrowsers, devicePreset, visitDevicePage) => {
1620
+ const extraBrowser = extraBrowsers.get(devicePreset.browserName);
1621
+ if (extraBrowser === void 0) throw new Error(`No sweep browser for ${devicePreset.browserName}`);
1622
+ const browserContext = await extraBrowser.newContext(devicePreset.use);
1623
+ try {
1624
+ await browserContext.addInitScript(NAME_HELPER_SCRIPT);
1625
+ const page = await browserContext.newPage();
1626
+ if (devicePreset.browserName === "chromium") await emulateDevice(page, devicePreset);
1627
+ return await visitDevicePage({
1628
+ device: devicePreset,
1629
+ page,
1630
+ hasLiveScreen: true
1631
+ });
1632
+ } finally {
1633
+ await browserContext.close();
1634
+ }
1635
+ };
1636
+ // src/sweep/sweep-logic.ts
1637
+ const BAD_START_URL_STATUS = 422;
1638
+ const BEFORE_FAILED_PREFIX = "before flow failed: ";
1639
+ const WEB_PROTOCOLS = ["http:", "https:"];
1640
+ const NOFOLLOW_PATTERN = /(^|\s)nofollow(\s|$)/i;
1641
+ const DOWNLOAD_EXTENSIONS = [
1642
+ ".pdf",
1643
+ ".zip",
1644
+ ".gz",
1645
+ ".tar",
1646
+ ".dmg",
1647
+ ".exe",
1648
+ ".apk",
1649
+ ".csv",
1650
+ ".xml",
1651
+ ".json",
1652
+ ".txt",
1653
+ ".mp3",
1654
+ ".mp4",
1655
+ ".mov",
1656
+ ".png",
1657
+ ".jpg",
1658
+ ".jpeg",
1659
+ ".gif",
1660
+ ".webp",
1661
+ ".svg",
1662
+ ".ico"
1663
+ ];
1664
+ const SITEMAP_LOC_PATTERN = /<loc>([^<]*)<\/loc>/g;
1665
+ const XML_ENTITY_PATTERN = /&(?:lt|gt|quot|apos|amp);/g;
1666
+ const XML_ENTITIES = {
1667
+ "&lt;": "<",
1668
+ "&gt;": ">",
1669
+ "&quot;": "\"",
1670
+ "&apos;": "'",
1671
+ "&amp;": "&"
1672
+ };
1673
+ const createRoutePath = (href, baseUrl) => {
1674
+ if (!URL.canParse(href, baseUrl)) return null;
1675
+ const linkUrl = new URL(href, baseUrl);
1676
+ const isNotWebPage = !WEB_PROTOCOLS.includes(linkUrl.protocol);
1677
+ const isOtherOrigin = linkUrl.origin !== new URL(baseUrl).origin;
1678
+ const pathname = linkUrl.pathname.toLowerCase();
1679
+ const isFileDownload = DOWNLOAD_EXTENSIONS.some((extension) => pathname.endsWith(extension));
1680
+ const isHostPath = linkUrl.pathname.startsWith("//");
1681
+ if (isNotWebPage || isOtherOrigin || isFileDownload || isHostPath) return null;
1682
+ return `${linkUrl.pathname}${linkUrl.search}`;
1683
+ };
1684
+ const pickRoutes = (hrefs, baseUrl) => hrefs.map((href) => createRoutePath(href, baseUrl)).filter((route) => route !== null);
1685
+ const pickLinkRoutes = (pageLinks, startUrl) => {
1686
+ const followedHrefs = pageLinks.filter(({ rel, isDownload }) => !isDownload && !NOFOLLOW_PATTERN.test(rel)).map(({ href }) => href);
1687
+ return pickRoutes(followedHrefs, startUrl);
1688
+ };
1689
+ const parseSitemapLocs = (sitemapXml) => [...sitemapXml.matchAll(SITEMAP_LOC_PATTERN)].map(([, loc]) => loc.trim().replace(XML_ENTITY_PATTERN, (entity) => XML_ENTITIES[entity]));
1690
+ const mergeRoutes = (routeLists, maxRoutes) => [...new Set(routeLists.flat())].slice(0, maxRoutes);
1691
+ const pickStartRoute = (startUrl) => {
1692
+ const startRoute = createRoutePath(startUrl, startUrl);
1693
+ if (startRoute === null) throw new HTTPException(BAD_START_URL_STATUS, { message: "Sweep needs an http or https url" });
1694
+ return startRoute;
1695
+ };
1696
+ const createRouteUrl = (route, startUrl) => {
1697
+ return `${new URL(startUrl).origin}${route}`;
1698
+ };
1699
+ const hasLeftOrigin = (pageUrl, startUrl) => {
1700
+ return new URL(pageUrl).origin !== new URL(startUrl).origin;
1701
+ };
1702
+ const createLeftOriginStep = (deviceStep) => {
1703
+ const { deviceId, durationMs, shotPath } = deviceStep.deviceAnswer;
1704
+ return {
1705
+ deviceAnswer: {
1706
+ status: "failed",
1707
+ deviceId,
1708
+ durationMs,
1709
+ message: "left the app origin",
1710
+ shotPath
1711
+ },
1712
+ findings: deviceStep.findings
1713
+ };
1714
+ };
1715
+ const createCrashedStep = (deviceId, durationMs, error) => ({
1716
+ deviceAnswer: {
1717
+ status: "failed",
1718
+ deviceId,
1719
+ durationMs,
1720
+ message: pickErrorMessage(error),
1721
+ shotPath: null
1722
+ },
1723
+ findings: []
1724
+ });
1725
+ const splitDeviceIds = (chosenIds, runningIds) => ({
1726
+ gridIds: chosenIds.filter((deviceId) => runningIds.includes(deviceId)),
1727
+ extraIds: chosenIds.filter((deviceId) => !runningIds.includes(deviceId))
1728
+ });
1729
+ const pickSweepDevices = (deviceIds, runningIds, catalogIds) => {
1730
+ const knownIds = [.../* @__PURE__ */ new Set([...runningIds, ...catalogIds])];
1731
+ if (deviceIds === "all") return splitDeviceIds(knownIds, runningIds);
1732
+ const chosenIds = pickDeviceIds(deviceIds === null ? runningIds : knownIds, deviceIds);
1733
+ return splitDeviceIds(chosenIds, runningIds);
1734
+ };
1735
+ const createBeforeFailure = (deviceFailure) => {
1736
+ const [firstLine] = deviceFailure.message.split("\n");
1737
+ return {
1738
+ ...deviceFailure,
1739
+ message: `${BEFORE_FAILED_PREFIX}${firstLine}`
1740
+ };
1741
+ };
1742
+ const isDeviceVisited = (deviceVisit) => deviceVisit.kind === "visited";
1743
+ const isBeforeFailed = (deviceVisit) => deviceVisit.kind === "before-failed";
1744
+ const pickVisitedRouteSteps = (deviceVisits) => deviceVisits.filter(isDeviceVisited).map(({ routeSteps }) => routeSteps);
1745
+ const pickBeforeFailures = (deviceVisits) => deviceVisits.filter(isBeforeFailed).map(({ beforeFailure }) => beforeFailure);
1746
+ const listBeforeRunSteps = (beforeFailures, routeCount, startedAt) => {
1747
+ if (beforeFailures.length === 0) return [];
1748
+ return [createCommandRunStep({
1749
+ stepId: createStepId(routeCount + 1),
1750
+ title: "before flow",
1751
+ startedAt,
1752
+ deviceSteps: beforeFailures.map((deviceAnswer) => ({
1753
+ deviceAnswer,
1754
+ findings: []
1755
+ }))
1756
+ })];
1757
+ };
1758
+ const createSweepSteps = (routes, routeStepsByDevice) => {
1759
+ if (routeStepsByDevice.length === 0) return [];
1760
+ return routes.map((route, routeIndex) => {
1761
+ const routeSteps = routeStepsByDevice.map((deviceRouteSteps) => deviceRouteSteps[routeIndex]);
1762
+ const startTimes = routeSteps.map(({ startedAt }) => startedAt);
1763
+ return {
1764
+ route,
1765
+ runStep: createCommandRunStep({
1766
+ stepId: createStepId(routeIndex + 1),
1767
+ title: `sweep ${route}`,
1768
+ startedAt: Math.min(...startTimes),
1769
+ deviceSteps: routeSteps.map(({ deviceStep }) => deviceStep)
1770
+ })
1771
+ };
1772
+ });
1773
+ };
1774
+ const summarizeSweep = (sweepSteps, beforeRunSteps, runId) => {
1775
+ const routeRunSteps = sweepSteps.map(({ runStep }) => runStep);
1776
+ const { okCount, warnCount, failedCount } = countDeviceStatuses([...routeRunSteps, ...beforeRunSteps]);
1777
+ const deviceCount = okCount + warnCount + failedCount;
1778
+ const routeFailures = sweepSteps.flatMap(({ route, runStep }) => listDeviceFailures(runStep).map((deviceFailure) => ({
1779
+ ...deviceFailure,
1780
+ message: `${route} ${deviceFailure.message}`
1781
+ })));
1782
+ const failures = [...beforeRunSteps.flatMap(listDeviceFailures), ...routeFailures];
1783
+ const shotCount = routeRunSteps.flatMap((runStep) => runStep.deviceAnswers).filter(({ shotPath }) => shotPath !== null).length;
1784
+ const findingCount = sweepSteps.reduce((total, { runStep }) => total + runStep.findings.length, 0);
1785
+ const warnParts = warnCount > 0 ? [`${warnCount} warn`] : [];
1786
+ const hasFailure = failedCount > 0;
1787
+ const failedParts = hasFailure ? [`${failedCount} failed`] : [];
1788
+ const lineParts = [
1789
+ countUnits(sweepSteps.length, "route"),
1790
+ countUnits(deviceCount, "device"),
1791
+ countUnits(shotCount, "shot"),
1792
+ countUnits(findingCount, "finding"),
1793
+ `${okCount} ok`,
1794
+ ...warnParts,
1795
+ ...failedParts,
1796
+ `report ${runId}`
1797
+ ];
1798
+ return {
1799
+ status: hasFailure ? "failed" : "ok",
1800
+ line: lineParts.join(" · "),
1801
+ runId,
1802
+ failures
1803
+ };
1804
+ };
1805
+ // src/sweep/sweep-routes.ts
1806
+ const SITEMAP_PATH = "/sitemap.xml";
1807
+ const LINK_SELECTOR = "a[href]";
1808
+ const readPageLinks = async (page) => page.evaluate((linkSelector) => [...document.querySelectorAll(linkSelector)].map((anchor) => ({
1809
+ href: anchor.href,
1810
+ rel: anchor.rel,
1811
+ isDownload: anchor.hasAttribute("download")
1812
+ })), LINK_SELECTOR);
1813
+ const readRouteLinks = async (page, routeUrl, startUrl) => {
1814
+ try {
1815
+ await page.goto(routeUrl, { timeout: ACTION_TIMEOUT_MS });
1816
+ await waitForPaintedPage(page);
1817
+ const pageUrl = page.url();
1818
+ if (hasLeftOrigin(pageUrl, startUrl)) return [];
1819
+ const pageLinks = await readPageLinks(page);
1820
+ return pickLinkRoutes(pageLinks, startUrl);
1821
+ } catch (error) {
1822
+ console.error({
1823
+ routeUrl,
1824
+ error
1825
+ });
1826
+ return [];
1827
+ }
1828
+ };
1829
+ const crawlRoutes = async (page, startUrl, maxRoutes) => {
1830
+ const knownRoutes = [pickStartRoute(startUrl)];
1831
+ for (const route of knownRoutes) {
1832
+ if (knownRoutes.length >= maxRoutes) break;
1833
+ const routeUrl = createRouteUrl(route, startUrl);
1834
+ const foundRoutes = await readRouteLinks(page, routeUrl, startUrl);
1835
+ const mergedRoutes = mergeRoutes([knownRoutes, foundRoutes], maxRoutes);
1836
+ knownRoutes.push(...mergedRoutes.slice(knownRoutes.length));
1837
+ }
1838
+ return knownRoutes;
1839
+ };
1840
+ const readSitemapRoutes = async (startUrl) => {
1841
+ const sitemapUrl = createRouteUrl(SITEMAP_PATH, startUrl);
1842
+ try {
1843
+ const sitemapReply = await fetch(sitemapUrl, { signal: AbortSignal.timeout(ACTION_TIMEOUT_MS) });
1844
+ if (!sitemapReply.ok) return [];
1845
+ const sitemapXml = await sitemapReply.text();
1846
+ const sitemapLocs = parseSitemapLocs(sitemapXml);
1847
+ return pickRoutes(sitemapLocs, startUrl);
1848
+ } catch (error) {
1849
+ console.error({
1850
+ sitemapUrl,
1851
+ error
1852
+ });
1853
+ return [];
1854
+ }
1855
+ };
1856
+ const listSweepRoutes = async (crawlPage, routeSources) => {
1857
+ const { startUrl, maxRoutes, configuredRoutes } = routeSources;
1858
+ const crawledRoutes = await crawlRoutes(crawlPage, startUrl, maxRoutes);
1859
+ const sitemapRoutes = await readSitemapRoutes(startUrl);
1860
+ const yamlRoutes = pickRoutes(configuredRoutes, startUrl);
1861
+ return mergeRoutes([
1862
+ crawledRoutes,
1863
+ sitemapRoutes,
1864
+ yamlRoutes
1865
+ ], maxRoutes);
1866
+ };
1867
+ // src/sweep/sweep-run.ts
1868
+ const runStepOrCrash = async (devicePage, stepTarget) => {
1869
+ const startedAt = Date.now();
1870
+ try {
1871
+ return await runStepOnDevice(devicePage, stepTarget);
1872
+ } catch (error) {
1873
+ console.error({
1874
+ deviceId: devicePage.device.id,
1875
+ error
1876
+ });
1877
+ const durationMs = Date.now() - startedAt;
1878
+ return createCrashedStep(devicePage.device.id, durationMs, error);
1879
+ }
1880
+ };
1881
+ const runRouteOnDevice = async (devicePage, stepStart, routeUrl) => {
1882
+ const startedAt = Date.now();
1883
+ try {
1884
+ const deviceStep = await runStepOnDevice(devicePage, stepStart);
1885
+ const pageUrl = devicePage.page.url();
1886
+ return hasLeftOrigin(pageUrl, routeUrl) ? createLeftOriginStep(deviceStep) : deviceStep;
1887
+ } catch (error) {
1888
+ console.error({
1889
+ deviceId: devicePage.device.id,
1890
+ error
1891
+ });
1892
+ const durationMs = Date.now() - startedAt;
1893
+ return createCrashedStep(devicePage.device.id, durationMs, error);
1894
+ }
1895
+ };
1896
+ const sweepDevice = async (deviceSweep) => {
1897
+ const { devicePage, rootFolder, runId, routes, sweepCommand } = deviceSweep;
1898
+ const routeSteps = [];
1899
+ for (const [routeIndex, route] of routes.entries()) {
1900
+ const startedAt = Date.now();
1901
+ const routeUrl = createRouteUrl(route, sweepCommand.url);
1902
+ const stepTarget = {
1903
+ rootFolder,
1904
+ runId,
1905
+ stepId: createStepId(routeIndex + 1),
1906
+ hubCommand: {
1907
+ ...sweepCommand,
1908
+ url: routeUrl
1909
+ }
1910
+ };
1911
+ const deviceStep = await runRouteOnDevice(devicePage, stepTarget, routeUrl);
1912
+ routeSteps.push({
1913
+ startedAt,
1914
+ deviceStep
1915
+ });
1916
+ }
1917
+ return routeSteps;
1918
+ };
1919
+ const runBeforeSteps = async (sweepRunStart, devicePage) => {
1920
+ const { rootFolder, runId, sweepCommand } = sweepRunStart;
1921
+ const { beforeSteps } = sweepCommand;
1922
+ if (beforeSteps === null) return null;
1923
+ for (const [stepIndex, hubCommand] of beforeSteps.entries()) {
1924
+ const stepTarget = {
1925
+ rootFolder,
1926
+ runId,
1927
+ stepId: `before-${stepIndex + 1}`,
1928
+ hubCommand
1929
+ };
1930
+ const { deviceAnswer } = await runStepOrCrash(devicePage, stepTarget);
1931
+ if (deviceAnswer.status === "failed") return createBeforeFailure(deviceAnswer);
1932
+ }
1933
+ return null;
1934
+ };
1935
+ const prepareGridPage = async (sweepRunStart, devicePage) => {
1936
+ const beforeFailure = await runBeforeSteps(sweepRunStart, devicePage);
1937
+ if (beforeFailure !== null) return {
1938
+ kind: "before-failed",
1939
+ beforeFailure
1940
+ };
1941
+ return {
1942
+ kind: "ready",
1943
+ devicePage
1944
+ };
1945
+ };
1946
+ const visitReadyPage = async (deviceSweep) => {
1947
+ return {
1948
+ kind: "visited",
1949
+ routeSteps: await sweepDevice(deviceSweep)
1950
+ };
1951
+ };
1952
+ const createCrawlPages = (sweepRunStart) => {
1953
+ const { sweepCommand, configuredRoutes } = sweepRunStart;
1954
+ return {
1955
+ startUrl: sweepCommand.url,
1956
+ maxRoutes: sweepCommand.maxRoutes,
1957
+ configuredRoutes
1958
+ };
1959
+ };
1960
+ const visitExtraDevice = (extraBrowsers, extraPreset, sweepRoutes) => withExtraDevicePage(extraBrowsers, extraPreset, async (devicePage) => {
1961
+ const { sweepRunStart, routes } = sweepRoutes;
1962
+ const beforeFailure = await runBeforeSteps(sweepRunStart, devicePage);
1963
+ if (beforeFailure !== null) return {
1964
+ kind: "before-failed",
1965
+ beforeFailure
1966
+ };
1967
+ return visitReadyPage({
1968
+ ...sweepRunStart,
1969
+ devicePage,
1970
+ routes
1971
+ });
1972
+ });
1973
+ const visitCrawlingExtraDevice = (extraBrowsers, extraPreset, sweepRunStart) => withExtraDevicePage(extraBrowsers, extraPreset, async (devicePage) => {
1974
+ const beforeFailure = await runBeforeSteps(sweepRunStart, devicePage);
1975
+ if (beforeFailure !== null) return {
1976
+ routes: null,
1977
+ deviceVisit: {
1978
+ kind: "before-failed",
1979
+ beforeFailure
1980
+ }
1981
+ };
1982
+ const crawlPages = createCrawlPages(sweepRunStart);
1983
+ const routes = await listSweepRoutes(devicePage.page, crawlPages);
1984
+ return {
1985
+ routes,
1986
+ deviceVisit: await visitReadyPage({
1987
+ ...sweepRunStart,
1988
+ devicePage,
1989
+ routes
1990
+ })
1991
+ };
1992
+ });
1993
+ const visitExtraDevices = async (extraBrowsers, extraPresets, sweepRoutes) => {
1994
+ const extraIds = extraPresets.map(({ id }) => id);
1995
+ const deviceVisits = [];
1996
+ for (const batchIds of listFlowBatches(extraIds, 4)) {
1997
+ const batchPresets = extraPresets.filter(({ id }) => batchIds.includes(id));
1998
+ const batchVisits = await Promise.all(batchPresets.map((extraPreset) => visitExtraDevice(extraBrowsers, extraPreset, sweepRoutes)));
1999
+ deviceVisits.push(...batchVisits);
2000
+ }
2001
+ return deviceVisits;
2002
+ };
2003
+ const visitExtraDevicesWithoutGrid = async (sweepRunStart, extraBrowsers, extraPresets) => {
2004
+ const deviceVisits = [];
2005
+ for (const [presetIndex, extraPreset] of extraPresets.entries()) {
2006
+ const crawlingVisit = await visitCrawlingExtraDevice(extraBrowsers, extraPreset, sweepRunStart);
2007
+ deviceVisits.push(crawlingVisit.deviceVisit);
2008
+ const { routes } = crawlingVisit;
2009
+ if (routes !== null) {
2010
+ const restPresets = extraPresets.slice(presetIndex + 1);
2011
+ const restVisits = await visitExtraDevices(extraBrowsers, restPresets, {
2012
+ sweepRunStart,
2013
+ routes
2014
+ });
2015
+ return {
2016
+ routes,
2017
+ deviceVisits: [...deviceVisits, ...restVisits]
2018
+ };
2019
+ }
2020
+ }
2021
+ return {
2022
+ routes: [],
2023
+ deviceVisits
2024
+ };
2025
+ };
2026
+ const visitDevices = async (sweepRunStart, gridPages, extraPresets) => {
2027
+ const preparedPages = await Promise.all(gridPages.map((gridPage) => prepareGridPage(sweepRunStart, gridPage)));
2028
+ const readyGridPages = preparedPages.filter((preparedPage) => preparedPage.kind === "ready").map(({ devicePage }) => devicePage);
2029
+ const gridFailureVisits = preparedPages.filter((preparedPage) => preparedPage.kind === "before-failed");
2030
+ return withExtraBrowsers(extraPresets, async (extraBrowsers) => {
2031
+ const [crawlGridPage] = readyGridPages;
2032
+ if (!(crawlGridPage !== void 0)) {
2033
+ const extraVisits = await visitExtraDevicesWithoutGrid(sweepRunStart, extraBrowsers, extraPresets);
2034
+ const deviceVisits = [...gridFailureVisits, ...extraVisits.deviceVisits];
2035
+ return {
2036
+ routes: extraVisits.routes,
2037
+ deviceVisits
2038
+ };
2039
+ }
2040
+ const crawlPages = createCrawlPages(sweepRunStart);
2041
+ const routes = await listSweepRoutes(crawlGridPage.page, crawlPages);
2042
+ const sweepRoutes = {
2043
+ sweepRunStart,
2044
+ routes
2045
+ };
2046
+ const [gridVisits, extraVisits] = await Promise.all([Promise.all(readyGridPages.map((devicePage) => visitReadyPage({
2047
+ ...sweepRunStart,
2048
+ devicePage,
2049
+ routes
2050
+ }))), visitExtraDevices(extraBrowsers, extraPresets, sweepRoutes)]);
2051
+ return {
2052
+ routes,
2053
+ deviceVisits: [
2054
+ ...gridFailureVisits,
2055
+ ...gridVisits,
2056
+ ...extraVisits
2057
+ ]
2058
+ };
2059
+ });
2060
+ };
2061
+ const saveSweepRun = async ({ rootFolder, runId }, runSteps, sweepReply) => {
2062
+ for (const runStep of runSteps) await saveRunEvent(rootFolder, runId, {
2063
+ kind: "step-end",
2064
+ step: runStep
2065
+ });
2066
+ const runStatus = sweepReply.status === "ok" ? "ok" : "failed";
2067
+ await saveRunEvent(rootFolder, runId, {
2068
+ kind: "run-end",
2069
+ status: runStatus
2070
+ });
2071
+ };
2072
+ const broadcastSweepRun = (runId, sweepSteps) => {
2073
+ const findings = sweepSteps.flatMap(({ runStep }) => runStep.findings);
2074
+ for (const finding of findings) broadcast({
2075
+ kind: "finding",
2076
+ finding
2077
+ });
2078
+ broadcast({
2079
+ kind: "run",
2080
+ runId
2081
+ });
2082
+ };
2083
+ const runSweep = async (rootFolder, devicePages, sweepCommand) => {
2084
+ const devicesFile = await readDevicesFile(rootFolder);
2085
+ const runningIds = devicePages.map(({ device }) => device.id);
2086
+ const catalogIds = DEVICE_CATALOG.map(({ id }) => id);
2087
+ const { gridIds, extraIds } = pickSweepDevices(sweepCommand.deviceIds, runningIds, catalogIds);
2088
+ const gridPages = devicePages.filter(({ device }) => gridIds.includes(device.id));
2089
+ const extraPresets = extraIds.map((extraId) => findCatalogPreset(extraId, devicesFile.viewportModes[extraId] ?? "full-screen"));
2090
+ const { runId } = await createRun(rootFolder, "cli", "sweep");
2091
+ const sweepRunStart = {
2092
+ rootFolder,
2093
+ runId,
2094
+ sweepCommand,
2095
+ configuredRoutes: devicesFile.routes
2096
+ };
2097
+ try {
2098
+ const startedAt = Date.now();
2099
+ const { routes, deviceVisits } = await visitDevices(sweepRunStart, gridPages, extraPresets);
2100
+ const routeStepsByDevice = pickVisitedRouteSteps(deviceVisits);
2101
+ const beforeFailures = pickBeforeFailures(deviceVisits);
2102
+ const sweepSteps = createSweepSteps(routes, routeStepsByDevice);
2103
+ const beforeRunSteps = listBeforeRunSteps(beforeFailures, sweepSteps.length, startedAt);
2104
+ const sweepReply = summarizeSweep(sweepSteps, beforeRunSteps, runId);
2105
+ const runSteps = [...sweepSteps.map(({ runStep }) => runStep), ...beforeRunSteps];
2106
+ await saveSweepRun(sweepRunStart, runSteps, sweepReply);
2107
+ broadcastSweepRun(runId, sweepSteps);
2108
+ return sweepReply;
2109
+ } catch (error) {
2110
+ try {
2111
+ await saveRunEvent(rootFolder, runId, {
2112
+ kind: "run-end",
2113
+ status: "failed"
2114
+ });
2115
+ } catch (saveError) {
2116
+ console.error({
2117
+ runId,
2118
+ saveError
2119
+ });
2120
+ throw new Error("Sweep failed and its run end was not saved", { cause: error });
2121
+ }
2122
+ throw error;
2123
+ }
2124
+ };
2125
+ // src/commands/commands-routes.ts
2126
+ const runCommand = async (rootFolder, devicePages, hubCommand) => {
2127
+ if (hubCommand.commandName === "sweep") return runSweep(rootFolder, devicePages, hubCommand);
2128
+ const chosenPages = pickChosenPages(devicePages, hubCommand.deviceIds);
2129
+ const run = await readInteractiveRun(rootFolder);
2130
+ const stepTarget = {
2131
+ rootFolder,
2132
+ runId: run.runId,
2133
+ stepId: pickStepId(run.steps),
2134
+ hubCommand
2135
+ };
2136
+ const startedAt = Date.now();
2137
+ const deviceSteps = await Promise.all(chosenPages.map((devicePage) => runStepOnDevice(devicePage, stepTarget)));
2138
+ const runStep = createCommandRunStep({
2139
+ stepId: stepTarget.stepId,
2140
+ title: createStepTitle(hubCommand),
2141
+ startedAt,
2142
+ deviceSteps
2143
+ });
2144
+ const { findings } = runStep;
2145
+ await saveRunEvent(rootFolder, run.runId, {
2146
+ kind: "step-end",
2147
+ step: runStep
2148
+ });
2149
+ for (const finding of findings) broadcast({
2150
+ kind: "finding",
2151
+ finding
2152
+ });
2153
+ broadcast({
2154
+ kind: "run",
2155
+ runId: run.runId
2156
+ });
2157
+ return summarizeCommand(runStep, run.runId);
2158
+ };
2159
+ const createCommandsRoutes = (rootFolder, devicePages) => new Hono().get("/app", (c) => {
2160
+ const pageUrls = devicePages.map(({ page }) => page.url());
2161
+ return c.json(pickOpenApp(pageUrls));
2162
+ }).post("/", zValidator("json", hubCommandSchema, rejectInvalidRequest), async (c) => {
2163
+ const hubCommand = c.req.valid("json");
2164
+ const commandQueueKey = createCommandQueueKey(rootFolder);
2165
+ const commandReply = await runOneAtATime(commandQueueKey, () => runCommand(rootFolder, devicePages, hubCommand));
2166
+ const commandNotice = commandNoticeSchema.parse({
2167
+ commandName: hubCommand.commandName,
2168
+ status: commandReply.status,
2169
+ line: commandReply.line,
2170
+ sentAt: Date.now()
2171
+ });
2172
+ broadcast({
2173
+ kind: "command",
2174
+ commandNotice
2175
+ });
2176
+ return c.json(commandReply);
2177
+ });
2178
+ // src/devices/devices-routes.ts
2179
+ const CREATED_STATUS$1 = 201;
2180
+ const syncRunningDevices = async (syncGrid, changedFile) => {
2181
+ if (syncGrid === null) return;
2182
+ await syncGrid(pickGridDevices(changedFile));
2183
+ };
2184
+ const createDevicesRoutes = (rootFolder, syncGrid = null) => new Hono().get("/", async (c) => {
2185
+ const devicesFile = await readDevicesFile(rootFolder);
2186
+ return c.json(pickGridDevices(devicesFile));
2187
+ }).get("/catalog", async (c) => {
2188
+ const devicesFile = await readDevicesFile(rootFolder);
2189
+ return c.json(listCatalogReplies(devicesFile));
2190
+ }).post("/", zValidator("json", customDeviceSchema, rejectInvalidRequest), async (c) => {
2191
+ const customDevice = c.req.valid("json");
2192
+ const changedFile = await updateDevicesFile(rootFolder, (devicesFile) => {
2193
+ const gridDevices = pickGridDevices(devicesFile);
2194
+ const takenDevices = [...DEVICE_CATALOG, ...gridDevices];
2195
+ const checkedDevice = checkNewDevice(customDevice, takenDevices);
2196
+ return {
2197
+ ...devicesFile,
2198
+ devices: [...devicesFile.devices, checkedDevice]
2199
+ };
2200
+ });
2201
+ await syncRunningDevices(syncGrid, changedFile);
2202
+ const devicePreset = createPresetFromCustomDevice(customDevice);
2203
+ return c.json(devicePreset, CREATED_STATUS$1);
2204
+ }).put("/grid", zValidator("json", gridUpdateSchema, rejectInvalidRequest), async (c) => {
2205
+ const { deviceIds } = c.req.valid("json");
2206
+ const changedFile = await updateDevicesFile(rootFolder, (devicesFile) => replaceGrid(devicesFile, deviceIds));
2207
+ await syncRunningDevices(syncGrid, changedFile);
2208
+ return c.json({ deviceIds: changedFile.grid });
2209
+ }).delete("/:deviceId", async (c) => {
2210
+ const deviceId = c.req.param("deviceId");
2211
+ const changedFile = await updateDevicesFile(rootFolder, (devicesFile) => checkDeviceRemoval(devicesFile, deviceId));
2212
+ await syncRunningDevices(syncGrid, changedFile);
2213
+ return c.json({ deviceId });
2214
+ });
2215
+ // src/live/live-test-schema.ts
2216
+ const liveTestStatusSchema = z.enum([
2217
+ "running",
2218
+ "ok",
2219
+ "failed"
2220
+ ]);
2221
+ const runOutcomeSchema = z.enum(["ok", "failed"]);
2222
+ const runEndReasonSchema = z.enum(["reported", "stopped"]);
2223
+ const testScreenSchema = z.object({
2224
+ deviceId: z.string().min(1),
2225
+ imageBase64: z.string().min(1)
2226
+ });
2227
+ const testEventSchema = z.object({
2228
+ deviceId: z.string().min(1),
2229
+ testId: z.string().min(1),
2230
+ title: z.string(),
2231
+ specFile: z.string(),
2232
+ status: liveTestStatusSchema,
2233
+ errorLine: z.string().nullable()
2234
+ });
2235
+ const stepEventSchema = z.object({
2236
+ deviceId: z.string().min(1),
2237
+ testId: z.string().min(1),
2238
+ stepIndex: z.number().int().min(1),
2239
+ stepTitle: z.string(),
2240
+ status: liveTestStatusSchema,
2241
+ errorLine: z.string().nullable()
2242
+ });
2243
+ const runTotalsSchema = z.object({
2244
+ phase: z.enum(["start", "update"]),
2245
+ totalTests: z.number().int().min(0),
2246
+ startedAt: z.number().int().min(0),
2247
+ endedAt: z.number().int().min(0).nullable(),
2248
+ okCount: z.number().int().min(0),
2249
+ failedCount: z.number().int().min(0),
2250
+ outcome: runOutcomeSchema.nullable(),
2251
+ endReason: runEndReasonSchema.nullable()
2252
+ });
2253
+ const testsClearSchema = z.object({ deviceIds: z.array(z.string().min(1)) });
2254
+ const runSavedSchema = z.object({ runId: z.string().min(1) });
2255
+ // src/live/run-silence-logic.ts
2256
+ const RUN_SILENCE_LIMIT_MS = 15e3;
2257
+ const RUN_STOPPED_LINE = "Run stopped without a report";
2258
+ const isRunLive = (runTotals) => runTotals.outcome === null;
2259
+ const pickSilentRunEnd = (lastRunTotals, stoppedTestCount, nowMs) => {
2260
+ if (lastRunTotals === null) return null;
2261
+ if (!isRunLive(lastRunTotals)) return null;
2262
+ return {
2263
+ ...lastRunTotals,
2264
+ failedCount: lastRunTotals.failedCount + stoppedTestCount,
2265
+ phase: "update",
2266
+ endedAt: nowMs,
2267
+ outcome: "failed",
2268
+ endReason: "stopped"
2269
+ };
2270
+ };
2271
+ const listStoppedTests = (runningTests) => runningTests.map((runningTest) => ({
2272
+ ...runningTest,
2273
+ status: "failed",
2274
+ errorLine: RUN_STOPPED_LINE
2275
+ }));
2276
+ // src/live/run-watchdog.ts
2277
+ const watchdog = { silenceTimer: null };
2278
+ const endSilentRun = () => {
2279
+ watchdog.silenceTimer = null;
2280
+ const runningTests = listRunningTests();
2281
+ const silentRunEnd = pickSilentRunEnd(readRunTotals(), runningTests.length, Date.now());
2282
+ if (silentRunEnd === null) return;
2283
+ const stoppedTests = listStoppedTests(runningTests);
2284
+ for (const stoppedTest of stoppedTests) broadcast({
2285
+ kind: "test",
2286
+ ...stoppedTest
2287
+ });
2288
+ broadcast({
2289
+ kind: "run-totals",
2290
+ ...silentRunEnd
2291
+ });
2292
+ };
2293
+ const watchRun = (runTotals) => {
2294
+ const { silenceTimer } = watchdog;
2295
+ if (silenceTimer !== null) clearTimeout(silenceTimer);
2296
+ watchdog.silenceTimer = null;
2297
+ if (!isRunLive(runTotals)) return;
2298
+ const nextTimer = setTimeout(endSilentRun, RUN_SILENCE_LIMIT_MS);
2299
+ nextTimer.unref();
2300
+ watchdog.silenceTimer = nextTimer;
2301
+ };
2302
+ // src/live/live-test-routes.ts
2303
+ const NO_CONTENT_STATUS = 204;
2304
+ const createLiveTestRoutes = () => new Hono().post("/screens", zValidator("json", testScreenSchema, rejectInvalidRequest), (c) => {
2305
+ const { deviceId, imageBase64 } = c.req.valid("json");
2306
+ broadcast({
2307
+ kind: "screen",
2308
+ deviceId,
2309
+ imageBase64
2310
+ }, "test-run");
2311
+ return c.body(null, NO_CONTENT_STATUS);
2312
+ }).post("/events", zValidator("json", testEventSchema, rejectInvalidRequest), (c) => {
2313
+ const testEvent = c.req.valid("json");
2314
+ broadcast({
2315
+ kind: "test",
2316
+ ...testEvent
2317
+ });
2318
+ return c.body(null, NO_CONTENT_STATUS);
2319
+ }).post("/steps", zValidator("json", stepEventSchema, rejectInvalidRequest), (c) => {
2320
+ const stepEvent = c.req.valid("json");
2321
+ broadcast({
2322
+ kind: "step",
2323
+ ...stepEvent
2324
+ });
2325
+ return c.body(null, NO_CONTENT_STATUS);
2326
+ }).post("/run", zValidator("json", runTotalsSchema, rejectInvalidRequest), (c) => {
2327
+ const runTotals = c.req.valid("json");
2328
+ broadcast({
2329
+ kind: "run-totals",
2330
+ ...runTotals
2331
+ });
2332
+ watchRun(runTotals);
2333
+ return c.body(null, NO_CONTENT_STATUS);
2334
+ }).post("/run-saved", zValidator("json", runSavedSchema, rejectInvalidRequest), (c) => {
2335
+ const { runId } = c.req.valid("json");
2336
+ broadcast({
2337
+ kind: "run",
2338
+ runId
2339
+ });
2340
+ return c.body(null, NO_CONTENT_STATUS);
2341
+ }).post("/clear", zValidator("json", testsClearSchema, rejectInvalidRequest), (c) => {
2342
+ const { deviceIds } = c.req.valid("json");
2343
+ forgetTests(deviceIds);
2344
+ return c.body(null, NO_CONTENT_STATUS);
2345
+ });
2346
+ // src/live/zoom-routes.ts
2347
+ const zoomSchema = z.object({
2348
+ deviceId: z.string().min(1).nullable(),
2349
+ clientId: z.string().min(1)
2350
+ });
2351
+ const createZoomRoutes = () => new Hono().put("/", zValidator("json", zoomSchema, rejectInvalidRequest), async (c) => {
2352
+ const { deviceId, clientId } = c.req.valid("json");
2353
+ await changeZoomedDevice(deviceId, clientId);
2354
+ return c.json({ deviceId });
2355
+ });
2356
+ // src/runs/runs-routes.ts
2357
+ const NOT_FOUND_STATUS$2 = 404;
2358
+ const CREATED_STATUS = 201;
2359
+ const runIdParamSchema = z.object({ runId: z.string().regex(RUN_ID_PATTERN) });
2360
+ const newRunSchema = z.object({ origin: runOriginSchema });
2361
+ const createRunsRoutes = (rootFolder) => new Hono().get("/", async (c) => {
2362
+ const runs = await listRuns(rootFolder);
2363
+ return c.json(runs);
2364
+ }).post("/", zValidator("json", newRunSchema, rejectInvalidRequest), async (c) => {
2365
+ const { origin } = c.req.valid("json");
2366
+ const run = await createRun(rootFolder, origin, "steps");
2367
+ return c.json({ runId: run.runId }, CREATED_STATUS);
2368
+ }).get("/:runId", zValidator("param", runIdParamSchema, rejectInvalidRequest), async (c) => {
2369
+ const { runId } = c.req.valid("param");
2370
+ const run = await readRun(rootFolder, runId);
2371
+ if (run === null) throw new HTTPException(NOT_FOUND_STATUS$2, { message: `No run ${runId}` });
2372
+ return c.json(run);
2373
+ }).post("/:runId/events", zValidator("param", runIdParamSchema, rejectInvalidRequest), zValidator("json", runEventSchema, rejectInvalidRequest), async (c) => {
2374
+ const { runId } = c.req.valid("param");
2375
+ const runEvent = c.req.valid("json");
2376
+ const savedRun = await saveRunEvent(rootFolder, runId, runEvent);
2377
+ broadcast({
2378
+ kind: "run",
2379
+ runId
2380
+ });
2381
+ return c.json(savedRun);
2382
+ });
2383
+ // src/runs/shots-routes.ts
2384
+ const NOT_FOUND_STATUS$1 = 404;
2385
+ const SHOTS_SUBFOLDER = "shots";
2386
+ const SHOT_FILE_NAME_PATTERN = /^[A-Za-z0-9-]+\.png$/;
2387
+ const SHOT_CONTENT_TYPE = "image/png";
2388
+ const shotNameParamSchema = z.object({ fileName: z.string().regex(SHOT_FILE_NAME_PATTERN) });
2389
+ const createShotsRoutes = (rootFolder) => new Hono().get("/:fileName", zValidator("param", shotNameParamSchema, rejectInvalidRequest), async (c) => {
2390
+ const { fileName } = c.req.valid("param");
2391
+ const shotPath = join(rootFolder, STORE_FOLDER, SHOTS_SUBFOLDER, fileName);
2392
+ const shotBytes = await readStoredBytes(shotPath);
2393
+ if (shotBytes === null) throw new HTTPException(NOT_FOUND_STATUS$1, { message: `No shot ${fileName}` });
2394
+ const shotImage = new Uint8Array(shotBytes);
2395
+ return c.body(shotImage, 200, { "Content-Type": SHOT_CONTENT_TYPE });
2396
+ });
2397
+ // src/hub/hub-app.ts
2398
+ const createHubApp = (rootFolder, devicePages = [], syncGrid = null) => new Hono().basePath("/api").route("/devices", createDevicesRoutes(rootFolder, syncGrid)).route("/commands", createCommandsRoutes(rootFolder, devicePages)).route("/live-tests", createLiveTestRoutes()).route("/zoom", createZoomRoutes()).route("/runs", createRunsRoutes(rootFolder)).route("/shots", createShotsRoutes(rootFolder)).onError(writeProblem);
2399
+ // src/live/live-routes.ts
2400
+ const SOCKET_OPEN = 1;
2401
+ const CLIENT_ID_QUERY = "clientId";
2402
+ const liveRoutes = new Hono().get(LIVE_PATH, upgradeWebSocket((c) => {
2403
+ const clientId = c.req.query(CLIENT_ID_QUERY) ?? null;
2404
+ const unsubscribeBySocket = /* @__PURE__ */ new Map();
2405
+ return {
2406
+ onOpen: (_openEvent, socket) => {
2407
+ const unsubscribe = subscribe((liveMessage) => {
2408
+ if (socket.readyState === SOCKET_OPEN) socket.send(JSON.stringify(liveMessage));
2409
+ });
2410
+ unsubscribeBySocket.set(socket, unsubscribe);
2411
+ },
2412
+ onClose: async (_closeEvent, socket) => {
2413
+ unsubscribeBySocket.get(socket)?.();
2414
+ unsubscribeBySocket.delete(socket);
2415
+ if (!(clientId !== null)) return;
2416
+ try {
2417
+ await releaseClientZoom(clientId);
2418
+ } catch (error) {
2419
+ console.error({
2420
+ clientId,
2421
+ error
2422
+ });
2423
+ }
2424
+ }
2425
+ };
2426
+ }));
2427
+ // src/hub/hub-server.ts
2428
+ const HUB_FILE_MODE = 384;
2429
+ const GRID_SYNC_QUEUE_KEY = "grid-sync";
2430
+ const NOT_FOUND_STATUS = 404;
2431
+ const API_PATH = "/api/*";
2432
+ const CLIENT_ROUTE_PATH = "*";
2433
+ const INDEX_FILE = "index.html";
2434
+ const startGridDevice = async (device) => {
2435
+ const deviceBrowser = await startDeviceBrowser(device);
2436
+ try {
2437
+ return {
2438
+ device,
2439
+ deviceBrowser,
2440
+ devicePage: {
2441
+ device,
2442
+ page: await deviceBrowser.openPage(),
2443
+ hasLiveScreen: deviceBrowser.cdpUrl !== null
2444
+ }
2445
+ };
2446
+ } catch (error) {
2447
+ await deviceBrowser.stop();
2448
+ throw error;
2449
+ }
2450
+ };
2451
+ const openAppUrlOnDevice = async ({ device, devicePage }, appUrl) => {
2452
+ try {
2453
+ await devicePage.page.goto(appUrl);
2454
+ } catch (error) {
2455
+ console.error({
2456
+ deviceId: device.id,
2457
+ error
2458
+ });
2459
+ }
2460
+ };
2461
+ const openAppUrlOnDevices = async (gridDevices, appUrl) => {
2462
+ if (appUrl === null) return;
2463
+ const openingDevices = gridDevices.map((gridDevice) => openAppUrlOnDevice(gridDevice, appUrl));
2464
+ await Promise.all(openingDevices);
2465
+ };
2466
+ const stopGridDevices = async (gridDevices) => {
2467
+ const stopReplies = await Promise.allSettled(gridDevices.map(({ deviceBrowser }) => deviceBrowser.stop()));
2468
+ for (const stopReply of stopReplies) if (stopReply.status === "rejected") console.error({ error: stopReply.reason });
2469
+ };
2470
+ const startGridDevices = async (devices) => {
2471
+ const startReplies = await Promise.allSettled(devices.map(startGridDevice));
2472
+ const startedDevices = startReplies.filter((startReply) => startReply.status === "fulfilled").map((startReply) => startReply.value);
2473
+ const failedStart = startReplies.find((startReply) => startReply.status === "rejected");
2474
+ if (failedStart !== void 0) {
2475
+ await stopGridDevices(startedDevices);
2476
+ throw new Error("A device browser failed to start", { cause: failedStart.reason });
2477
+ }
2478
+ return startedDevices;
2479
+ };
2480
+ const createHubFile = (port, gridDevices) => ({
2481
+ pid: process.pid,
2482
+ port,
2483
+ devices: Object.fromEntries(gridDevices.map(({ device, deviceBrowser }) => [device.id, {
2484
+ wsEndpoint: deviceBrowser.wsEndpoint,
2485
+ cdpUrl: deviceBrowser.cdpUrl
2486
+ }]))
2487
+ });
2488
+ const writeHubFile = async (hubFilePath, hubFile) => {
2489
+ const serializedHubFile = JSON.stringify(hubFile, null, 2);
2490
+ await writeStoredFile(hubFilePath, serializedHubFile, HUB_FILE_MODE);
2491
+ };
2492
+ const removeFromList = (list, isRemoved) => {
2493
+ const keptEntries = list.filter((listed) => !isRemoved(listed));
2494
+ list.splice(0, list.length, ...keptEntries);
2495
+ };
2496
+ const createGridSync = (gridDevices, devicePages, writeCurrentHubFile) => (nextDevices) => runOneAtATime(GRID_SYNC_QUEUE_KEY, async () => {
2497
+ const pageUrls = devicePages.map(({ page }) => page.url());
2498
+ const { appUrl } = pickOpenApp(pageUrls);
2499
+ const runningDeviceIds = gridDevices.map(({ device }) => device.id);
2500
+ const { addDevices, removeDeviceIds } = pickGridChanges(runningDeviceIds, nextDevices);
2501
+ const goneDevices = gridDevices.filter(({ device }) => removeDeviceIds.includes(device.id));
2502
+ removeFromList(gridDevices, ({ device }) => removeDeviceIds.includes(device.id));
2503
+ removeFromList(devicePages, ({ device }) => removeDeviceIds.includes(device.id));
2504
+ await stopGridDevices(goneDevices);
2505
+ for (const removeDeviceId of removeDeviceIds) forgetDeviceScreen(removeDeviceId);
2506
+ const startedDevices = await startGridDevices(addDevices);
2507
+ gridDevices.push(...startedDevices);
2508
+ devicePages.push(...startedDevices.map(({ devicePage }) => devicePage));
2509
+ await openAppUrlOnDevices(startedDevices, appUrl);
2510
+ await writeCurrentHubFile();
2511
+ broadcast({ kind: "device-list" });
2512
+ });
2513
+ const createHubServerApp = (hubStart, devicePages, syncGrid) => {
2514
+ const { rootFolder, dashboardFolder } = hubStart;
2515
+ return new Hono().route("/", createHubApp(rootFolder, devicePages, syncGrid)).route("/", liveRoutes).all(API_PATH, () => {
2516
+ throw new HTTPException(NOT_FOUND_STATUS, { message: "No such route" });
2517
+ }).use(CLIENT_ROUTE_PATH, serveStatic({ root: dashboardFolder })).get(CLIENT_ROUTE_PATH, serveStatic({
2518
+ root: dashboardFolder,
2519
+ path: INDEX_FILE
2520
+ })).onError(writeProblem);
2521
+ };
2522
+ const listen = (hubServerApp, websocketServer, port) => new Promise((onListening, onListenFailed) => {
2523
+ const httpServer = serve({
2524
+ fetch: hubServerApp.fetch,
2525
+ port,
2526
+ hostname: HUB_HOST,
2527
+ websocket: { server: websocketServer }
2528
+ }, (addressInfo) => onListening({
2529
+ httpServer,
2530
+ port: addressInfo.port
2531
+ }));
2532
+ httpServer.once("error", onListenFailed);
2533
+ });
2534
+ const closeServer = (httpServer) => new Promise((onClosed, onCloseFailed) => {
2535
+ httpServer.close((error) => {
2536
+ if (error !== void 0) onCloseFailed(error);
2537
+ else onClosed();
2538
+ });
2539
+ if ("closeAllConnections" in httpServer) httpServer.closeAllConnections();
2540
+ });
2541
+ const listenOrStopDevices = async (hubServerApp, websocketServer, port, gridDevices) => {
2542
+ try {
2543
+ return await listen(hubServerApp, websocketServer, port);
2544
+ } catch (error) {
2545
+ await stopGridDevices(gridDevices);
2546
+ throw error;
2547
+ }
2548
+ };
2549
+ const startHub = async (hubStart) => {
2550
+ const { rootFolder, port } = hubStart;
2551
+ await closeLeftOpenRuns(rootFolder);
2552
+ const devicesFile = await readDevicesFile(rootFolder);
2553
+ const gridDevices = await startGridDevices(pickGridDevices(devicesFile));
2554
+ const devicePages = gridDevices.map(({ devicePage }) => devicePage);
2555
+ const hubFilePath = join(rootFolder, HUB_FILE_PATH);
2556
+ const websocketServer = new WebSocketServer({ noServer: true });
2557
+ const writeCurrentHubFile = () => writeHubFile(hubFilePath, createHubFile(listeningServer.port, gridDevices));
2558
+ const syncGrid = createGridSync(gridDevices, devicePages, writeCurrentHubFile);
2559
+ const hubServerApp = createHubServerApp(hubStart, devicePages, syncGrid);
2560
+ const listeningServer = await listenOrStopDevices(hubServerApp, websocketServer, port, gridDevices);
2561
+ try {
2562
+ await writeCurrentHubFile();
2563
+ const stop = async () => {
2564
+ await rm(hubFilePath, { force: true });
2565
+ for (const liveClient of websocketServer.clients) liveClient.terminate();
2566
+ await closeServer(listeningServer.httpServer);
2567
+ try {
2568
+ await closeInteractiveRun(rootFolder);
2569
+ } finally {
2570
+ await stopGridDevices(gridDevices);
2571
+ }
2572
+ };
2573
+ return {
2574
+ url: `http://${HUB_HOST}:${listeningServer.port}`,
2575
+ stop
2576
+ };
2577
+ } catch (error) {
2578
+ await closeServer(listeningServer.httpServer);
2579
+ await stopGridDevices(gridDevices);
2580
+ throw error;
2581
+ }
2582
+ };
2583
+ // src/cli/hub-process.ts
2584
+ const HUB_LOG_PATH = `${STORE_FOLDER}/hub.log`;
2585
+ const START_TIMEOUT_MS = 18e4;
2586
+ const STOP_TIMEOUT_MS = 3e4;
2587
+ const POLL_INTERVAL_MS = 200;
2588
+ const DASHBOARD_PATH = import.meta.url.endsWith(".mjs") ? "./dashboard" : "../../dist/dashboard";
2589
+ const dashboardUrl = new URL(DASHBOARD_PATH, import.meta.url);
2590
+ const DASHBOARD_FOLDER = fileURLToPath(dashboardUrl);
2591
+ const isProcessMissingError = (error) => error instanceof Error && "code" in error && error.code === "ESRCH";
2592
+ const isProcessAlive = (processId) => {
2593
+ try {
2594
+ process.kill(processId, 0);
2595
+ return true;
2596
+ } catch (error) {
2597
+ if (isProcessMissingError(error)) return false;
2598
+ throw error;
2599
+ }
2600
+ };
2601
+ const sendStopSignal = (processId) => {
2602
+ try {
2603
+ process.kill(processId, "SIGTERM");
2604
+ } catch (error) {
2605
+ if (isProcessMissingError(error)) return;
2606
+ throw error;
2607
+ }
2608
+ };
2609
+ const removeHubFile = async (rootFolder) => {
2610
+ const hubFilePath = join(rootFolder, HUB_FILE_PATH);
2611
+ await rm(hubFilePath, { force: true });
2612
+ };
2613
+ const waitForHubFile = async (rootFolder, hubProcessId) => {
2614
+ const giveUpAt = Date.now() + START_TIMEOUT_MS;
2615
+ const isBeforeDeadline = () => Date.now() < giveUpAt;
2616
+ while (isBeforeDeadline()) {
2617
+ const hubFile = await readHubFile(rootFolder);
2618
+ if (hubFile !== null && hubFile.pid === hubProcessId) return hubFile;
2619
+ if (!isProcessAlive(hubProcessId)) return null;
2620
+ await setTimeout$1(POLL_INTERVAL_MS);
2621
+ }
2622
+ return null;
2623
+ };
2624
+ const spawnHubProcess = async (rootFolder, port) => {
2625
+ const storeFolder = join(rootFolder, STORE_FOLDER);
2626
+ const hubLogPath = join(rootFolder, HUB_LOG_PATH);
2627
+ await mkdir(storeFolder, { recursive: true });
2628
+ const hubLog = await open(hubLogPath, "a");
2629
+ const [, cliPath] = process.argv;
2630
+ const hubProcess = spawn(process.execPath, [
2631
+ ...process.execArgv,
2632
+ cliPath,
2633
+ "start",
2634
+ "--foreground",
2635
+ "--port",
2636
+ String(port)
2637
+ ], {
2638
+ cwd: rootFolder,
2639
+ detached: true,
2640
+ stdio: [
2641
+ "ignore",
2642
+ hubLog.fd,
2643
+ hubLog.fd
2644
+ ]
2645
+ });
2646
+ try {
2647
+ await once(hubProcess, "spawn");
2648
+ } finally {
2649
+ await hubLog.close();
2650
+ }
2651
+ hubProcess.unref();
2652
+ const hubProcessId = hubProcess.pid;
2653
+ if (hubProcessId === void 0) throw new Error("The hub process did not start");
2654
+ return hubProcessId;
2655
+ };
2656
+ const checkRunningHub = async (rootFolder, requestedPort) => {
2657
+ const oldHubFile = await readHubFile(rootFolder);
2658
+ if (!(oldHubFile !== null && await isHubAnswering(oldHubFile))) return null;
2659
+ const oldHubUrl = createHubUrl(oldHubFile);
2660
+ if (isPortConflict(requestedPort, oldHubFile.port)) return createPortConflictReply(oldHubUrl);
2661
+ const oldDeviceCount = countDevices(oldHubFile);
2662
+ const runningLine = createRunningLine(oldHubUrl, oldDeviceCount);
2663
+ return createReply("ok", runningLine);
2664
+ };
2665
+ const startHubInBackground = async (rootFolder, requestedPort) => {
2666
+ const runningHubReply = await checkRunningHub(rootFolder, requestedPort);
2667
+ if (runningHubReply !== null) return runningHubReply;
2668
+ await removeHubFile(rootFolder);
2669
+ const hubProcessId = await spawnHubProcess(rootFolder, requestedPort ?? 4100);
2670
+ const hubFile = await waitForHubFile(rootFolder, hubProcessId);
2671
+ if (hubFile === null) {
2672
+ sendStopSignal(hubProcessId);
2673
+ return createReply("failed", `hub did not start · see ${HUB_LOG_PATH}`);
2674
+ }
2675
+ const hubUrl = createHubUrl(hubFile);
2676
+ const deviceCount = countDevices(hubFile);
2677
+ const readyLine = createReadyLine(hubUrl, deviceCount);
2678
+ return createReply("ok", readyLine);
2679
+ };
2680
+ const waitForStopSignal = async () => {
2681
+ const termSignal = once(process, "SIGTERM");
2682
+ const interruptSignal = once(process, "SIGINT");
2683
+ await Promise.race([termSignal, interruptSignal]);
2684
+ };
2685
+ const stopRunningHub = async (runningHub) => {
2686
+ try {
2687
+ await runningHub.stop();
2688
+ } catch (error) {
2689
+ console.error({ error });
2690
+ return createReply("failed", "hub did not stop cleanly");
2691
+ }
2692
+ return createReply("ok", "hub stopped");
2693
+ };
2694
+ const startHubInForeground = async (rootFolder, requestedPort, writeStartedReply) => {
2695
+ const runningHubReply = await checkRunningHub(rootFolder, requestedPort);
2696
+ if (runningHubReply !== null) return runningHubReply;
2697
+ const stopSignal = waitForStopSignal();
2698
+ const runningHub = await startHub({
2699
+ rootFolder,
2700
+ port: requestedPort ?? 4100,
2701
+ dashboardFolder: DASHBOARD_FOLDER
2702
+ });
2703
+ const hubFile = await readHubFile(rootFolder);
2704
+ if (hubFile === null) {
2705
+ await runningHub.stop();
2706
+ throw new Error("The hub did not write its hub file");
2707
+ }
2708
+ const deviceCount = countDevices(hubFile);
2709
+ const readyLine = createReadyLine(runningHub.url, deviceCount);
2710
+ writeStartedReply(createReply("ok", readyLine));
2711
+ await stopSignal;
2712
+ return stopRunningHub(runningHub);
2713
+ };
2714
+ const stopHub = async (rootFolder) => {
2715
+ const hubFile = await readHubFile(rootFolder);
2716
+ if (hubFile === null) return createHubDownReply();
2717
+ if (!await isHubAnswering(hubFile)) {
2718
+ await removeHubFile(rootFolder);
2719
+ return createHubDownReply();
2720
+ }
2721
+ sendStopSignal(hubFile.pid);
2722
+ const giveUpAt = Date.now() + STOP_TIMEOUT_MS;
2723
+ const isWaitingForExit = () => isProcessAlive(hubFile.pid) && Date.now() < giveUpAt;
2724
+ while (isWaitingForExit()) await setTimeout$1(POLL_INTERVAL_MS);
2725
+ if (isProcessAlive(hubFile.pid)) return createReply("failed", `hub did not stop · pid ${hubFile.pid}`);
2726
+ return createReply("ok", "hub stopped");
2727
+ };
2728
+ // src/flows/flow-schema.ts
2729
+ const STEP_WITHOUT_COMMAND_FIELDS = {
2730
+ commandName: true,
2731
+ deviceIds: true
2732
+ };
2733
+ const flowStepSchema = z.union([
2734
+ z.strictObject({ open: openCommandSchema.shape.url }).transform(({ open }) => ({
2735
+ commandName: "open",
2736
+ url: open
2737
+ })),
2738
+ z.strictObject({ tap: tapCommandSchema.omit(STEP_WITHOUT_COMMAND_FIELDS).strict() }).transform(({ tap }) => ({
2739
+ commandName: "tap",
2740
+ ...tap
2741
+ })),
2742
+ z.strictObject({ fill: fillCommandSchema.omit(STEP_WITHOUT_COMMAND_FIELDS).strict() }).transform(({ fill }) => ({
2743
+ commandName: "fill",
2744
+ ...fill
2745
+ })),
2746
+ z.strictObject({ scroll: scrollCommandSchema.omit(STEP_WITHOUT_COMMAND_FIELDS).strict() }).transform(({ scroll }) => ({
2747
+ commandName: "scroll",
2748
+ ...scroll
2749
+ })),
2750
+ z.strictObject({ expect: expectCommandSchema.omit(STEP_WITHOUT_COMMAND_FIELDS).strict() }).transform(({ expect }) => ({
2751
+ commandName: "expect",
2752
+ ...expect
2753
+ })),
2754
+ z.strictObject({ check: z.null() }).transform(() => ({ commandName: "check" })),
2755
+ z.strictObject({ shot: z.null() }).transform(() => ({ commandName: "shot" }))
2756
+ ]);
2757
+ const flowSchema = z.strictObject({
2758
+ name: z.string(),
2759
+ devices: z.array(z.string()).min(1).nullable().default(null),
2760
+ steps: z.array(flowStepSchema)
2761
+ });
2762
+ z.number().int().min(1);
2763
+ // src/flows/flow-runner.ts
2764
+ const listFlowPaths = async (rootFolder, flowsGlob) => {
2765
+ const flowPaths = [];
2766
+ for await (const flowPath of glob(flowsGlob, { cwd: rootFolder })) flowPaths.push(flowPath);
2767
+ return flowPaths.toSorted();
2768
+ };
2769
+ const readFlow = async (rootFolder, flowPath) => {
2770
+ const flowYaml = await readFile(join(rootFolder, flowPath), "utf8");
2771
+ const flowDocument = parse(flowYaml);
2772
+ const flowCheck = flowSchema.safeParse(flowDocument);
2773
+ if (flowCheck.success) return flowCheck.data;
2774
+ const [firstIssue] = flowCheck.error.issues;
2775
+ const badField = firstIssue.path.join(".");
2776
+ throw new Error(`${flowPath} is not a valid flow: ${badField !== "" ? badField : "file"}: ${firstIssue.message}`, { cause: flowCheck.error });
2777
+ };
2778
+ const runFlow = async (rootFolder, flowPath) => {
2779
+ const flow = await readFlow(rootFolder, flowPath);
2780
+ const stepReplies = [];
2781
+ for (const hubCommand of listFlowCommands(flow)) {
2782
+ const stepReply = await sendHubCommand(rootFolder, hubCommand);
2783
+ stepReplies.push(stepReply);
2784
+ if (stepReply.status === "failed") break;
2785
+ }
2786
+ return createFlowAnswer(flowPath, stepReplies);
2787
+ };
2788
+ const runFlowOrFail = async (rootFolder, flowPath) => {
2789
+ try {
2790
+ return await runFlow(rootFolder, flowPath);
2791
+ } catch (error) {
2792
+ const problemLine = error instanceof Error ? error.message : "the flow did not run";
2793
+ return createFailedFlowAnswer(flowPath, problemLine);
2794
+ }
2795
+ };
2796
+ const runFlows = async (rootFolder, flowPaths, parallel) => {
2797
+ const flowAnswers = [];
2798
+ for (const flowBatch of listFlowBatches(flowPaths, parallel)) {
2799
+ const batchAnswers = await Promise.all(flowBatch.map((flowPath) => runFlowOrFail(rootFolder, flowPath)));
2800
+ flowAnswers.push(...batchAnswers);
2801
+ }
2802
+ return flowAnswers;
2803
+ };
2804
+ // src/skill/skill-install.ts
2805
+ const SKILL_FOLDERS = {
2806
+ claude: ".claude/skills/everyscreen",
2807
+ codex: ".agents/skills/everyscreen",
2808
+ cursor: ".cursor/skills/everyscreen"
2809
+ };
2810
+ const SKILL_FILE_URLS = [new URL("./SKILL.md", import.meta.url), new URL("../src/skill/SKILL.md", import.meta.url)];
2811
+ const readSkillMarkdown = async () => {
2812
+ for (const skillFileUrl of SKILL_FILE_URLS) {
2813
+ const skillPath = fileURLToPath(skillFileUrl);
2814
+ const skillMarkdown = await readStoredFile(skillPath);
2815
+ if (skillMarkdown !== null) return skillMarkdown;
2816
+ }
2817
+ throw new Error("SKILL.md is missing from the Everyscreen package");
2818
+ };
2819
+ const pickSkillFile = (agent, rootFolder) => join(rootFolder, SKILL_FOLDERS[agent], "SKILL.md");
2820
+ const installSkill = async (agent, rootFolder) => {
2821
+ const installedFile = pickSkillFile(agent, rootFolder);
2822
+ const skillMarkdown = await readSkillMarkdown();
2823
+ await writeStoredFile(installedFile, skillMarkdown);
2824
+ return installedFile;
2825
+ };
2826
+ // src/cli/cli-requests.ts
2827
+ const listHubDevices = (rootFolder, createDevicesReply) => callHub(rootFolder, async (hubClient, hubUrl) => {
2828
+ const devicesReply = await hubClient.api.devices.$get();
2829
+ if (!devicesReply.ok) return readProblemReply(devicesReply);
2830
+ return createDevicesReply(await devicesReply.json(), hubUrl);
2831
+ });
2832
+ const showHubStatus = (rootFolder) => listHubDevices(rootFolder, (hubDevices, hubUrl) => {
2833
+ const runningLine = createRunningLine(hubUrl, hubDevices.length);
2834
+ return createReply("ok", runningLine);
2835
+ });
2836
+ const listDevices = (rootFolder) => listHubDevices(rootFolder, (hubDevices) => {
2837
+ const deviceIds = hubDevices.map(({ id }) => id);
2838
+ const deviceListLine = createDeviceListLine(deviceIds);
2839
+ return createReply("ok", deviceListLine);
2840
+ });
2841
+ const addDevice = (rootFolder, customDevice) => callHub(rootFolder, async (hubClient) => {
2842
+ const addReply = await hubClient.api.devices.$post({ json: customDevice });
2843
+ if (!addReply.ok) return readProblemReply(addReply);
2844
+ const devicePreset = await addReply.json();
2845
+ return createReply("ok", `device added · ${devicePreset.id}`);
2846
+ });
2847
+ const removeDevice = (rootFolder, deviceId) => callHub(rootFolder, async (hubClient) => {
2848
+ const removeReply = await hubClient.api.devices[":deviceId"].$delete({ param: { deviceId } });
2849
+ if (!removeReply.ok) return readProblemReply(removeReply);
2850
+ return createReply("ok", `device removed · ${deviceId}`);
2851
+ });
2852
+ const runFlowFiles = async (rootFolder, flowsGlob, parallel) => {
2853
+ const flowPaths = await listFlowPaths(rootFolder, flowsGlob);
2854
+ if (flowPaths.length === 0) throw new HTTPException(400, { message: `no flow file matches ${flowsGlob}` });
2855
+ if (await readHubFile(rootFolder) === null) return createHubDownReply();
2856
+ const flowAnswers = await runFlows(rootFolder, flowPaths, parallel);
2857
+ return summarizeFlows(flowAnswers);
2858
+ };
2859
+ const readBeforeSteps = async (rootFolder, beforeFlowPath) => {
2860
+ try {
2861
+ const beforeFlow = await readFlow(rootFolder, beforeFlowPath);
2862
+ return listFlowCommands(beforeFlow);
2863
+ } catch (error) {
2864
+ const errorLine = pickErrorMessage(error);
2865
+ return createReply("failed", `before flow: ${errorLine}`);
2866
+ }
2867
+ };
2868
+ const sweepApp = async (rootFolder, sweepCommand, beforeFlowPath) => {
2869
+ if (beforeFlowPath === null) return sendHubCommand(rootFolder, sweepCommand);
2870
+ const beforeStepsReply = await readBeforeSteps(rootFolder, beforeFlowPath);
2871
+ if (!Array.isArray(beforeStepsReply)) return beforeStepsReply;
2872
+ return sendHubCommand(rootFolder, {
2873
+ ...sweepCommand,
2874
+ beforeSteps: beforeStepsReply
2875
+ });
2876
+ };
2877
+ const installAgentSkill = async (rootFolder, agent, isConfirmed) => {
2878
+ const skillFile = pickSkillFile(agent, rootFolder);
2879
+ if (await readStoredFile(skillFile) !== null && !isConfirmed) throw new HTTPException(400, { message: "skill install needs --yes to replace the skill already installed" });
2880
+ const installedFile = await installSkill(agent, rootFolder);
2881
+ return createReply("ok", `skill installed · ${installedFile}`);
2882
+ };
2883
+ const runCliRequest = (cliRequest, rootFolder, writeStartedReply) => {
2884
+ switch (cliRequest.kind) {
2885
+ case "send-command": return sendHubCommand(rootFolder, cliRequest.hubCommand);
2886
+ case "start": {
2887
+ const { port, isForeground } = cliRequest;
2888
+ if (isForeground) return startHubInForeground(rootFolder, port, writeStartedReply);
2889
+ return startHubInBackground(rootFolder, port);
2890
+ }
2891
+ case "stop": return stopHub(rootFolder);
2892
+ case "status": return showHubStatus(rootFolder);
2893
+ case "list-devices": return listDevices(rootFolder);
2894
+ case "add-device": return addDevice(rootFolder, cliRequest.customDevice);
2895
+ case "remove-device": return removeDevice(rootFolder, cliRequest.deviceId);
2896
+ case "run-flows": return runFlowFiles(rootFolder, cliRequest.flowsGlob, cliRequest.parallel);
2897
+ case "run-sweep": return sweepApp(rootFolder, cliRequest.sweepCommand, cliRequest.beforeFlowPath);
2898
+ case "install-skill": return installAgentSkill(rootFolder, cliRequest.agent, cliRequest.isConfirmed);
2899
+ }
2900
+ };
2901
+ // src/cli/cli.ts
2902
+ const EXIT_CODES = {
2903
+ ok: 0,
2904
+ failed: 1,
2905
+ badUsage: 2
2906
+ };
2907
+ const runPlan = async (cliPlan, isJsonWanted) => {
2908
+ switch (cliPlan.kind) {
2909
+ case "show-help": {
2910
+ const helpReply = createReply("ok", cliPlan.helpPage);
2911
+ writeReply(helpReply, isJsonWanted);
2912
+ return EXIT_CODES.ok;
2913
+ }
2914
+ case "bad-usage": {
2915
+ const badUsageReply = createReply("failed", cliPlan.message);
2916
+ writeReply(badUsageReply, isJsonWanted);
2917
+ return EXIT_CODES.badUsage;
2918
+ }
2919
+ case "run": {
2920
+ const writeStartedReply = (startedReply) => writeReply(startedReply, isJsonWanted);
2921
+ const commandReply = await runCliRequest(cliPlan.cliRequest, process.cwd(), writeStartedReply);
2922
+ const isTerminal = process.stdout.isTTY === true;
2923
+ if (canWriteFinalReply(cliPlan.cliRequest, isJsonWanted, isTerminal)) writeReply(commandReply, isJsonWanted);
2924
+ return EXIT_CODES[commandReply.status];
2925
+ }
2926
+ }
2927
+ };
2928
+ const runCli = async () => {
2929
+ const commandLineWords = process.argv.slice(2);
2930
+ const isJsonWanted = commandLineWords.includes("--json");
2931
+ const commandLine = readCommandLine(commandLineWords);
2932
+ if (commandLine.kind === "unreadable") {
2933
+ const unreadableReply = createReply("failed", commandLine.message);
2934
+ writeReply(unreadableReply, isJsonWanted);
2935
+ process.exitCode = EXIT_CODES.badUsage;
2936
+ return;
2937
+ }
2938
+ const isTerminal = process.stdout.isTTY === true;
2939
+ const cliPlan = planCli(commandLine.commandWords, commandLine.flags, isTerminal);
2940
+ try {
2941
+ process.exitCode = await runPlan(cliPlan, isJsonWanted);
2942
+ } catch (error) {
2943
+ const badUsageDetail = pickBadUsageDetail(error);
2944
+ if (badUsageDetail !== null) {
2945
+ const badUsageReply = createReply("failed", createBadUsageLine(badUsageDetail));
2946
+ writeReply(badUsageReply, isJsonWanted);
2947
+ process.exitCode = EXIT_CODES.badUsage;
2948
+ return;
2949
+ }
2950
+ console.error({ error });
2951
+ const errorLine = error instanceof Error ? error.message : "unexpected error";
2952
+ const errorReply = createReply("failed", errorLine);
2953
+ writeReply(errorReply, isJsonWanted);
2954
+ process.exitCode = EXIT_CODES.failed;
2955
+ }
2956
+ };
2957
+ await runCli();
2958
+ export {};