@guanzhu.me/pw-cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/pw-cli.js ADDED
@@ -0,0 +1,870 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ process.on('uncaughtException', err => {
5
+ process.stderr.write(`pw-cli fatal: ${err.message}\n`);
6
+ process.exit(1);
7
+ });
8
+ process.on('unhandledRejection', err => {
9
+ process.stderr.write(`pw-cli fatal: ${err && err.message || err}\n`);
10
+ process.exit(1);
11
+ });
12
+
13
+ const os = require('os');
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+ const net = require('net');
17
+ const crypto = require('crypto');
18
+ const { execSync } = require('child_process');
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Global session setup — makes playwright-cli always find the same session
22
+ // regardless of which directory pw-cli is run from.
23
+ // ---------------------------------------------------------------------------
24
+ const HOME_DIR = os.homedir();
25
+ const PW_CLI_DIR = path.join(HOME_DIR, '.pw-cli');
26
+ const DEFAULT_PROFILE = path.join(PW_CLI_DIR, 'profiles', 'default');
27
+ const PLAYWRIGHT_MARKER = path.join(PW_CLI_DIR, '.playwright'); // makes cwd a "workspace"
28
+
29
+ fs.mkdirSync(DEFAULT_PROFILE, { recursive: true });
30
+ fs.mkdirSync(PLAYWRIGHT_MARKER, { recursive: true });
31
+
32
+ // Compute session hash the same way playwright-cli does:
33
+ // sha1(workspaceDir).hex[:16] where workspaceDir = PW_CLI_DIR
34
+ const WORKSPACE_HASH = crypto.createHash('sha1')
35
+ .update(PW_CLI_DIR)
36
+ .digest('hex')
37
+ .substring(0, 16);
38
+
39
+ // Locate @playwright/cli via npm global root
40
+ function findPlaywrightCli() {
41
+ try {
42
+ const globalRoot = execSync('npm root -g', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
43
+ const cliEntry = path.join(globalRoot, '@playwright', 'cli', 'node_modules', 'playwright', 'lib', 'cli', 'client', 'program.js');
44
+ if (fs.existsSync(cliEntry)) return cliEntry;
45
+ } catch { /* ignore */ }
46
+ return null;
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Daemon session file location (Windows / macOS / Linux)
51
+ // ---------------------------------------------------------------------------
52
+ function getDaemonDir() {
53
+ if (process.platform === 'win32') {
54
+ return path.join(process.env.LOCALAPPDATA || path.join(HOME_DIR, 'AppData', 'Local'), 'ms-playwright', 'daemon');
55
+ } else if (process.platform === 'darwin') {
56
+ return path.join(HOME_DIR, 'Library', 'Caches', 'ms-playwright', 'daemon');
57
+ }
58
+ return path.join(process.env.XDG_CACHE_HOME || path.join(HOME_DIR, '.cache'), 'ms-playwright', 'daemon');
59
+ }
60
+
61
+ function isSessionFilePresent(sessionName = 'default') {
62
+ const sessionFile = path.join(getDaemonDir(), WORKSPACE_HASH, `${sessionName}.session`);
63
+ return fs.existsSync(sessionFile);
64
+ }
65
+
66
+ async function isSessionAlive(sessionName = 'default') {
67
+ if (!isSessionFilePresent(sessionName)) return false;
68
+ // probe the named pipe / socket
69
+ const socketPath = process.platform === 'win32'
70
+ ? `\\\\.\\pipe\\${WORKSPACE_HASH}-${sessionName}.sock`
71
+ : path.join(process.env.PLAYWRIGHT_DAEMON_SOCKETS_DIR || path.join(os.tmpdir(), 'playwright-cli'), WORKSPACE_HASH, `${sessionName}.sock`);
72
+ return new Promise(resolve => {
73
+ const s = net.connect(socketPath);
74
+ s.once('connect', () => { s.destroy(); resolve(true); });
75
+ s.once('error', () => resolve(false));
76
+ setTimeout(() => { s.destroy(); resolve(false); }, 1500);
77
+ });
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Argv helpers
82
+ // ---------------------------------------------------------------------------
83
+ function hasFlag(argv, ...flags) {
84
+ return flags.some(f => argv.includes(f));
85
+ }
86
+
87
+ function injectOpenDefaults(argv) {
88
+ const result = [...argv];
89
+ // inject --headed unless explicitly suppressed
90
+ if (!hasFlag(result, '--headed', '--no-headed')) {
91
+ result.push('--headed');
92
+ }
93
+ // inject persistent + default profile unless user specified their own
94
+ if (!hasFlag(result, '--persistent', '--no-persistent', '--profile')) {
95
+ result.push('--persistent', '--profile', DEFAULT_PROFILE);
96
+ } else if (hasFlag(result, '--profile') && !hasFlag(result, '--persistent', '--no-persistent')) {
97
+ result.push('--persistent');
98
+ }
99
+ return result;
100
+ }
101
+
102
+ function getCommandAndSession(argv) {
103
+ // find session flag
104
+ let session = 'default';
105
+ const sIdx = argv.findIndex(a => a === '-s' || a === '--session');
106
+ if (sIdx !== -1 && argv[sIdx + 1]) session = argv[sIdx + 1];
107
+ const sEq = argv.find(a => a.startsWith('-s=') || a.startsWith('--session='));
108
+ if (sEq) session = sEq.split('=')[1];
109
+
110
+ // first non-flag positional is the command
111
+ const command = argv.find(a => !a.startsWith('-') && !/^\d/.test(a));
112
+ return { command, session };
113
+ }
114
+
115
+ function parsePwCliGlobalOptions(argv) {
116
+ const options = { headless: false, profile: 'default', port: 9223 };
117
+
118
+ for (let i = 0; i < argv.length; i++) {
119
+ const arg = argv[i];
120
+ if (arg === '--headless') {
121
+ options.headless = true;
122
+ } else if (arg === '--profile' && argv[i + 1]) {
123
+ options.profile = argv[++i];
124
+ } else if (arg === '--port' && argv[i + 1]) {
125
+ options.port = parseInt(argv[++i], 10);
126
+ }
127
+ }
128
+
129
+ return options;
130
+ }
131
+
132
+ function printMainHelp() {
133
+ process.stdout.write(`pw-cli - run Playwright terminal commands with pw-cli enhancements
134
+
135
+ Usage: pw-cli <command> [args] [options]
136
+ Usage: pw-cli -s=<session> <command> [args] [options]
137
+
138
+ pw-cli wraps @playwright/cli and keeps its command model. It also adds:
139
+ - headed + persistent defaults for browser open flows
140
+ - auto-open when a browser-backed command needs a session
141
+ - run-code from stdin or inline JavaScript
142
+ - run-script for local .js files
143
+ - queue subcommands for batching commands
144
+ - XPath support for click/dblclick/hover/fill/check/uncheck/select/drag
145
+
146
+ Core:
147
+ open [url] open the browser
148
+ pw-cli: injects headed + persistent defaults
149
+ pw-cli: if url is provided, opens first and then navigates
150
+ close close the browser
151
+ goto <url> navigate to a url
152
+ type <text> type text into editable element
153
+ click <ref> [button] perform click on a web page
154
+ pw-cli: accepts XPath ref
155
+ dblclick <ref> [button] perform double click on a web page
156
+ pw-cli: accepts XPath ref
157
+ fill <ref> <text> fill text into editable element
158
+ pw-cli: accepts XPath ref
159
+ drag <startRef> <endRef> perform drag and drop between two elements
160
+ pw-cli: accepts XPath refs
161
+ hover <ref> hover over element on page
162
+ pw-cli: accepts XPath ref
163
+ select <ref> <val> select an option in a dropdown
164
+ pw-cli: accepts XPath ref
165
+ upload <file> upload one or multiple files
166
+ check <ref> check a checkbox or radio button
167
+ pw-cli: accepts XPath ref
168
+ uncheck <ref> uncheck a checkbox or radio button
169
+ pw-cli: accepts XPath ref
170
+ snapshot capture page snapshot to obtain element ref
171
+ eval <func> [ref] evaluate javascript expression on page or element
172
+ dialog-accept [prompt] accept a dialog
173
+ dialog-dismiss dismiss a dialog
174
+ resize <w> <h> resize the browser window
175
+ delete-data delete session data
176
+
177
+ Navigation:
178
+ go-back go back to the previous page
179
+ go-forward go forward to the next page
180
+ reload reload the current page
181
+
182
+ Keyboard:
183
+ press <key> press a key on the keyboard, \`a\`, \`arrowleft\`
184
+ keydown <key> press a key down on the keyboard
185
+ keyup <key> press a key up on the keyboard
186
+
187
+ Mouse:
188
+ mousemove <x> <y> move mouse to a given position
189
+ mousedown [button] press mouse down
190
+ mouseup [button] press mouse up
191
+ mousewheel <dx> <dy> scroll mouse wheel
192
+
193
+ Save as:
194
+ screenshot [ref] screenshot of the current page or element
195
+ pdf save page as pdf
196
+
197
+ Tabs:
198
+ tab-list list all tabs
199
+ tab-new [url] create a new tab
200
+ tab-close [index] close a browser tab
201
+ tab-select <index> select a browser tab
202
+
203
+ Storage:
204
+ state-load <filename> loads browser storage (authentication) state from a file
205
+ state-save [filename] saves the current storage (authentication) state to a file
206
+ cookie-list list all cookies (optionally filtered by domain/path)
207
+ cookie-get <name> get a specific cookie by name
208
+ cookie-set <name> <value> set a cookie with optional flags
209
+ cookie-delete <name> delete a specific cookie
210
+ cookie-clear clear all cookies
211
+ localstorage-list list all localstorage key-value pairs
212
+ localstorage-get <key> get a localstorage item by key
213
+ localstorage-set <key> <value> set a localstorage item
214
+ localstorage-delete <key> delete a localstorage item
215
+ localstorage-clear clear all localstorage
216
+ sessionstorage-list list all sessionstorage key-value pairs
217
+ sessionstorage-get <key> get a sessionstorage item by key
218
+ sessionstorage-set <key> <value> set a sessionstorage item
219
+ sessionstorage-delete <key> delete a sessionstorage item
220
+ sessionstorage-clear clear all sessionstorage
221
+
222
+ Network:
223
+ route <pattern> mock network requests matching a url pattern
224
+ route-list list all active network routes
225
+ unroute [pattern] remove routes matching a pattern (or all routes)
226
+
227
+ DevTools:
228
+ console [min-level] list console messages
229
+ run-code <code> run playwright code snippet
230
+ pw-cli: reads code from stdin when <code> is omitted
231
+ pw-cli: wraps plain statements in an async function
232
+ run-script <file> [...] run a local JavaScript file with page/context/browser globals
233
+ network list all network requests since loading the page
234
+ tracing-start start trace recording
235
+ tracing-stop stop trace recording
236
+ video-start start video recording
237
+ video-stop stop video recording
238
+ show show browser devtools
239
+ devtools-start show browser devtools
240
+
241
+ Install:
242
+ install initialize workspace
243
+ install-browser install browser
244
+
245
+ Browser sessions:
246
+ list list browser sessions
247
+ close-all close all browser sessions
248
+ kill-all forcefully kill all browser sessions (for stale/zombie processes)
249
+
250
+ pw-cli queue:
251
+ queue add <command> [args...] add a command to the queue
252
+ queue list show queued commands
253
+ queue run [--fail-fast] execute queued commands in order
254
+ queue remove <id> remove a queued command by id prefix
255
+ queue clear clear the queue
256
+
257
+ Global options:
258
+ --help [command] print help
259
+ -h print help
260
+ --version print version
261
+ -s, --session <name> choose browser session
262
+ --headless used by pw-cli-managed browser launches
263
+
264
+ Requirements:
265
+ Node.js 18+
266
+ playwright
267
+ @playwright/cli
268
+
269
+ Examples:
270
+ pw-cli open https://example.com
271
+ pw-cli run-code "await page.goto('https://example.com'); return await page.title()"
272
+ echo "return await page.url()" | pw-cli run-code
273
+ pw-cli run-script .\\scripts\\smoke.js --env prod
274
+ pw-cli click "//button[contains(., 'Submit')]"
275
+ pw-cli queue add goto https://example.com
276
+ pw-cli queue add snapshot
277
+ pw-cli queue run
278
+ `.trim() + '\n');
279
+ }
280
+
281
+ // Management commands that don't need a running browser
282
+ const MGMT_COMMANDS = new Set([
283
+ 'open', 'close', 'list', 'kill-all', 'close-all', 'delete-data',
284
+ 'install', 'install-browser', 'show', 'config-print', 'tray',
285
+ 'queue',
286
+ ]);
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Auto-open: launches playwright-cli open with defaults if no session running
290
+ // ---------------------------------------------------------------------------
291
+ function autoOpen(session) {
292
+ const { spawnSync } = require('child_process');
293
+ const cliPath = findPlaywrightCli();
294
+ if (!cliPath) {
295
+ process.stderr.write('pw-cli: @playwright/cli not found. Install with: npm install -g @playwright/cli\n');
296
+ process.exit(1);
297
+ }
298
+ const openArgv = [
299
+ 'node', 'pw-cli',
300
+ ...(session !== 'default' ? ['-s', session] : []),
301
+ 'open',
302
+ '--headed', '--persistent', '--profile', DEFAULT_PROFILE,
303
+ ];
304
+ process.chdir(PW_CLI_DIR);
305
+ const prog = require(cliPath); // runs synchronously until browser ready
306
+ // playwright-cli's program() is async and calls process.exit when done,
307
+ // but "open" detaches the daemon – so we need to wait a beat.
308
+ // Instead, spawn a separate node process synchronously.
309
+ // Reset and re-approach: spawn as a child.
310
+ // (Require approach won't work cleanly for open — see delegation below)
311
+ void prog; // unused, actual open is done via spawnSync below
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // stdin reader
316
+ // ---------------------------------------------------------------------------
317
+ function readStdin() {
318
+ return new Promise((resolve, reject) => {
319
+ let data = '';
320
+ process.stdin.setEncoding('utf8');
321
+ process.stdin.on('data', c => { data += c; });
322
+ process.stdin.on('end', () => resolve(data.trim()));
323
+ process.stdin.on('error', reject);
324
+ });
325
+ }
326
+
327
+ // Wrap raw code in async function if not already a function expression
328
+ function wrapCodeIfNeeded(code) {
329
+ const t = code.trim();
330
+ // Match: async (page) => ..., async function, (async ..., function, async page => ...
331
+ if (/^async\s*[\w(]|^async\s+function|^\(async|^function/.test(t)) return t;
332
+ return `async (page) => {\n${code}\n}`;
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // XPath support
337
+ // ---------------------------------------------------------------------------
338
+
339
+ // 检测是否为 XPath 表达式
340
+ function isXPath(str) {
341
+ if (!str || typeof str !== 'string') return false;
342
+ const t = str.trim();
343
+ return t.startsWith('//') || t.startsWith('(//') || t.startsWith('xpath=');
344
+ }
345
+
346
+ // 标准化为 xpath=... 格式
347
+ function toXPathLocator(str) {
348
+ const t = str.trim();
349
+ if (t.startsWith('xpath=')) return t;
350
+ return `xpath=${t}`;
351
+ }
352
+
353
+ // 用单引号包裹字符串(单引号本身用 \' 转义),避免双引号与 minimist 解析冲突
354
+ function jsStr(s) {
355
+ return `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
356
+ }
357
+
358
+ // locator 表达式片段
359
+ function xpathLocator(ref) {
360
+ return `page.locator(${jsStr(toXPathLocator(ref))})`;
361
+ }
362
+
363
+ // 各命令转 run-code 代码的映射
364
+ // 返回 JS 代码字符串,或 null 表示无需转换
365
+ function buildXPathCode(command, positionals, flags) {
366
+ const buttonFlag = flags['--button'] || flags['-b'];
367
+ const forceFlag = flags['--force'] || flags['-f'];
368
+
369
+ switch (command) {
370
+ case 'click':
371
+ case 'dblclick': {
372
+ const [ref] = positionals;
373
+ if (!isXPath(ref) && !forceFlag) return null;
374
+ const method = command === 'dblclick' ? 'dblclick' : 'click';
375
+ const locator = isXPath(ref)
376
+ ? xpathLocator(ref)
377
+ : `page.locator(${jsStr('aria-ref=' + ref)})`;
378
+ const opts = [];
379
+ if (buttonFlag) opts.push(`button: ${jsStr(buttonFlag)}`);
380
+ if (forceFlag) opts.push('force: true');
381
+ const optsStr = opts.length ? `{ ${opts.join(', ')} }` : '';
382
+ return `await ${locator}.${method}(${optsStr})`;
383
+ }
384
+ case 'hover': {
385
+ const [ref] = positionals;
386
+ if (!isXPath(ref)) return null;
387
+ return `await ${xpathLocator(ref)}.hover()`;
388
+ }
389
+ case 'fill': {
390
+ const [ref, text] = positionals;
391
+ if (!isXPath(ref)) return null;
392
+ return `await ${xpathLocator(ref)}.fill(${jsStr(text || '')})`;
393
+ }
394
+ case 'check': {
395
+ const [ref] = positionals;
396
+ if (!isXPath(ref)) return null;
397
+ return `await ${xpathLocator(ref)}.check()`;
398
+ }
399
+ case 'uncheck': {
400
+ const [ref] = positionals;
401
+ if (!isXPath(ref)) return null;
402
+ return `await ${xpathLocator(ref)}.uncheck()`;
403
+ }
404
+ case 'select': {
405
+ const [ref, value] = positionals;
406
+ if (!isXPath(ref)) return null;
407
+ return `await ${xpathLocator(ref)}.selectOption(${jsStr(value || '')})`;
408
+ }
409
+ case 'drag': {
410
+ const [startRef, endRef] = positionals;
411
+ if (!isXPath(startRef) && !isXPath(endRef)) return null;
412
+ // 任意一端是 XPath 就整条转换;非 XPath 端保持 aria-ref 格式
413
+ const srcLocator = isXPath(startRef)
414
+ ? xpathLocator(startRef)
415
+ : `page.locator(${jsStr(`aria-ref=${startRef}`)})`;
416
+ const dstLocator = isXPath(endRef)
417
+ ? xpathLocator(endRef)
418
+ : `page.locator(${jsStr(`aria-ref=${endRef}`)})`;
419
+ return `await ${srcLocator}.dragTo(${dstLocator})`;
420
+ }
421
+ default:
422
+ return null;
423
+ }
424
+ }
425
+
426
+ const XPATH_COMMANDS = new Set(['click', 'dblclick', 'hover', 'fill', 'check', 'uncheck', 'select', 'drag']);
427
+
428
+ // 主入口:检测 argv 中是否有 XPath ref,有则整条命令转为 run-code
429
+ function convertXPathCommand(argv) {
430
+ const cmdIdx = argv.findIndex(a => XPATH_COMMANDS.has(a));
431
+ if (cmdIdx === -1) return argv;
432
+
433
+ const command = argv[cmdIdx];
434
+ const afterCmd = argv.slice(cmdIdx + 1);
435
+
436
+ // 分离 positionals 和 flags
437
+ const positionals = [];
438
+ const flags = {};
439
+ for (let i = 0; i < afterCmd.length; i++) {
440
+ const arg = afterCmd[i];
441
+ const next = afterCmd[i + 1];
442
+ if (arg.startsWith('--') && next && !next.startsWith('-')) {
443
+ flags[arg] = next;
444
+ i++;
445
+ } else if (arg.startsWith('-') && arg.length === 2 && next && !next.startsWith('-')) {
446
+ flags[arg] = next;
447
+ i++;
448
+ } else if (arg.startsWith('-')) {
449
+ flags[arg] = true; // 布尔标志,如 --force
450
+ } else {
451
+ positionals.push(arg);
452
+ }
453
+ }
454
+
455
+ const code = buildXPathCode(command, positionals, flags);
456
+ if (!code) return argv; // 没有 XPath,原样返回
457
+
458
+ // 保留命令前的全局参数(session 等),替换为 run-code
459
+ const beforeCmd = argv.slice(0, cmdIdx);
460
+ const wrappedCode = `async (page) => { ${code} }`;
461
+ return [...beforeCmd, 'run-code', wrappedCode];
462
+ }
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // queue — batch multiple actions and run them together
466
+ // ---------------------------------------------------------------------------
467
+ async function handleQueue(rawArgv) {
468
+ const { readQueue, addItem, removeItem, clearQueue } = require('../src/queue');
469
+ const { spawnSync } = require('child_process');
470
+
471
+ const queueIdx = rawArgv.indexOf('queue');
472
+ const globalArgs = rawArgv.slice(0, queueIdx); // e.g. ['-s', 'work']
473
+ const subCmd = rawArgv[queueIdx + 1];
474
+ const rest = rawArgv.slice(queueIdx + 2);
475
+
476
+ switch (subCmd) {
477
+ case 'add': {
478
+ if (!rest.length) {
479
+ process.stderr.write('pw-cli: queue add requires a command\n\nUsage: pw-cli queue add <command> [args...]\n');
480
+ process.exit(1);
481
+ }
482
+ const [command, ...args] = rest;
483
+ const item = addItem(command, args);
484
+ console.log(`queued [${item.id}] ${command}${args.length ? ' ' + args.join(' ') : ''}`);
485
+ break;
486
+ }
487
+
488
+ case 'list': {
489
+ const queue = readQueue();
490
+ if (!queue.length) {
491
+ console.log('Queue is empty.');
492
+ break;
493
+ }
494
+ console.log(`Queue (${queue.length} item${queue.length === 1 ? '' : 's'}):`);
495
+ queue.forEach((item, i) => {
496
+ const argStr = item.args && item.args.length ? ' ' + item.args.join(' ') : '';
497
+ console.log(` #${i + 1} [${item.id.slice(0, 7)}] ${item.command}${argStr}`);
498
+ });
499
+ break;
500
+ }
501
+
502
+ case 'remove': {
503
+ const [idPrefix] = rest;
504
+ if (!idPrefix) {
505
+ process.stderr.write('pw-cli: queue remove requires an id\n\nUsage: pw-cli queue remove <id>\n');
506
+ process.exit(1);
507
+ }
508
+ const removed = removeItem(idPrefix);
509
+ if (!removed) {
510
+ process.stderr.write(`pw-cli: no queue item matching "${idPrefix}"\n`);
511
+ process.exit(1);
512
+ }
513
+ console.log(`removed [${removed.id}] ${removed.command}`);
514
+ break;
515
+ }
516
+
517
+ case 'clear': {
518
+ clearQueue();
519
+ console.log('Queue cleared.');
520
+ break;
521
+ }
522
+
523
+ case 'run': {
524
+ const queue = readQueue();
525
+ if (!queue.length) {
526
+ console.log('Queue is empty, nothing to run.');
527
+ break;
528
+ }
529
+
530
+ const failFast = rest.includes('--fail-fast');
531
+ const scriptPath = process.argv[1]; // absolute path to pw-cli.js
532
+
533
+ console.log(`Running ${queue.length} queued item${queue.length === 1 ? '' : 's'}...`);
534
+ let failed = 0;
535
+
536
+ for (let i = 0; i < queue.length; i++) {
537
+ const item = queue[i];
538
+ const argStr = item.args && item.args.length ? ' ' + item.args.join(' ') : '';
539
+ process.stdout.write(` [${i + 1}/${queue.length}] ${item.command}${argStr} ... `);
540
+
541
+ const itemArgv = [item.command, ...(item.args || [])];
542
+ const res = spawnSync(process.execPath, [scriptPath, ...globalArgs, ...itemArgv], {
543
+ stdio: ['ignore', 'pipe', 'pipe'],
544
+ encoding: 'utf8',
545
+ });
546
+
547
+ if (res.status === 0) {
548
+ process.stdout.write('ok\n');
549
+ if (res.stdout && res.stdout.trim()) console.log(res.stdout.trimEnd());
550
+ } else {
551
+ process.stdout.write('FAILED\n');
552
+ if (res.stderr && res.stderr.trim()) process.stderr.write(res.stderr.trimEnd() + '\n');
553
+ failed++;
554
+ if (failFast) {
555
+ process.stderr.write(`pw-cli: queue run aborted at item ${i + 1} (--fail-fast)\n`);
556
+ break;
557
+ }
558
+ }
559
+ }
560
+
561
+ if (failed === 0) {
562
+ console.log('All items completed successfully.');
563
+ } else {
564
+ console.log(`Done. ${failed} item${failed === 1 ? '' : 's'} failed.`);
565
+ process.exit(1);
566
+ }
567
+ break;
568
+ }
569
+
570
+ default: {
571
+ process.stdout.write(`pw-cli queue — batch actions and run them together
572
+
573
+ USAGE
574
+ pw-cli queue <subcommand> [args...]
575
+
576
+ SUBCOMMANDS
577
+ add <command> [args...] Add an action to the queue
578
+ list Show all queued actions
579
+ run [--fail-fast] Execute all queued actions in order
580
+ remove <id> Remove a specific item by id prefix
581
+ clear Empty the queue
582
+
583
+ EXAMPLES
584
+ pw-cli queue add goto https://example.com
585
+ pw-cli queue add click e12
586
+ pw-cli queue add fill e5 "hello world"
587
+ pw-cli queue add run-code "return await page.title()"
588
+ pw-cli queue list
589
+ pw-cli queue run
590
+ pw-cli queue run --fail-fast
591
+ pw-cli queue clear
592
+ `);
593
+ if (subCmd && subCmd !== 'help' && subCmd !== '--help' && subCmd !== '-h') {
594
+ process.stderr.write(`pw-cli: unknown queue subcommand: ${subCmd}\n`);
595
+ process.exit(1);
596
+ }
597
+ break;
598
+ }
599
+ }
600
+ }
601
+
602
+ // ---------------------------------------------------------------------------
603
+ // run-script — delegates to our CDP-based executor
604
+ // ---------------------------------------------------------------------------
605
+ async function handleRunScript(rawArgv) {
606
+ const { getConnection, killBrowser } = require('../src/browser-manager');
607
+ const { execScript } = require('../src/executor');
608
+
609
+ // parse: pw-cli [global-opts] run-script <file> [script-args...]
610
+ const rsIdx = rawArgv.indexOf('run-script');
611
+ const afterRs = rawArgv.slice(rsIdx + 1);
612
+ const globalBefore = rawArgv.slice(0, rsIdx);
613
+
614
+ // extract --headless from global opts
615
+ const headless = hasFlag(globalBefore, '--headless');
616
+ const [scriptPath, ...scriptArgs] = afterRs;
617
+
618
+ if (!scriptPath) {
619
+ process.stderr.write('pw-cli: run-script requires a script path\n\nUsage: pw-cli run-script <file.js> [args...]\n');
620
+ process.exit(1);
621
+ }
622
+ if (!fs.existsSync(path.resolve(scriptPath))) {
623
+ process.stderr.write(`pw-cli: script not found: ${path.resolve(scriptPath)}\n`);
624
+ process.exit(3);
625
+ }
626
+
627
+ const conn = await getConnection({ headless, profile: 'default', port: 9223 });
628
+ try {
629
+ const result = await execScript(scriptPath, scriptArgs, conn);
630
+ if (result !== undefined) console.log(result);
631
+ } catch (err) {
632
+ process.stderr.write(`pw-cli: ${err.message}\n`);
633
+ process.exit(1);
634
+ } finally {
635
+ await conn.browser.close();
636
+ }
637
+ process.exit(0);
638
+ }
639
+
640
+ async function handleRunCode(rawArgv) {
641
+ const { getConnection } = require('../src/browser-manager');
642
+ const { execCode } = require('../src/executor');
643
+
644
+ const rcIdx = rawArgv.indexOf('run-code');
645
+ const beforeRc = rawArgv.slice(0, rcIdx);
646
+ const afterRc = rawArgv.slice(rcIdx + 1);
647
+ const options = parsePwCliGlobalOptions(beforeRc);
648
+
649
+ let code = afterRc.join(' ').trim();
650
+
651
+ if (!code) {
652
+ if (process.stdin.isTTY) {
653
+ process.stderr.write(
654
+ 'pw-cli: no code provided.\n\n' +
655
+ 'Usage:\n' +
656
+ ' pw-cli run-code "await page.goto(\'https://example.com\')"\n' +
657
+ ' pw-cli run-code "async (page) => { await page.goto(\'https://example.com\') }"\n' +
658
+ ' @\'\n' +
659
+ ' await page.goto(\'https://example.com\')\n' +
660
+ ' \'@ | pw-cli run-code\n'
661
+ );
662
+ process.exit(1);
663
+ }
664
+
665
+ code = (await readStdin()).trim();
666
+ if (!code) {
667
+ process.stderr.write('pw-cli: empty code from stdin\n');
668
+ process.exit(1);
669
+ }
670
+ }
671
+
672
+ const conn = await getConnection(options);
673
+ try {
674
+ const result = await execCode(code, conn);
675
+ if (result !== undefined) {
676
+ console.log(result);
677
+ }
678
+ } catch (err) {
679
+ process.stderr.write(`pw-cli: ${err.message || err}\n`);
680
+ process.exit(1);
681
+ } finally {
682
+ await conn.browser.close();
683
+ }
684
+
685
+ process.exit(0);
686
+ }
687
+
688
+ // ---------------------------------------------------------------------------
689
+ // Main
690
+ // ---------------------------------------------------------------------------
691
+ async function main() {
692
+ const ORIGINAL_CWD = process.cwd();
693
+ const rawArgv = process.argv.slice(2);
694
+ const { command, session } = getCommandAndSession(rawArgv);
695
+
696
+ if (!command || command === 'help' || (rawArgv.length === 1 && hasFlag(rawArgv, '--help', '-h'))) {
697
+ printMainHelp();
698
+ return;
699
+ }
700
+
701
+ // ── queue: batch actions and run them together ────────────────────────────
702
+ if (command === 'queue') {
703
+ await handleQueue(rawArgv);
704
+ return;
705
+ }
706
+
707
+ // ── run-script: handled entirely by our CDP executor ─────────────────────
708
+ if (command === 'run-script') {
709
+ await handleRunScript(rawArgv);
710
+ return;
711
+ }
712
+
713
+ // ── run-code: handled entirely by our CDP executor ───────────────────────
714
+ if (command === 'run-code') {
715
+ await handleRunCode(rawArgv);
716
+ return;
717
+ }
718
+
719
+ // ── From here on: delegate to playwright-cli (with enhancements) ─────────
720
+ const cliPath = findPlaywrightCli();
721
+ if (!cliPath) {
722
+ process.stderr.write('pw-cli: @playwright/cli not found.\nInstall: npm install -g @playwright/cli\n');
723
+ process.exit(1);
724
+ }
725
+
726
+ let argv = [...rawArgv];
727
+
728
+ // ── run-code: stdin support + auto-wrap plain code as function ───────────
729
+ if (command === 'run-code') {
730
+ const cmdIdx = argv.indexOf('run-code');
731
+ const afterCmd = argv.slice(cmdIdx + 1);
732
+ const positionals = afterCmd.filter(a => !a.startsWith('-'));
733
+
734
+ if (positionals.length === 0) {
735
+ // No inline code — try stdin
736
+ if (process.stdin.isTTY) {
737
+ process.stderr.write('pw-cli: no code provided.\n\nUsage:\n pw-cli run-code "<async (page) => { ... }>"\n echo "return await page.title()" | pw-cli run-code\n');
738
+ process.exit(1);
739
+ }
740
+ const code = await readStdin();
741
+ if (!code) {
742
+ process.stderr.write('pw-cli: empty code from stdin\n');
743
+ process.exit(1);
744
+ }
745
+ argv.splice(cmdIdx + 1, 0, wrapCodeIfNeeded(code));
746
+ } else {
747
+ // Inline code provided — wrap if it's plain statements, not a function
748
+ const codeArg = positionals[0];
749
+ const wrapped = wrapCodeIfNeeded(codeArg);
750
+ if (wrapped !== codeArg) {
751
+ // replace original positional with wrapped version
752
+ const origIdx = argv.indexOf(codeArg, cmdIdx + 1);
753
+ if (origIdx !== -1) argv[origIdx] = wrapped;
754
+ }
755
+ }
756
+ }
757
+
758
+ // ── Inject defaults for open ─────────────────────────────────────────────
759
+ if (command === 'open') {
760
+ const openIdx = argv.indexOf('open');
761
+ const afterOpen = argv.slice(openIdx + 1);
762
+
763
+ // If a URL is provided with open, split into two steps to avoid goto timeout:
764
+ // 1) spawn open (no URL) to start the browser daemon
765
+ // 2) navigate via run-code with waitUntil:'domcontentloaded' + timeout:0
766
+ // so redirects (login flows, SPA routing) never time out
767
+ const urlArg = afterOpen.find(a => !a.startsWith('-') && /^https?:\/\//.test(a));
768
+ if (urlArg) {
769
+ const { spawnSync } = require('child_process');
770
+ const openOnlyArgs = injectOpenDefaults(afterOpen.filter(a => a !== urlArg));
771
+ const res = spawnSync(process.execPath, [cliPath, 'open', ...openOnlyArgs], {
772
+ stdio: 'inherit',
773
+ cwd: PW_CLI_DIR,
774
+ });
775
+ if (res.status !== 0) {
776
+ process.stderr.write('pw-cli: failed to open browser\n');
777
+ process.exit(res.status || 1);
778
+ }
779
+ // Replace open+url with a run-code navigation (lenient wait strategy)
780
+ const navCode = `async page => { await page.goto(${JSON.stringify(urlArg)}, { waitUntil: 'domcontentloaded', timeout: 0 }); return page.url(); }`;
781
+ argv = ['run-code', navCode];
782
+ } else {
783
+ const enhanced = injectOpenDefaults(afterOpen);
784
+ argv = [...argv.slice(0, openIdx + 1), ...enhanced];
785
+ }
786
+ }
787
+
788
+ // ── XPath support: convert XPath ref args to run-code ───────────────────
789
+ argv = convertXPathCommand(argv);
790
+
791
+ // ── Auto-open if session not running and command needs a browser ──────────
792
+ if (command && !MGMT_COMMANDS.has(command)) {
793
+ const alive = await isSessionAlive(session);
794
+ if (!alive) {
795
+ // Spawn playwright-cli open as a separate process (it detaches the daemon)
796
+ const { spawnSync } = require('child_process');
797
+ const openArgs = [
798
+ cliPath,
799
+ ...(session !== 'default' ? ['-s', session] : []),
800
+ 'open',
801
+ '--headed', '--persistent', '--profile', DEFAULT_PROFILE,
802
+ ];
803
+ process.chdir(PW_CLI_DIR);
804
+ const res = spawnSync(process.execPath, openArgs, { stdio: 'inherit' });
805
+ if (res.status !== 0) {
806
+ process.stderr.write('pw-cli: failed to auto-open browser\n');
807
+ process.exit(1);
808
+ }
809
+ }
810
+ }
811
+
812
+ // ── Rewrite --filename relative paths to absolute (based on original cwd) ─
813
+ // playwright-cli runs from PW_CLI_DIR, so relative --filename paths would
814
+ // land there. Rewrite them before chdir so files end up where the user expects.
815
+ argv = argv.map((arg, i) => {
816
+ if (arg.startsWith('--filename=')) {
817
+ const val = arg.slice('--filename='.length);
818
+ if (val && !path.isAbsolute(val)) return `--filename=${path.resolve(ORIGINAL_CWD, val)}`;
819
+ }
820
+ if (arg === '--filename' && argv[i + 1] && !argv[i + 1].startsWith('-') && !path.isAbsolute(argv[i + 1])) {
821
+ // value is the next element; will be rewritten on that element's turn — skip
822
+ }
823
+ // handle the value token after a bare --filename
824
+ if (i > 0 && argv[i - 1] === '--filename' && !arg.startsWith('-') && !path.isAbsolute(arg)) {
825
+ return path.resolve(ORIGINAL_CWD, arg);
826
+ }
827
+ return arg;
828
+ });
829
+
830
+ // ── Redirect snapshot/console output to original cwd ────────────────────
831
+ // playwright-cli writes to .playwright-cli/ relative to its cwd (PW_CLI_DIR).
832
+ // On exit, move any newly created files back to the directory pw-cli was
833
+ // called from, so snapshots appear alongside the user's project.
834
+ {
835
+ const snapshotSrc = path.join(PW_CLI_DIR, '.playwright-cli');
836
+ const snapshotDst = path.join(ORIGINAL_CWD, '.playwright-cli');
837
+ let before = new Set();
838
+ try { before = new Set(fs.readdirSync(snapshotSrc)); } catch {}
839
+ process.on('exit', () => {
840
+ try {
841
+ const files = fs.readdirSync(snapshotSrc);
842
+ if (files.some(f => !before.has(f))) {
843
+ fs.mkdirSync(snapshotDst, { recursive: true });
844
+ for (const f of files) {
845
+ if (before.has(f)) continue;
846
+ const src = path.join(snapshotSrc, f);
847
+ const dst = path.join(snapshotDst, f);
848
+ try {
849
+ fs.renameSync(src, dst);
850
+ } catch (e) {
851
+ if (e.code === 'EXDEV') { fs.copyFileSync(src, dst); fs.unlinkSync(src); }
852
+ }
853
+ }
854
+ }
855
+ } catch {}
856
+ });
857
+ }
858
+
859
+ // ── Delegate to playwright-cli ────────────────────────────────────────────
860
+ // Set cwd to PW_CLI_DIR so playwright-cli always finds our workspace
861
+ process.chdir(PW_CLI_DIR);
862
+ // Override argv so playwright-cli's minimist sees our modified args
863
+ process.argv = ['node', 'pw-cli.js', ...argv];
864
+ require(cliPath);
865
+ }
866
+
867
+ main().catch(err => {
868
+ process.stderr.write(`pw-cli: ${err.message}\n`);
869
+ process.exit(1);
870
+ });