@tamasno1/safari-cli 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,795 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * safari-cli — Control Safari from the command line via WebDriver.
4
+ */
5
+ import { program } from 'commander';
6
+ import { spawn } from 'node:child_process';
7
+ import { writeFileSync } from 'node:fs';
8
+ import { resolve } from 'node:path';
9
+ import { WebDriver, WebDriverError } from './webdriver.js';
10
+ import { loadSession, saveSession, clearSession, requireSession, } from './session.js';
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+ function getDriver(session) {
15
+ return new WebDriver(session.port);
16
+ }
17
+ /** Check if a process is still alive */
18
+ function isProcessAlive(pid) {
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ /** Wait until safaridriver is ready */
28
+ async function waitForDriver(port, timeoutMs = 10000) {
29
+ const driver = new WebDriver(port);
30
+ const deadline = Date.now() + timeoutMs;
31
+ while (Date.now() < deadline) {
32
+ try {
33
+ await driver.getStatus();
34
+ return;
35
+ }
36
+ catch {
37
+ await new Promise((r) => setTimeout(r, 200));
38
+ }
39
+ }
40
+ throw new Error(`SafariDriver did not start within ${timeoutMs}ms`);
41
+ }
42
+ /** Resolve CSS / XPath selector strategy */
43
+ function selectorStrategy(selector) {
44
+ if (selector.startsWith('//') || selector.startsWith('(//')) {
45
+ return { using: 'xpath', value: selector };
46
+ }
47
+ return { using: 'css selector', value: selector };
48
+ }
49
+ // JS snippets injected into the page for console/network capture
50
+ const INJECT_CONSOLE = `
51
+ if (!window.__safariCLI_console) {
52
+ window.__safariCLI_console = [];
53
+ const orig = {};
54
+ ['log','warn','error','info','debug'].forEach(m => {
55
+ orig[m] = console[m];
56
+ console[m] = function(...args) {
57
+ const msg = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
58
+ window.__safariCLI_console.push({ level: m.toUpperCase(), message: msg, timestamp: Date.now() });
59
+ orig[m].apply(console, args);
60
+ };
61
+ });
62
+ // capture uncaught errors
63
+ window.addEventListener('error', e => {
64
+ window.__safariCLI_console.push({ level: 'ERROR', message: e.message + ' at ' + e.filename + ':' + e.lineno, timestamp: Date.now() });
65
+ });
66
+ window.addEventListener('unhandledrejection', e => {
67
+ window.__safariCLI_console.push({ level: 'ERROR', message: 'Unhandled rejection: ' + String(e.reason), timestamp: Date.now() });
68
+ });
69
+ }
70
+ return 'ok';
71
+ `;
72
+ const INJECT_NETWORK = `
73
+ if (!window.__safariCLI_network) {
74
+ window.__safariCLI_network = [];
75
+ // Intercept fetch
76
+ const origFetch = window.fetch;
77
+ window.fetch = async function(...args) {
78
+ const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
79
+ const method = args[1]?.method || 'GET';
80
+ const entry = { method, url, timestamp: Date.now(), status: null, duration: null };
81
+ const start = performance.now();
82
+ try {
83
+ const resp = await origFetch.apply(this, args);
84
+ entry.status = resp.status;
85
+ entry.duration = Math.round(performance.now() - start);
86
+ window.__safariCLI_network.push(entry);
87
+ return resp;
88
+ } catch(e) {
89
+ entry.status = 0;
90
+ entry.duration = Math.round(performance.now() - start);
91
+ entry.error = String(e);
92
+ window.__safariCLI_network.push(entry);
93
+ throw e;
94
+ }
95
+ };
96
+ // Intercept XMLHttpRequest
97
+ const origOpen = XMLHttpRequest.prototype.open;
98
+ const origSend = XMLHttpRequest.prototype.send;
99
+ XMLHttpRequest.prototype.open = function(method, url) {
100
+ this.__safariCLI = { method, url, timestamp: Date.now() };
101
+ return origOpen.apply(this, arguments);
102
+ };
103
+ XMLHttpRequest.prototype.send = function() {
104
+ const meta = this.__safariCLI;
105
+ if (meta) {
106
+ const start = performance.now();
107
+ this.addEventListener('loadend', () => {
108
+ meta.status = this.status;
109
+ meta.duration = Math.round(performance.now() - start);
110
+ window.__safariCLI_network.push(meta);
111
+ });
112
+ }
113
+ return origSend.apply(this, arguments);
114
+ };
115
+ }
116
+ return 'ok';
117
+ `;
118
+ // ---------------------------------------------------------------------------
119
+ // CLI Definition
120
+ // ---------------------------------------------------------------------------
121
+ program
122
+ .name('safari-cli')
123
+ .description('Control Safari from the command line via WebDriver')
124
+ .version('1.0.0');
125
+ // ---- start ----------------------------------------------------------------
126
+ program
127
+ .command('start')
128
+ .description('Start SafariDriver and create a browser session')
129
+ .option('-p, --port <port>', 'SafariDriver port', '9515')
130
+ .action(async (opts) => {
131
+ const port = parseInt(opts.port, 10);
132
+ // Check for existing session
133
+ const existing = loadSession();
134
+ if (existing && isProcessAlive(existing.pid)) {
135
+ console.log(`Session already active (pid=${existing.pid}, port=${existing.port}, session=${existing.sessionId})`);
136
+ return;
137
+ }
138
+ // Start safaridriver
139
+ console.error(`Starting safaridriver on port ${port}...`);
140
+ const child = spawn('safaridriver', ['-p', String(port)], {
141
+ detached: true,
142
+ stdio: 'ignore',
143
+ });
144
+ child.unref();
145
+ if (!child.pid) {
146
+ console.error('Failed to start safaridriver');
147
+ process.exit(1);
148
+ }
149
+ try {
150
+ await waitForDriver(port);
151
+ }
152
+ catch (e) {
153
+ console.error(e.message);
154
+ try {
155
+ process.kill(child.pid);
156
+ }
157
+ catch { /* ignore */ }
158
+ process.exit(1);
159
+ }
160
+ // Create session
161
+ const driver = new WebDriver(port);
162
+ let sessionId;
163
+ try {
164
+ sessionId = await driver.createSession();
165
+ }
166
+ catch (e) {
167
+ console.error(`Failed to create session: ${e.message}`);
168
+ try {
169
+ process.kill(child.pid);
170
+ }
171
+ catch { /* ignore */ }
172
+ process.exit(1);
173
+ }
174
+ saveSession({
175
+ port,
176
+ sessionId,
177
+ pid: child.pid,
178
+ startedAt: new Date().toISOString(),
179
+ });
180
+ console.log(`Safari session started`);
181
+ console.log(` Port: ${port}`);
182
+ console.log(` Session: ${sessionId}`);
183
+ console.log(` PID: ${child.pid}`);
184
+ });
185
+ // ---- stop -----------------------------------------------------------------
186
+ program
187
+ .command('stop')
188
+ .description('Close Safari session and stop SafariDriver')
189
+ .action(async () => {
190
+ const session = loadSession();
191
+ if (!session) {
192
+ console.log('No active session.');
193
+ return;
194
+ }
195
+ const driver = getDriver(session);
196
+ try {
197
+ await driver.deleteSession(session.sessionId);
198
+ }
199
+ catch { /* session may already be dead */ }
200
+ if (isProcessAlive(session.pid)) {
201
+ try {
202
+ process.kill(session.pid, 'SIGTERM');
203
+ console.log(`Stopped safaridriver (pid=${session.pid})`);
204
+ }
205
+ catch { /* ignore */ }
206
+ }
207
+ clearSession();
208
+ console.log('Session closed.');
209
+ });
210
+ // ---- status ---------------------------------------------------------------
211
+ program
212
+ .command('status')
213
+ .description('Show current session status')
214
+ .action(async () => {
215
+ const session = loadSession();
216
+ if (!session) {
217
+ console.log('No active session.');
218
+ return;
219
+ }
220
+ const alive = isProcessAlive(session.pid);
221
+ console.log(`Session: ${session.sessionId}`);
222
+ console.log(`Port: ${session.port}`);
223
+ console.log(`PID: ${session.pid} (${alive ? 'running' : 'DEAD'})`);
224
+ console.log(`Started: ${session.startedAt}`);
225
+ if (alive) {
226
+ const driver = getDriver(session);
227
+ try {
228
+ const url = await driver.getCurrentUrl(session.sessionId);
229
+ const title = await driver.getTitle(session.sessionId);
230
+ console.log(`Current URL: ${url}`);
231
+ console.log(`Page Title: ${title}`);
232
+ }
233
+ catch { /* session might be stale */ }
234
+ }
235
+ });
236
+ // ---- navigate -------------------------------------------------------------
237
+ program
238
+ .command('navigate <url>')
239
+ .alias('go')
240
+ .description('Navigate to a URL')
241
+ .action(async (url) => {
242
+ const session = requireSession();
243
+ const driver = getDriver(session);
244
+ // Auto-add https:// if missing
245
+ if (!/^https?:\/\//i.test(url))
246
+ url = 'https://' + url;
247
+ await driver.navigateTo(session.sessionId, url);
248
+ console.log(`Navigated to ${url}`);
249
+ });
250
+ // ---- back / forward / refresh ---------------------------------------------
251
+ program
252
+ .command('back')
253
+ .description('Go back')
254
+ .action(async () => {
255
+ const session = requireSession();
256
+ await getDriver(session).back(session.sessionId);
257
+ console.log('Navigated back.');
258
+ });
259
+ program
260
+ .command('forward')
261
+ .description('Go forward')
262
+ .action(async () => {
263
+ const session = requireSession();
264
+ await getDriver(session).forward(session.sessionId);
265
+ console.log('Navigated forward.');
266
+ });
267
+ program
268
+ .command('refresh')
269
+ .description('Refresh the page')
270
+ .action(async () => {
271
+ const session = requireSession();
272
+ await getDriver(session).refresh(session.sessionId);
273
+ console.log('Page refreshed.');
274
+ });
275
+ // ---- info -----------------------------------------------------------------
276
+ program
277
+ .command('info')
278
+ .description('Get page title and URL')
279
+ .action(async () => {
280
+ const session = requireSession();
281
+ const driver = getDriver(session);
282
+ const [url, title] = await Promise.all([
283
+ driver.getCurrentUrl(session.sessionId),
284
+ driver.getTitle(session.sessionId),
285
+ ]);
286
+ console.log(`Title: ${title}`);
287
+ console.log(`URL: ${url}`);
288
+ });
289
+ // ---- source ---------------------------------------------------------------
290
+ program
291
+ .command('source')
292
+ .description('Get page source HTML')
293
+ .option('-o, --output <file>', 'Write to file instead of stdout')
294
+ .action(async (opts) => {
295
+ const session = requireSession();
296
+ const source = await getDriver(session).getPageSource(session.sessionId);
297
+ if (opts.output) {
298
+ writeFileSync(resolve(opts.output), source);
299
+ console.error(`Saved to ${opts.output}`);
300
+ }
301
+ else {
302
+ process.stdout.write(source);
303
+ }
304
+ });
305
+ // ---- screenshot -----------------------------------------------------------
306
+ program
307
+ .command('screenshot')
308
+ .description('Take a screenshot')
309
+ .option('-o, --output <file>', 'Output file path (default: screenshot-<timestamp>.png)')
310
+ .option('-s, --selector <selector>', 'Screenshot a specific element')
311
+ .action(async (opts) => {
312
+ const session = requireSession();
313
+ const driver = getDriver(session);
314
+ let base64;
315
+ if (opts.selector) {
316
+ const { using, value } = selectorStrategy(opts.selector);
317
+ const elementId = await driver.findElement(session.sessionId, using, value);
318
+ base64 = await driver.takeElementScreenshot(session.sessionId, elementId);
319
+ }
320
+ else {
321
+ base64 = await driver.takeScreenshot(session.sessionId);
322
+ }
323
+ const filename = opts.output || `screenshot-${Date.now()}.png`;
324
+ const filepath = resolve(filename);
325
+ writeFileSync(filepath, Buffer.from(base64, 'base64'));
326
+ console.log(filepath);
327
+ });
328
+ // ---- console --------------------------------------------------------------
329
+ program
330
+ .command('console')
331
+ .description('Get captured console logs')
332
+ .option('-l, --level <level>', 'Filter by level (LOG, WARN, ERROR, INFO, DEBUG)')
333
+ .option('--inject', 'Just inject the capture hook (for pages loaded without it)')
334
+ .action(async (opts) => {
335
+ const session = requireSession();
336
+ const driver = getDriver(session);
337
+ // Always ensure injection
338
+ await driver.executeScript(session.sessionId, INJECT_CONSOLE);
339
+ if (opts.inject) {
340
+ console.log('Console capture injected.');
341
+ return;
342
+ }
343
+ let logs = await driver.executeScript(session.sessionId, 'return window.__safariCLI_console || [];');
344
+ if (opts.level) {
345
+ const level = opts.level.toUpperCase();
346
+ logs = logs.filter((l) => l.level === level);
347
+ }
348
+ if (logs.length === 0) {
349
+ console.log('No console logs captured.');
350
+ return;
351
+ }
352
+ for (const entry of logs) {
353
+ const time = new Date(entry.timestamp).toISOString().slice(11, 23);
354
+ const levelTag = entry.level.padEnd(5);
355
+ console.log(`[${time}] ${levelTag} ${entry.message}`);
356
+ }
357
+ });
358
+ // ---- console-clear --------------------------------------------------------
359
+ program
360
+ .command('console-clear')
361
+ .description('Clear captured console logs')
362
+ .action(async () => {
363
+ const session = requireSession();
364
+ await getDriver(session).executeScript(session.sessionId, 'window.__safariCLI_console = []; return "ok";');
365
+ console.log('Console logs cleared.');
366
+ });
367
+ // ---- network --------------------------------------------------------------
368
+ program
369
+ .command('network')
370
+ .description('Get captured network logs')
371
+ .option('--inject', 'Just inject the capture hook')
372
+ .action(async (opts) => {
373
+ const session = requireSession();
374
+ const driver = getDriver(session);
375
+ await driver.executeScript(session.sessionId, INJECT_NETWORK);
376
+ if (opts.inject) {
377
+ console.log('Network capture injected.');
378
+ return;
379
+ }
380
+ const logs = await driver.executeScript(session.sessionId, 'return window.__safariCLI_network || [];');
381
+ if (logs.length === 0) {
382
+ console.log('No network logs captured.');
383
+ return;
384
+ }
385
+ for (const entry of logs) {
386
+ const status = entry.status != null ? String(entry.status) : '???';
387
+ const dur = entry.duration != null ? `${entry.duration}ms` : '';
388
+ console.log(`${entry.method.padEnd(6)} ${status.padEnd(4)} ${dur.padStart(8)} ${entry.url}`);
389
+ }
390
+ });
391
+ // ---- network-clear --------------------------------------------------------
392
+ program
393
+ .command('network-clear')
394
+ .description('Clear captured network logs')
395
+ .action(async () => {
396
+ const session = requireSession();
397
+ await getDriver(session).executeScript(session.sessionId, 'window.__safariCLI_network = []; return "ok";');
398
+ console.log('Network logs cleared.');
399
+ });
400
+ // ---- execute --------------------------------------------------------------
401
+ program
402
+ .command('execute <script>')
403
+ .alias('eval')
404
+ .description('Execute JavaScript in the browser')
405
+ .option('--async', 'Execute as async script (must call arguments[0] callback)')
406
+ .action(async (script, opts) => {
407
+ const session = requireSession();
408
+ const driver = getDriver(session);
409
+ let result;
410
+ if (opts.async) {
411
+ result = await driver.executeAsyncScript(session.sessionId, script);
412
+ }
413
+ else {
414
+ // If script already has return, use as-is.
415
+ // If it's a single expression, wrap with return.
416
+ // If multi-statement, wrap the last expression with return via IIFE.
417
+ let toRun;
418
+ if (/^\s*return\s/m.test(script)) {
419
+ toRun = script;
420
+ }
421
+ else if (/;/.test(script)) {
422
+ // Multi-statement: wrap last statement's value
423
+ const stmts = script.split(';').map((s) => s.trim()).filter(Boolean);
424
+ const last = stmts.pop() || '';
425
+ const prefix = stmts.length > 0 ? stmts.join('; ') + '; ' : '';
426
+ toRun = `${prefix}return ${last}`;
427
+ }
428
+ else {
429
+ toRun = `return ${script}`;
430
+ }
431
+ try {
432
+ result = await driver.executeScript(session.sessionId, toRun);
433
+ }
434
+ catch {
435
+ // If wrapping failed, try raw
436
+ result = await driver.executeScript(session.sessionId, script);
437
+ }
438
+ }
439
+ if (result !== undefined && result !== null) {
440
+ if (typeof result === 'object') {
441
+ console.log(JSON.stringify(result, null, 2));
442
+ }
443
+ else {
444
+ console.log(String(result));
445
+ }
446
+ }
447
+ });
448
+ // ---- inspect --------------------------------------------------------------
449
+ program
450
+ .command('inspect <selector>')
451
+ .description('Inspect a DOM element')
452
+ .action(async (selector) => {
453
+ const session = requireSession();
454
+ const driver = getDriver(session);
455
+ const { using, value } = selectorStrategy(selector);
456
+ const elementId = await driver.findElement(session.sessionId, using, value);
457
+ const elRef = { 'element-6066-11e4-a52e-4f735466cecf': elementId };
458
+ const [tagName, text, rect] = await Promise.all([
459
+ driver.getElementTagName(session.sessionId, elementId),
460
+ driver.getElementText(session.sessionId, elementId),
461
+ driver.getElementRect(session.sessionId, elementId),
462
+ ]);
463
+ // Get attributes, visibility, and enabled via JS (Safari doesn't support /displayed endpoint)
464
+ const extras = await driver.executeScript(session.sessionId, `
465
+ const el = arguments[0];
466
+ const attrs = {};
467
+ for (const attr of el.attributes) attrs[attr.name] = attr.value;
468
+ const style = window.getComputedStyle(el);
469
+ const displayed = style.display !== 'none' && style.visibility !== 'hidden' && el.offsetParent !== null;
470
+ return { attrs, displayed, enabled: !el.disabled };
471
+ `, [elRef]);
472
+ console.log(`Tag: <${tagName}>`);
473
+ console.log(`Text: ${text.substring(0, 200) || '(empty)'}`);
474
+ console.log(`Rect: x=${rect.x} y=${rect.y} w=${rect.width} h=${rect.height}`);
475
+ console.log(`Displayed: ${extras.displayed}`);
476
+ console.log(`Enabled: ${extras.enabled}`);
477
+ const attrs = extras.attrs;
478
+ if (attrs && Object.keys(attrs).length > 0) {
479
+ console.log(`Attributes:`);
480
+ for (const [k, v] of Object.entries(attrs)) {
481
+ console.log(` ${k}="${v}"`);
482
+ }
483
+ }
484
+ });
485
+ // ---- click ----------------------------------------------------------------
486
+ program
487
+ .command('click <selector>')
488
+ .description('Click a DOM element')
489
+ .action(async (selector) => {
490
+ const session = requireSession();
491
+ const driver = getDriver(session);
492
+ const { using, value } = selectorStrategy(selector);
493
+ const elementId = await driver.findElement(session.sessionId, using, value);
494
+ await driver.clickElement(session.sessionId, elementId);
495
+ console.log(`Clicked: ${selector}`);
496
+ });
497
+ // ---- type -----------------------------------------------------------------
498
+ program
499
+ .command('type <selector> <text>')
500
+ .description('Type text into a DOM element')
501
+ .option('--clear', 'Clear the field first')
502
+ .action(async (selector, text, opts) => {
503
+ const session = requireSession();
504
+ const driver = getDriver(session);
505
+ const { using, value } = selectorStrategy(selector);
506
+ const elementId = await driver.findElement(session.sessionId, using, value);
507
+ if (opts.clear) {
508
+ await driver.clearElement(session.sessionId, elementId);
509
+ }
510
+ await driver.sendKeys(session.sessionId, elementId, text);
511
+ console.log(`Typed into: ${selector}`);
512
+ });
513
+ // ---- find -----------------------------------------------------------------
514
+ program
515
+ .command('find <selector>')
516
+ .description('Find elements matching a selector')
517
+ .option('--text', 'Show element text')
518
+ .action(async (selector, opts) => {
519
+ const session = requireSession();
520
+ const driver = getDriver(session);
521
+ const { using, value } = selectorStrategy(selector);
522
+ const elements = await driver.findElements(session.sessionId, using, value);
523
+ console.log(`Found ${elements.length} element(s)`);
524
+ for (let i = 0; i < elements.length; i++) {
525
+ const tag = await driver.getElementTagName(session.sessionId, elements[i]);
526
+ let line = ` [${i}] <${tag}>`;
527
+ if (opts.text) {
528
+ const text = await driver.getElementText(session.sessionId, elements[i]);
529
+ if (text)
530
+ line += ` "${text.substring(0, 80)}"`;
531
+ }
532
+ console.log(line);
533
+ }
534
+ });
535
+ // ---- html -----------------------------------------------------------------
536
+ program
537
+ .command('html [selector]')
538
+ .description('Get outerHTML of an element (or full page)')
539
+ .option('-o, --output <file>', 'Write to file')
540
+ .action(async (selector, opts) => {
541
+ const session = requireSession();
542
+ const driver = getDriver(session);
543
+ let html;
544
+ if (selector) {
545
+ const { using, value } = selectorStrategy(selector);
546
+ const elementId = await driver.findElement(session.sessionId, using, value);
547
+ html = await driver.executeScript(session.sessionId, 'return arguments[0].outerHTML;', [{ 'element-6066-11e4-a52e-4f735466cecf': elementId }]);
548
+ }
549
+ else {
550
+ html = await driver.getPageSource(session.sessionId);
551
+ }
552
+ if (opts.output) {
553
+ writeFileSync(resolve(opts.output), html);
554
+ console.error(`Saved to ${opts.output}`);
555
+ }
556
+ else {
557
+ process.stdout.write(html + '\n');
558
+ }
559
+ });
560
+ // ---- perf -----------------------------------------------------------------
561
+ program
562
+ .command('perf')
563
+ .description('Get page performance metrics')
564
+ .action(async () => {
565
+ const session = requireSession();
566
+ const driver = getDriver(session);
567
+ const metrics = await driver.executeScript(session.sessionId, `
568
+ const nav = performance.getEntriesByType('navigation')[0] || {};
569
+ const paint = performance.getEntriesByType('paint');
570
+ const fp = paint.find(e => e.name === 'first-paint');
571
+ const fcp = paint.find(e => e.name === 'first-contentful-paint');
572
+ const resources = performance.getEntriesByType('resource');
573
+ return {
574
+ url: location.href,
575
+ domContentLoaded: Math.round(nav.domContentLoadedEventEnd || 0),
576
+ loadComplete: Math.round(nav.loadEventEnd || 0),
577
+ firstPaint: fp ? Math.round(fp.startTime) : null,
578
+ firstContentfulPaint: fcp ? Math.round(fcp.startTime) : null,
579
+ domInteractive: Math.round(nav.domInteractive || 0),
580
+ responseTime: Math.round((nav.responseEnd || 0) - (nav.requestStart || 0)),
581
+ resourceCount: resources.length,
582
+ totalTransferSize: resources.reduce((sum, r) => sum + (r.transferSize || 0), 0),
583
+ };
584
+ `);
585
+ console.log(`URL: ${metrics.url}`);
586
+ console.log(`DOM Content Loaded: ${metrics.domContentLoaded}ms`);
587
+ console.log(`Load Complete: ${metrics.loadComplete}ms`);
588
+ console.log(`DOM Interactive: ${metrics.domInteractive}ms`);
589
+ console.log(`First Paint: ${metrics.firstPaint != null ? metrics.firstPaint + 'ms' : 'N/A'}`);
590
+ console.log(`First Contentful Paint: ${metrics.firstContentfulPaint != null ? metrics.firstContentfulPaint + 'ms' : 'N/A'}`);
591
+ console.log(`Response Time: ${metrics.responseTime}ms`);
592
+ console.log(`Resources: ${metrics.resourceCount}`);
593
+ console.log(`Transfer Size: ${(metrics.totalTransferSize / 1024).toFixed(1)} KB`);
594
+ });
595
+ // ---- cookies --------------------------------------------------------------
596
+ program
597
+ .command('cookies')
598
+ .description('List all cookies')
599
+ .option('--json', 'Output as JSON')
600
+ .action(async (opts) => {
601
+ const session = requireSession();
602
+ const cookies = await getDriver(session).getCookies(session.sessionId);
603
+ if (opts.json) {
604
+ console.log(JSON.stringify(cookies, null, 2));
605
+ return;
606
+ }
607
+ if (cookies.length === 0) {
608
+ console.log('No cookies.');
609
+ return;
610
+ }
611
+ for (const c of cookies) {
612
+ console.log(`${c.name}=${c.value}`);
613
+ if (c.domain)
614
+ console.log(` domain: ${c.domain}`);
615
+ if (c.path)
616
+ console.log(` path: ${c.path}`);
617
+ if (c.expiry)
618
+ console.log(` expires: ${new Date(c.expiry * 1000).toISOString()}`);
619
+ if (c.secure)
620
+ console.log(` secure: true`);
621
+ if (c.httpOnly)
622
+ console.log(` httpOnly: true`);
623
+ }
624
+ });
625
+ // ---- resize ---------------------------------------------------------------
626
+ program
627
+ .command('resize')
628
+ .description('Get or set window size')
629
+ .option('-w, --width <width>', 'Window width')
630
+ .option('-h, --height <height>', 'Window height')
631
+ .option('--maximize', 'Maximize window')
632
+ .option('--fullscreen', 'Fullscreen window')
633
+ .action(async (opts) => {
634
+ const session = requireSession();
635
+ const driver = getDriver(session);
636
+ if (opts.maximize) {
637
+ await driver.maximizeWindow(session.sessionId);
638
+ console.log('Window maximized.');
639
+ return;
640
+ }
641
+ if (opts.fullscreen) {
642
+ await driver.fullscreenWindow(session.sessionId);
643
+ console.log('Window fullscreened.');
644
+ return;
645
+ }
646
+ if (opts.width || opts.height) {
647
+ const rect = {};
648
+ if (opts.width)
649
+ rect.width = parseInt(opts.width, 10);
650
+ if (opts.height)
651
+ rect.height = parseInt(opts.height, 10);
652
+ await driver.setWindowRect(session.sessionId, rect);
653
+ console.log(`Window resized to ${rect.width || '?'}×${rect.height || '?'}`);
654
+ return;
655
+ }
656
+ // Just show current size
657
+ const rect = await driver.getWindowRect(session.sessionId);
658
+ console.log(`Position: ${rect.x}, ${rect.y}`);
659
+ console.log(`Size: ${rect.width}×${rect.height}`);
660
+ });
661
+ // ---- tabs -----------------------------------------------------------------
662
+ program
663
+ .command('tabs')
664
+ .description('List open tabs/windows')
665
+ .action(async () => {
666
+ const session = requireSession();
667
+ const driver = getDriver(session);
668
+ const handles = await driver.getWindowHandles(session.sessionId);
669
+ const current = await driver.getWindowHandle(session.sessionId);
670
+ for (const handle of handles) {
671
+ const marker = handle === current ? '→' : ' ';
672
+ // Try to get title by switching
673
+ if (handle !== current) {
674
+ try {
675
+ await driver.switchToWindow(session.sessionId, handle);
676
+ const title = await driver.getTitle(session.sessionId);
677
+ const url = await driver.getCurrentUrl(session.sessionId);
678
+ console.log(`${marker} ${handle} ${title} (${url})`);
679
+ }
680
+ catch {
681
+ console.log(`${marker} ${handle}`);
682
+ }
683
+ }
684
+ else {
685
+ const title = await driver.getTitle(session.sessionId);
686
+ const url = await driver.getCurrentUrl(session.sessionId);
687
+ console.log(`${marker} ${handle} ${title} (${url})`);
688
+ }
689
+ }
690
+ // Switch back
691
+ if (handles.length > 1) {
692
+ await driver.switchToWindow(session.sessionId, current);
693
+ }
694
+ });
695
+ // ---- tab ------------------------------------------------------------------
696
+ program
697
+ .command('tab <handle>')
698
+ .description('Switch to a tab/window by handle')
699
+ .action(async (handle) => {
700
+ const session = requireSession();
701
+ await getDriver(session).switchToWindow(session.sessionId, handle);
702
+ const driver = getDriver(session);
703
+ const title = await driver.getTitle(session.sessionId);
704
+ console.log(`Switched to: ${title}`);
705
+ });
706
+ // ---- wait -----------------------------------------------------------------
707
+ program
708
+ .command('wait <selector>')
709
+ .description('Wait for an element to appear')
710
+ .option('-t, --timeout <ms>', 'Timeout in milliseconds', '10000')
711
+ .action(async (selector, opts) => {
712
+ const session = requireSession();
713
+ const driver = getDriver(session);
714
+ const { using, value } = selectorStrategy(selector);
715
+ const timeout = parseInt(opts.timeout, 10);
716
+ const deadline = Date.now() + timeout;
717
+ while (Date.now() < deadline) {
718
+ try {
719
+ await driver.findElement(session.sessionId, using, value);
720
+ console.log(`Element found: ${selector}`);
721
+ return;
722
+ }
723
+ catch {
724
+ await new Promise((r) => setTimeout(r, 300));
725
+ }
726
+ }
727
+ console.error(`Timeout: element not found after ${timeout}ms: ${selector}`);
728
+ process.exit(1);
729
+ });
730
+ // ---- alert ----------------------------------------------------------------
731
+ program
732
+ .command('alert')
733
+ .description('Get alert text, accept, or dismiss')
734
+ .option('--accept', 'Accept the alert')
735
+ .option('--dismiss', 'Dismiss the alert')
736
+ .option('--text <text>', 'Send text to a prompt')
737
+ .action(async (opts) => {
738
+ const session = requireSession();
739
+ const driver = getDriver(session);
740
+ if (opts.text) {
741
+ await driver.sendAlertText(session.sessionId, opts.text);
742
+ }
743
+ if (opts.accept) {
744
+ await driver.acceptAlert(session.sessionId);
745
+ console.log('Alert accepted.');
746
+ }
747
+ else if (opts.dismiss) {
748
+ await driver.dismissAlert(session.sessionId);
749
+ console.log('Alert dismissed.');
750
+ }
751
+ else {
752
+ const text = await driver.getAlertText(session.sessionId);
753
+ console.log(`Alert: ${text}`);
754
+ }
755
+ });
756
+ // ---- frame ----------------------------------------------------------------
757
+ program
758
+ .command('frame [id]')
759
+ .description('Switch to an iframe (no arg = top-level)')
760
+ .action(async (id) => {
761
+ const session = requireSession();
762
+ const driver = getDriver(session);
763
+ if (id === undefined) {
764
+ await driver.switchToFrame(session.sessionId, null);
765
+ console.log('Switched to top-level frame.');
766
+ }
767
+ else {
768
+ const frameId = /^\d+$/.test(id) ? parseInt(id, 10) : id;
769
+ await driver.switchToFrame(session.sessionId, frameId);
770
+ console.log(`Switched to frame: ${id}`);
771
+ }
772
+ });
773
+ // ---- Error handling -------------------------------------------------------
774
+ program.hook('postAction', () => { });
775
+ // Global error handler
776
+ async function main() {
777
+ try {
778
+ await program.parseAsync(process.argv);
779
+ }
780
+ catch (err) {
781
+ if (err instanceof WebDriverError) {
782
+ console.error(`Error: ${err.message}`);
783
+ if (err.webdriverError === 'invalid session id') {
784
+ console.error('Session is stale. Run `safari-cli stop` then `safari-cli start`.');
785
+ clearSession();
786
+ }
787
+ }
788
+ else {
789
+ console.error(`Error: ${err.message || err}`);
790
+ }
791
+ process.exit(1);
792
+ }
793
+ }
794
+ main();
795
+ //# sourceMappingURL=cli.js.map