@flrande/browserctl 0.3.0 → 0.4.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.
@@ -55,6 +55,33 @@ describe("cli", () => {
55
55
  });
56
56
  });
57
57
 
58
+ it("parses --help as a global help request", () => {
59
+ expect(parseArgs(["--help"])).toEqual({
60
+ command: null,
61
+ commandArgs: [],
62
+ json: false,
63
+ helpTopic: "all"
64
+ });
65
+ });
66
+
67
+ it("parses -h as a global help request", () => {
68
+ expect(parseArgs(["-h"])).toEqual({
69
+ command: null,
70
+ commandArgs: [],
71
+ json: false,
72
+ helpTopic: "all"
73
+ });
74
+ });
75
+
76
+ it("parses command --help as command help request", () => {
77
+ expect(parseArgs(["status", "--help"])).toEqual({
78
+ command: null,
79
+ commandArgs: [],
80
+ json: false,
81
+ helpTopic: "status"
82
+ });
83
+ });
84
+
58
85
  it("writes JSON output on successful run", async () => {
59
86
  vi.spyOn(statusCommand, "runStatusCommand").mockResolvedValue({
60
87
  kind: "browserd",
@@ -86,6 +113,68 @@ describe("cli", () => {
86
113
  expect(state.stderr).toContain("Unknown command: unknown");
87
114
  });
88
115
 
116
+ it("writes general help output for --help", async () => {
117
+ const { io, state } = createIoCapture();
118
+
119
+ const exitCode = await runCli(["--help"], io);
120
+
121
+ expect(exitCode).toBe(EXIT_CODES.OK);
122
+ expect(state.stderr).toBe("");
123
+ expect(state.stdout).toContain("Usage:");
124
+ expect(state.stdout).toContain("browserctl help [command]");
125
+ });
126
+
127
+ it("writes JSON help output when --json is provided", async () => {
128
+ const { io, state } = createIoCapture();
129
+
130
+ const exitCode = await runCli(["--json", "--help"], io);
131
+
132
+ expect(exitCode).toBe(EXIT_CODES.OK);
133
+ expect(state.stderr).toBe("");
134
+ expect(JSON.parse(state.stdout)).toEqual(
135
+ expect.objectContaining({
136
+ ok: true,
137
+ command: "help",
138
+ data: expect.objectContaining({
139
+ topic: "all",
140
+ text: expect.stringContaining("Usage:")
141
+ })
142
+ })
143
+ );
144
+ });
145
+
146
+ it("writes command help output for help <command>", async () => {
147
+ const { io, state } = createIoCapture();
148
+
149
+ const exitCode = await runCli(["help", "status"], io);
150
+
151
+ expect(exitCode).toBe(EXIT_CODES.OK);
152
+ expect(state.stderr).toBe("");
153
+ expect(state.stdout).toContain("Usage:");
154
+ expect(state.stdout).toContain("browserctl status");
155
+ });
156
+
157
+ it("writes command help output for <command> --help", async () => {
158
+ const { io, state } = createIoCapture();
159
+
160
+ const exitCode = await runCli(["tab-open", "--help"], io);
161
+
162
+ expect(exitCode).toBe(EXIT_CODES.OK);
163
+ expect(state.stderr).toBe("");
164
+ expect(state.stdout).toContain("Usage:");
165
+ expect(state.stdout).toContain("browserctl tab-open <url>");
166
+ });
167
+
168
+ it("returns INVALID_ARGS for unknown help topic", async () => {
169
+ const { io, state } = createIoCapture();
170
+
171
+ const exitCode = await runCli(["help", "unknown"], io);
172
+
173
+ expect(exitCode).toBe(EXIT_CODES.INVALID_ARGS);
174
+ expect(state.stdout).toBe("");
175
+ expect(state.stderr).toContain("Unknown help topic: unknown");
176
+ });
177
+
89
178
  it("writes non-JSON output when --json is not provided", async () => {
90
179
  vi.spyOn(tabsCommand, "runTabsCommand").mockResolvedValue({
91
180
  driver: "managed",
@@ -74,9 +74,12 @@ export interface ParsedArgs {
74
74
  command: CommandName | null;
75
75
  commandArgs: string[];
76
76
  json: boolean;
77
+ helpTopic?: HelpTopic;
77
78
  error?: string;
78
79
  }
79
80
 
81
+ export type HelpTopic = "all" | CommandName;
82
+
80
83
  const VALID_COMMANDS: ReadonlySet<CommandName> = new Set([
81
84
  "status",
82
85
  "tabs",
@@ -111,6 +114,177 @@ const VALID_COMMANDS: ReadonlySet<CommandName> = new Set([
111
114
  "daemon-stop"
112
115
  ]);
113
116
 
117
+ interface CommandHelpEntry {
118
+ usage: string;
119
+ description: string;
120
+ }
121
+
122
+ const COMMAND_HELP: Readonly<Record<CommandName, CommandHelpEntry>> = {
123
+ status: {
124
+ usage: "status",
125
+ description: "Query daemon and active profile readiness."
126
+ },
127
+ tabs: {
128
+ usage: "tabs",
129
+ description: "List tabs for the current session."
130
+ },
131
+ "profile-list": {
132
+ usage: "profile-list",
133
+ description: "List available driver profiles."
134
+ },
135
+ "profile-use": {
136
+ usage: "profile-use <driverKey>",
137
+ description: "Bind session to a driver profile."
138
+ },
139
+ "tab-open": {
140
+ usage: "tab-open <url>",
141
+ description: "Open a new tab with the given URL."
142
+ },
143
+ "tab-focus": {
144
+ usage: "tab-focus <targetId>",
145
+ description: "Activate an existing tab target."
146
+ },
147
+ "tab-close": {
148
+ usage: "tab-close <targetId>",
149
+ description: "Close a tab target."
150
+ },
151
+ snapshot: {
152
+ usage: "snapshot <targetId>",
153
+ description: "Get structured DOM snapshot for a tab."
154
+ },
155
+ screenshot: {
156
+ usage: "screenshot <targetId>",
157
+ description: "Capture full-page screenshot for a tab."
158
+ },
159
+ "dom-query": {
160
+ usage: "dom-query <targetId> <selector>",
161
+ description: "Get first matching element details."
162
+ },
163
+ "dom-query-all": {
164
+ usage: "dom-query-all <targetId> <selector>",
165
+ description: "List all matching elements."
166
+ },
167
+ "element-screenshot": {
168
+ usage: "element-screenshot <targetId> <selector>",
169
+ description: "Capture screenshot for one element."
170
+ },
171
+ "a11y-snapshot": {
172
+ usage: "a11y-snapshot <targetId> [selector]",
173
+ description: "Get accessibility tree snapshot."
174
+ },
175
+ "network-wait-for": {
176
+ usage:
177
+ "network-wait-for <targetId> <urlPattern> [--method <METHOD>] [--status <CODE>] [--timeout-ms <ms>] [--poll-ms <ms>]",
178
+ description: "Wait until a matching network response appears."
179
+ },
180
+ "cookie-get": {
181
+ usage: "cookie-get <targetId> [name]",
182
+ description: "Read cookie(s) from page context."
183
+ },
184
+ "cookie-set": {
185
+ usage: "cookie-set <targetId> <name> <value> [url]",
186
+ description: "Set one cookie in page context."
187
+ },
188
+ "cookie-clear": {
189
+ usage: "cookie-clear <targetId> [name]",
190
+ description: "Clear one or all cookies."
191
+ },
192
+ "storage-get": {
193
+ usage: "storage-get <targetId> <local|session> <key>",
194
+ description: "Read a storage value."
195
+ },
196
+ "storage-set": {
197
+ usage: "storage-set <targetId> <local|session> <key> <value>",
198
+ description: "Write a storage value."
199
+ },
200
+ "frame-list": {
201
+ usage: "frame-list <targetId>",
202
+ description: "List frames for a tab."
203
+ },
204
+ "frame-snapshot": {
205
+ usage: "frame-snapshot <targetId> <frameId>",
206
+ description: "Snapshot one frame subtree."
207
+ },
208
+ act: {
209
+ usage: "act <actionType> <targetId>",
210
+ description: "Execute high-level action against a tab."
211
+ },
212
+ "upload-arm": {
213
+ usage: "upload-arm <targetId> <file1> [file2 ...]",
214
+ description: "Prepare file chooser upload paths."
215
+ },
216
+ "dialog-arm": {
217
+ usage: "dialog-arm <targetId>",
218
+ description: "Prepare next JS dialog interception."
219
+ },
220
+ "download-trigger": {
221
+ usage: "download-trigger <targetId>",
222
+ description: "Trigger download capture mode."
223
+ },
224
+ "download-wait": {
225
+ usage: "download-wait <targetId> [path]",
226
+ description: "Wait for next download and persist file."
227
+ },
228
+ "console-list": {
229
+ usage: "console-list <targetId>",
230
+ description: "List collected console events."
231
+ },
232
+ "response-body": {
233
+ usage: "response-body <targetId> <requestId>",
234
+ description: "Read response body for a captured request."
235
+ },
236
+ "daemon-status": {
237
+ usage: "daemon-status",
238
+ description: "Inspect local daemon process status."
239
+ },
240
+ "daemon-start": {
241
+ usage: "daemon-start [--browser <chromium|chrome|edge>]",
242
+ description: "Start daemon if needed and report runtime."
243
+ },
244
+ "daemon-stop": {
245
+ usage: "daemon-stop",
246
+ description: "Stop local daemon process."
247
+ }
248
+ };
249
+
250
+ const COMMAND_GROUPS: ReadonlyArray<{ title: string; commands: readonly CommandName[] }> = [
251
+ {
252
+ title: "Daemon lifecycle",
253
+ commands: ["daemon-start", "daemon-status", "daemon-stop"]
254
+ },
255
+ {
256
+ title: "Driver/session",
257
+ commands: ["status", "profile-list", "profile-use", "tabs"]
258
+ },
259
+ {
260
+ title: "Tab/page",
261
+ commands: ["tab-open", "tab-focus", "tab-close", "snapshot", "screenshot"]
262
+ },
263
+ {
264
+ title: "Structured reads",
265
+ commands: [
266
+ "dom-query",
267
+ "dom-query-all",
268
+ "element-screenshot",
269
+ "a11y-snapshot",
270
+ "network-wait-for",
271
+ "cookie-get",
272
+ "cookie-set",
273
+ "cookie-clear",
274
+ "storage-get",
275
+ "storage-set",
276
+ "frame-list",
277
+ "frame-snapshot",
278
+ "console-list",
279
+ "response-body"
280
+ ]
281
+ },
282
+ {
283
+ title: "Action/file flow",
284
+ commands: ["act", "upload-arm", "dialog-arm", "download-trigger", "download-wait"]
285
+ }
286
+ ];
287
+
114
288
  const CHROME_RELAY_FAILURE_GUIDANCE = [
115
289
  "chrome-relay 连接失败,请先完成以下检查:",
116
290
  "1) 扩展 relay 方案:设置 BROWSERD_CHROME_RELAY_MODE=extension。",
@@ -120,9 +294,89 @@ const CHROME_RELAY_FAILURE_GUIDANCE = [
120
294
  "5) 确认 BROWSERD_CHROME_RELAY_URL 指向可访问地址(默认 http://127.0.0.1:9223)。"
121
295
  ].join("\n");
122
296
 
297
+ function isHelpFlag(value: string): boolean {
298
+ return value === "--help" || value === "-h";
299
+ }
300
+
301
+ function formatGeneralHelp(): string {
302
+ const lines = [
303
+ "browserctl - BrowserCtl command line client",
304
+ "",
305
+ "Usage:",
306
+ " browserctl [--json] <command> [command-options] [positionals]",
307
+ " browserctl help [command]",
308
+ " browserctl <command> --help",
309
+ "",
310
+ "Global options:",
311
+ " --json Print success/error envelopes as JSON.",
312
+ " --help, -h Show general help or command help.",
313
+ "",
314
+ "Context options (browser commands):",
315
+ " --session <id> Session namespace (default: cli:local).",
316
+ " --profile <driverKey> Driver override.",
317
+ " --token <authToken> Request auth token override.",
318
+ " --browser <preset> Daemon startup browser preset (daemon-start only).",
319
+ "",
320
+ "Commands:"
321
+ ];
322
+
323
+ for (const group of COMMAND_GROUPS) {
324
+ lines.push(` ${group.title}:`);
325
+ for (const command of group.commands) {
326
+ const entry = COMMAND_HELP[command];
327
+ lines.push(` ${entry.usage}`);
328
+ lines.push(` ${entry.description}`);
329
+ }
330
+ }
331
+
332
+ lines.push("");
333
+ lines.push("Run \"browserctl help <command>\" for command-specific usage.");
334
+
335
+ return `${lines.join("\n")}\n`;
336
+ }
337
+
338
+ function formatCommandHelp(command: CommandName): string {
339
+ const entry = COMMAND_HELP[command];
340
+ const lines = [`Command: ${command}`, "", "Usage:", ` browserctl ${entry.usage}`, "", entry.description];
341
+ const isDaemonCommand = command.startsWith("daemon-");
342
+
343
+ if (!isDaemonCommand) {
344
+ lines.push("");
345
+ lines.push("Context options:");
346
+ lines.push(" --session <id> Session namespace (default: cli:local).");
347
+ lines.push(" --profile <driverKey> Driver override.");
348
+ lines.push(" --token <authToken> Request auth token override.");
349
+ } else if (command === "daemon-start") {
350
+ lines.push("");
351
+ lines.push("Command options:");
352
+ lines.push(" --browser <chromium|chrome|edge> managed-local browser preset.");
353
+ lines.push(" --token <authToken> Request auth token override.");
354
+ } else if (command === "daemon-status") {
355
+ lines.push("");
356
+ lines.push("Command options:");
357
+ lines.push(" --token <authToken> Request auth token override.");
358
+ }
359
+
360
+ lines.push("");
361
+ lines.push("Global options:");
362
+ lines.push(" --json");
363
+ lines.push(" --help, -h");
364
+
365
+ return `${lines.join("\n")}\n`;
366
+ }
367
+
368
+ function formatHelp(topic: HelpTopic): string {
369
+ if (topic === "all") {
370
+ return formatGeneralHelp();
371
+ }
372
+
373
+ return formatCommandHelp(topic);
374
+ }
375
+
123
376
  export function parseArgs(argv: string[]): ParsedArgs {
124
- const positional = [];
377
+ const positional: string[] = [];
125
378
  let json = false;
379
+ let helpRequested = false;
126
380
 
127
381
  for (const value of argv) {
128
382
  if (value === "--json") {
@@ -130,11 +384,25 @@ export function parseArgs(argv: string[]): ParsedArgs {
130
384
  continue;
131
385
  }
132
386
 
387
+ if (isHelpFlag(value)) {
388
+ helpRequested = true;
389
+ continue;
390
+ }
391
+
133
392
  positional.push(value);
134
393
  }
135
394
 
136
395
  const [commandToken, ...commandArgs] = positional;
137
396
  if (commandToken === undefined) {
397
+ if (helpRequested) {
398
+ return {
399
+ command: null,
400
+ commandArgs: [],
401
+ json,
402
+ helpTopic: "all"
403
+ };
404
+ }
405
+
138
406
  return {
139
407
  command: null,
140
408
  commandArgs: [],
@@ -143,6 +411,61 @@ export function parseArgs(argv: string[]): ParsedArgs {
143
411
  };
144
412
  }
145
413
 
414
+ if (commandToken === "help") {
415
+ if (commandArgs.length === 0) {
416
+ return {
417
+ command: null,
418
+ commandArgs: [],
419
+ json,
420
+ helpTopic: "all"
421
+ };
422
+ }
423
+
424
+ if (commandArgs.length > 1) {
425
+ return {
426
+ command: null,
427
+ commandArgs: [],
428
+ json,
429
+ error: "Too many arguments for help."
430
+ };
431
+ }
432
+
433
+ const topic = commandArgs[0];
434
+ if (!VALID_COMMANDS.has(topic as CommandName)) {
435
+ return {
436
+ command: null,
437
+ commandArgs: [],
438
+ json,
439
+ error: `Unknown help topic: ${topic}`
440
+ };
441
+ }
442
+
443
+ return {
444
+ command: null,
445
+ commandArgs: [],
446
+ json,
447
+ helpTopic: topic as CommandName
448
+ };
449
+ }
450
+
451
+ if (helpRequested) {
452
+ if (!VALID_COMMANDS.has(commandToken as CommandName)) {
453
+ return {
454
+ command: null,
455
+ commandArgs: [],
456
+ json,
457
+ error: `Unknown command: ${commandToken}`
458
+ };
459
+ }
460
+
461
+ return {
462
+ command: null,
463
+ commandArgs: [],
464
+ json,
465
+ helpTopic: commandToken as CommandName
466
+ };
467
+ }
468
+
146
469
  if (!VALID_COMMANDS.has(commandToken as CommandName)) {
147
470
  return {
148
471
  command: null,
@@ -327,6 +650,26 @@ export async function runCli(
327
650
  return EXIT_CODES.INVALID_ARGS;
328
651
  }
329
652
 
653
+ if (parsedArgs.helpTopic !== undefined) {
654
+ const helpText = formatHelp(parsedArgs.helpTopic);
655
+ if (parsedArgs.json) {
656
+ io.stdout.write(
657
+ `${JSON.stringify({
658
+ ok: true,
659
+ command: "help",
660
+ data: {
661
+ topic: parsedArgs.helpTopic,
662
+ text: helpText.trimEnd()
663
+ }
664
+ })}\n`
665
+ );
666
+ } else {
667
+ io.stdout.write(helpText);
668
+ }
669
+
670
+ return EXIT_CODES.OK;
671
+ }
672
+
330
673
  try {
331
674
  const data = await executeCommand(parsedArgs.command, parsedArgs.commandArgs);
332
675
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flrande/browserctl",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "bin": {
6
6
  "browserctl": "bin/browserctl.cjs",