@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/LICENSE +21 -0
- package/README.md +204 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +795 -0
- package/dist/cli.js.map +1 -0
- package/dist/session.d.ts +17 -0
- package/dist/session.js +45 -0
- package/dist/session.js.map +1 -0
- package/dist/webdriver.d.ts +85 -0
- package/dist/webdriver.js +231 -0
- package/dist/webdriver.js.map +1 -0
- package/package.json +49 -0
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
|