@darksol/terminal 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -1
- package/package.json +4 -1
- package/src/browser/actions.js +58 -0
- package/src/cli.js +136 -0
- package/src/config/keys.js +12 -2
- package/src/daemon/index.js +225 -0
- package/src/daemon/manager.js +148 -0
- package/src/daemon/pid.js +80 -0
- package/src/services/browser.js +659 -0
- package/src/services/telegram.js +570 -0
- package/src/web/commands.js +135 -1
- package/src/web/server.js +21 -2
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { success, error, warn, info, kvDisplay, spinner } from '../ui/components.js';
|
|
7
|
+
import { showSection } from '../ui/banner.js';
|
|
8
|
+
import { theme } from '../ui/theme.js';
|
|
9
|
+
|
|
10
|
+
const DARKSOL_DIR = join(homedir(), '.darksol');
|
|
11
|
+
const BROWSER_DIR = join(DARKSOL_DIR, 'browser');
|
|
12
|
+
const BROWSER_PROFILES_DIR = join(BROWSER_DIR, 'profiles');
|
|
13
|
+
const BROWSER_SCREENSHOT_PATH = join(BROWSER_DIR, 'latest.png');
|
|
14
|
+
const BROWSER_METADATA_PATH = join(BROWSER_DIR, 'metadata.json');
|
|
15
|
+
const DEFAULT_TIMEOUT = 30_000;
|
|
16
|
+
const DEFAULT_BROWSER_TYPE = 'chromium';
|
|
17
|
+
const DEFAULT_PROFILE = 'default';
|
|
18
|
+
const BROWSER_PIPE_PATH = process.platform === 'win32'
|
|
19
|
+
? '\\\\.\\pipe\\darksol-browser'
|
|
20
|
+
: join(DARKSOL_DIR, 'browser.sock');
|
|
21
|
+
|
|
22
|
+
let playwrightLoader = () => import('playwright-core');
|
|
23
|
+
|
|
24
|
+
function ensureBrowserDirs() {
|
|
25
|
+
mkdirSync(BROWSER_PROFILES_DIR, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ensureParentDir(path) {
|
|
29
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function browserNotRunningError() {
|
|
33
|
+
return new Error('Browser service is not running. Start it with: darksol browser launch');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createErrorPayload(err) {
|
|
37
|
+
return {
|
|
38
|
+
message: err?.message || String(err),
|
|
39
|
+
code: err?.code || 'BROWSER_ERROR',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeUrl(url) {
|
|
44
|
+
if (/^https?:\/\//i.test(url)) return url;
|
|
45
|
+
return `https://${url}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sanitizeEvalResult(result) {
|
|
49
|
+
if (result === undefined) return 'undefined';
|
|
50
|
+
if (typeof result === 'string') return result;
|
|
51
|
+
try {
|
|
52
|
+
return JSON.stringify(result, null, 2);
|
|
53
|
+
} catch {
|
|
54
|
+
return String(result);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function pathExists(path) {
|
|
59
|
+
return existsSync(path);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getBrowserPipePath() {
|
|
63
|
+
return BROWSER_PIPE_PATH;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getBrowserScreenshotPath() {
|
|
67
|
+
return BROWSER_SCREENSHOT_PATH;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getBrowserMetadataPath() {
|
|
71
|
+
return BROWSER_METADATA_PATH;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function setPlaywrightLoaderForTests(loader) {
|
|
75
|
+
playwrightLoader = loader;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class BrowserController {
|
|
79
|
+
constructor(opts = {}) {
|
|
80
|
+
this.playwright = null;
|
|
81
|
+
this.context = null;
|
|
82
|
+
this.pages = new Map();
|
|
83
|
+
this.currentPageId = null;
|
|
84
|
+
this.browserType = DEFAULT_BROWSER_TYPE;
|
|
85
|
+
this.profile = DEFAULT_PROFILE;
|
|
86
|
+
this.headed = false;
|
|
87
|
+
this.startedAt = null;
|
|
88
|
+
this.server = null;
|
|
89
|
+
this.serverClosing = false;
|
|
90
|
+
this.keepAlivePromise = null;
|
|
91
|
+
this.keepAliveResolve = null;
|
|
92
|
+
this.playwrightLoader = opts.playwrightLoader || playwrightLoader;
|
|
93
|
+
ensureBrowserDirs();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async ensurePlaywrightAvailable() {
|
|
97
|
+
if (this.playwright) return this.playwright;
|
|
98
|
+
try {
|
|
99
|
+
this.playwright = await this.playwrightLoader();
|
|
100
|
+
return this.playwright;
|
|
101
|
+
} catch {
|
|
102
|
+
const err = new Error('Playwright is not installed. Run `npm install playwright-core --save-optional` or `darksol browser install`.');
|
|
103
|
+
err.code = 'PLAYWRIGHT_MISSING';
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async launch(opts = {}) {
|
|
109
|
+
if (this.context) {
|
|
110
|
+
throw new Error('Browser is already running. Use `darksol browser status` or `darksol browser close` first.');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const playwright = await this.ensurePlaywrightAvailable();
|
|
114
|
+
this.browserType = opts.type || DEFAULT_BROWSER_TYPE;
|
|
115
|
+
this.profile = opts.profile || DEFAULT_PROFILE;
|
|
116
|
+
this.headed = Boolean(opts.headed);
|
|
117
|
+
const timeout = Number(opts.timeout || DEFAULT_TIMEOUT);
|
|
118
|
+
const browserType = playwright[this.browserType];
|
|
119
|
+
|
|
120
|
+
if (!browserType) {
|
|
121
|
+
throw new Error(`Unsupported browser type: ${this.browserType}. Use chromium, firefox, or webkit.`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const userDataDir = join(BROWSER_PROFILES_DIR, this.profile);
|
|
125
|
+
mkdirSync(userDataDir, { recursive: true });
|
|
126
|
+
|
|
127
|
+
this.context = await browserType.launchPersistentContext(userDataDir, {
|
|
128
|
+
headless: !this.headed,
|
|
129
|
+
});
|
|
130
|
+
this.context.setDefaultTimeout(timeout);
|
|
131
|
+
this.context.setDefaultNavigationTimeout(timeout);
|
|
132
|
+
this.startedAt = new Date().toISOString();
|
|
133
|
+
|
|
134
|
+
this.pages.clear();
|
|
135
|
+
const pages = this.context.pages();
|
|
136
|
+
if (pages.length === 0) {
|
|
137
|
+
const page = await this.context.newPage();
|
|
138
|
+
this.registerPage(page);
|
|
139
|
+
} else {
|
|
140
|
+
pages.forEach((page) => this.registerPage(page));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.context.on('page', (page) => this.registerPage(page));
|
|
144
|
+
this.context.on('close', async () => {
|
|
145
|
+
await this.cleanupRuntimeState();
|
|
146
|
+
await this.stopServer();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await this.persistMetadata();
|
|
150
|
+
return this.getStatus();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
registerPage(page) {
|
|
154
|
+
const pageId = this.pages.size + 1;
|
|
155
|
+
const pageState = { id: pageId, page };
|
|
156
|
+
this.pages.set(pageId, pageState);
|
|
157
|
+
this.currentPageId = pageId;
|
|
158
|
+
|
|
159
|
+
page.on('close', () => {
|
|
160
|
+
this.pages.delete(pageId);
|
|
161
|
+
if (this.currentPageId === pageId) {
|
|
162
|
+
this.currentPageId = this.pages.size ? [...this.pages.keys()].at(-1) : null;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
page.on('crash', async () => {
|
|
167
|
+
await this.cleanupRuntimeState();
|
|
168
|
+
await this.stopServer();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
getCurrentPage() {
|
|
173
|
+
if (!this.currentPageId || !this.pages.has(this.currentPageId)) {
|
|
174
|
+
throw new Error('No active browser page. Launch a browser first.');
|
|
175
|
+
}
|
|
176
|
+
return this.pages.get(this.currentPageId).page;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async navigate(url, opts = {}) {
|
|
180
|
+
const page = this.getCurrentPage();
|
|
181
|
+
const target = normalizeUrl(url);
|
|
182
|
+
try {
|
|
183
|
+
await page.goto(target, {
|
|
184
|
+
waitUntil: opts.waitUntil || 'domcontentloaded',
|
|
185
|
+
timeout: Number(opts.timeout || DEFAULT_TIMEOUT),
|
|
186
|
+
});
|
|
187
|
+
await this.persistMetadata();
|
|
188
|
+
return this.getStatus();
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err?.name === 'TimeoutError') {
|
|
191
|
+
throw new Error(`Navigation timed out after ${Math.round((opts.timeout || DEFAULT_TIMEOUT) / 1000)}s. Try a longer timeout or wait condition.`);
|
|
192
|
+
}
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async click(selector, opts = {}) {
|
|
198
|
+
const page = this.getCurrentPage();
|
|
199
|
+
try {
|
|
200
|
+
await page.waitForSelector(selector, { timeout: Number(opts.timeout || DEFAULT_TIMEOUT), state: 'visible' });
|
|
201
|
+
await page.click(selector, opts);
|
|
202
|
+
await this.persistMetadata();
|
|
203
|
+
return this.getStatus();
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (err?.name === 'TimeoutError') {
|
|
206
|
+
throw new Error(`Selector not found: ${selector}. Check that the selector is correct and the element is visible.`);
|
|
207
|
+
}
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async type(selector, text, opts = {}) {
|
|
213
|
+
const page = this.getCurrentPage();
|
|
214
|
+
try {
|
|
215
|
+
await page.waitForSelector(selector, { timeout: Number(opts.timeout || DEFAULT_TIMEOUT), state: 'visible' });
|
|
216
|
+
await page.fill(selector, '');
|
|
217
|
+
await page.type(selector, text, opts);
|
|
218
|
+
await this.persistMetadata();
|
|
219
|
+
return this.getStatus();
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if (err?.name === 'TimeoutError') {
|
|
222
|
+
throw new Error(`Selector not found: ${selector}. Check that the selector is correct and the element is visible.`);
|
|
223
|
+
}
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async evaluate(expression) {
|
|
229
|
+
const page = this.getCurrentPage();
|
|
230
|
+
const result = await page.evaluate((source) => {
|
|
231
|
+
// eslint-disable-next-line no-eval
|
|
232
|
+
return eval(source);
|
|
233
|
+
}, expression);
|
|
234
|
+
return {
|
|
235
|
+
result,
|
|
236
|
+
formatted: sanitizeEvalResult(result),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async screenshot(opts = {}) {
|
|
241
|
+
const page = this.getCurrentPage();
|
|
242
|
+
const outputPath = resolve(opts.path || `screenshot-${Date.now()}.png`);
|
|
243
|
+
ensureParentDir(outputPath);
|
|
244
|
+
await page.screenshot({
|
|
245
|
+
path: outputPath,
|
|
246
|
+
fullPage: opts.fullPage !== false,
|
|
247
|
+
});
|
|
248
|
+
ensureParentDir(BROWSER_SCREENSHOT_PATH);
|
|
249
|
+
copyFileSync(outputPath, BROWSER_SCREENSHOT_PATH);
|
|
250
|
+
await this.persistMetadata();
|
|
251
|
+
return {
|
|
252
|
+
path: outputPath,
|
|
253
|
+
latest: BROWSER_SCREENSHOT_PATH,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async getStatus() {
|
|
258
|
+
const page = this.currentPageId ? this.pages.get(this.currentPageId)?.page : null;
|
|
259
|
+
const url = page ? page.url() : '';
|
|
260
|
+
let title = '';
|
|
261
|
+
if (page) {
|
|
262
|
+
try {
|
|
263
|
+
title = await page.title();
|
|
264
|
+
} catch {}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
running: Boolean(this.context),
|
|
269
|
+
browserType: this.browserType,
|
|
270
|
+
profile: this.profile,
|
|
271
|
+
headed: this.headed,
|
|
272
|
+
startedAt: this.startedAt,
|
|
273
|
+
currentPageId: this.currentPageId,
|
|
274
|
+
pageCount: this.pages.size,
|
|
275
|
+
url,
|
|
276
|
+
title,
|
|
277
|
+
screenshotPath: (await pathExists(BROWSER_SCREENSHOT_PATH)) ? BROWSER_SCREENSHOT_PATH : null,
|
|
278
|
+
pages: await Promise.all(
|
|
279
|
+
[...this.pages.entries()].map(async ([id, entry]) => ({
|
|
280
|
+
id,
|
|
281
|
+
url: entry.page.url(),
|
|
282
|
+
title: await entry.page.title().catch(() => ''),
|
|
283
|
+
active: id === this.currentPageId,
|
|
284
|
+
})),
|
|
285
|
+
),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async close() {
|
|
290
|
+
if (!this.context) {
|
|
291
|
+
throw new Error('Browser is not running.');
|
|
292
|
+
}
|
|
293
|
+
const context = this.context;
|
|
294
|
+
await context.close();
|
|
295
|
+
await this.cleanupRuntimeState();
|
|
296
|
+
await this.stopServer();
|
|
297
|
+
return { running: false };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async cleanupRuntimeState() {
|
|
301
|
+
this.context = null;
|
|
302
|
+
this.pages.clear();
|
|
303
|
+
this.currentPageId = null;
|
|
304
|
+
this.startedAt = null;
|
|
305
|
+
rmSync(BROWSER_METADATA_PATH, { force: true });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async persistMetadata() {
|
|
309
|
+
const status = await this.getStatus();
|
|
310
|
+
ensureParentDir(BROWSER_METADATA_PATH);
|
|
311
|
+
await import('node:fs/promises').then(({ writeFile }) => writeFile(BROWSER_METADATA_PATH, JSON.stringify(status, null, 2)));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async startServer(pipePath = BROWSER_PIPE_PATH) {
|
|
315
|
+
if (this.server) return pipePath;
|
|
316
|
+
|
|
317
|
+
if (process.platform !== 'win32' && existsSync(pipePath)) {
|
|
318
|
+
rmSync(pipePath, { force: true });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
this.server = net.createServer((socket) => {
|
|
322
|
+
socket.setEncoding('utf8');
|
|
323
|
+
let buffer = '';
|
|
324
|
+
socket.on('data', async (chunk) => {
|
|
325
|
+
buffer += chunk;
|
|
326
|
+
let index = buffer.indexOf('\n');
|
|
327
|
+
while (index !== -1) {
|
|
328
|
+
const line = buffer.slice(0, index).trim();
|
|
329
|
+
buffer = buffer.slice(index + 1);
|
|
330
|
+
if (line) {
|
|
331
|
+
const message = JSON.parse(line);
|
|
332
|
+
const response = await this.handleIpcMessage(message);
|
|
333
|
+
socket.write(`${JSON.stringify(response)}\n`);
|
|
334
|
+
}
|
|
335
|
+
index = buffer.indexOf('\n');
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
341
|
+
this.server.once('error', rejectPromise);
|
|
342
|
+
this.server.listen(pipePath, () => {
|
|
343
|
+
this.server.off('error', rejectPromise);
|
|
344
|
+
resolvePromise();
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
this.keepAlivePromise = new Promise((resolvePromise) => {
|
|
349
|
+
this.keepAliveResolve = resolvePromise;
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
return pipePath;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async stopServer() {
|
|
356
|
+
if (!this.server || this.serverClosing) return;
|
|
357
|
+
this.serverClosing = true;
|
|
358
|
+
await new Promise((resolvePromise) => this.server.close(() => resolvePromise()));
|
|
359
|
+
this.server = null;
|
|
360
|
+
this.serverClosing = false;
|
|
361
|
+
if (process.platform !== 'win32' && existsSync(BROWSER_PIPE_PATH)) {
|
|
362
|
+
rmSync(BROWSER_PIPE_PATH, { force: true });
|
|
363
|
+
}
|
|
364
|
+
if (this.keepAliveResolve) {
|
|
365
|
+
this.keepAliveResolve();
|
|
366
|
+
this.keepAliveResolve = null;
|
|
367
|
+
this.keepAlivePromise = null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
waitUntilClosed() {
|
|
372
|
+
return this.keepAlivePromise || Promise.resolve();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async handleIpcMessage(message) {
|
|
376
|
+
try {
|
|
377
|
+
const result = await this.dispatch(message.action, message.args || {});
|
|
378
|
+
return { ok: true, id: message.id, result };
|
|
379
|
+
} catch (err) {
|
|
380
|
+
return { ok: false, id: message.id, error: createErrorPayload(err) };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async dispatch(action, args) {
|
|
385
|
+
switch (action) {
|
|
386
|
+
case 'status':
|
|
387
|
+
return this.getStatus();
|
|
388
|
+
case 'navigate':
|
|
389
|
+
return this.navigate(args.url, args);
|
|
390
|
+
case 'click':
|
|
391
|
+
return this.click(args.selector, args);
|
|
392
|
+
case 'type':
|
|
393
|
+
return this.type(args.selector, args.text, args);
|
|
394
|
+
case 'eval':
|
|
395
|
+
return this.evaluate(args.expression);
|
|
396
|
+
case 'screenshot':
|
|
397
|
+
return this.screenshot(args);
|
|
398
|
+
case 'close':
|
|
399
|
+
return this.close();
|
|
400
|
+
default:
|
|
401
|
+
throw new Error(`Unknown browser action: ${action}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function browserServiceAvailable(pipePath = BROWSER_PIPE_PATH) {
|
|
407
|
+
try {
|
|
408
|
+
await sendBrowserCommand('status', {}, { pipePath, timeout: 500 });
|
|
409
|
+
return true;
|
|
410
|
+
} catch {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export async function sendBrowserCommand(action, args = {}, opts = {}) {
|
|
416
|
+
const pipePath = opts.pipePath || BROWSER_PIPE_PATH;
|
|
417
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
418
|
+
const socket = net.createConnection(pipePath);
|
|
419
|
+
const timeout = setTimeout(() => {
|
|
420
|
+
socket.destroy();
|
|
421
|
+
rejectPromise(browserNotRunningError());
|
|
422
|
+
}, opts.timeout || DEFAULT_TIMEOUT);
|
|
423
|
+
|
|
424
|
+
let buffer = '';
|
|
425
|
+
socket.setEncoding('utf8');
|
|
426
|
+
|
|
427
|
+
socket.once('error', () => {
|
|
428
|
+
clearTimeout(timeout);
|
|
429
|
+
rejectPromise(browserNotRunningError());
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
socket.on('data', (chunk) => {
|
|
433
|
+
buffer += chunk;
|
|
434
|
+
const newline = buffer.indexOf('\n');
|
|
435
|
+
if (newline === -1) return;
|
|
436
|
+
|
|
437
|
+
clearTimeout(timeout);
|
|
438
|
+
const line = buffer.slice(0, newline);
|
|
439
|
+
socket.end();
|
|
440
|
+
|
|
441
|
+
const response = JSON.parse(line);
|
|
442
|
+
if (!response.ok) {
|
|
443
|
+
const err = new Error(response.error?.message || 'Browser command failed');
|
|
444
|
+
err.code = response.error?.code;
|
|
445
|
+
rejectPromise(err);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
resolvePromise(response.result);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
socket.on('connect', () => {
|
|
452
|
+
socket.write(`${JSON.stringify({ id: `${Date.now()}`, action, args })}\n`);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function promptToInstallPlaywright() {
|
|
458
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
459
|
+
const inquirer = (await import('inquirer')).default;
|
|
460
|
+
const { install } = await inquirer.prompt([{
|
|
461
|
+
type: 'confirm',
|
|
462
|
+
name: 'install',
|
|
463
|
+
message: theme.gold('Playwright is not installed. Install `playwright-core` now?'),
|
|
464
|
+
default: false,
|
|
465
|
+
}]);
|
|
466
|
+
return install;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export async function installPlaywrightBrowsers() {
|
|
470
|
+
const spin = spinner('Installing Chromium for Playwright...').start();
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
474
|
+
const runner = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
475
|
+
const child = spawn(runner, ['playwright', 'install', 'chromium'], {
|
|
476
|
+
stdio: 'inherit',
|
|
477
|
+
});
|
|
478
|
+
child.on('exit', (code) => {
|
|
479
|
+
if (code === 0) resolvePromise();
|
|
480
|
+
else rejectPromise(new Error(`Playwright install exited with code ${code}`));
|
|
481
|
+
});
|
|
482
|
+
child.on('error', rejectPromise);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
spin.succeed('Chromium installed for Playwright');
|
|
486
|
+
} catch (err) {
|
|
487
|
+
spin.fail('Playwright install failed');
|
|
488
|
+
throw err;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export async function startBrowserService(opts = {}) {
|
|
493
|
+
const controller = new BrowserController();
|
|
494
|
+
try {
|
|
495
|
+
await controller.launch(opts);
|
|
496
|
+
} catch (err) {
|
|
497
|
+
if (err.code === 'PLAYWRIGHT_MISSING' && await promptToInstallPlaywright()) {
|
|
498
|
+
await installPlaywrightBrowsers().catch(() => {});
|
|
499
|
+
}
|
|
500
|
+
throw err;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
await controller.startServer();
|
|
504
|
+
|
|
505
|
+
process.once('SIGINT', async () => {
|
|
506
|
+
if (controller.context) {
|
|
507
|
+
await controller.close().catch(() => {});
|
|
508
|
+
} else {
|
|
509
|
+
await controller.stopServer().catch(() => {});
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
return controller;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export async function ensureBrowserStatus() {
|
|
517
|
+
return sendBrowserCommand('status');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export async function showBrowserStatus() {
|
|
521
|
+
try {
|
|
522
|
+
const status = await ensureBrowserStatus();
|
|
523
|
+
showSection('BROWSER STATUS');
|
|
524
|
+
kvDisplay([
|
|
525
|
+
['Running', status.running ? theme.success('yes') : theme.dim('no')],
|
|
526
|
+
['Type', status.browserType || theme.dim('(unknown)')],
|
|
527
|
+
['Profile', status.profile || theme.dim('(default)')],
|
|
528
|
+
['Mode', status.headed ? 'headed' : 'headless'],
|
|
529
|
+
['Page', status.url || theme.dim('(blank)')],
|
|
530
|
+
['Title', status.title || theme.dim('(none)')],
|
|
531
|
+
['Pages', String(status.pageCount || 0)],
|
|
532
|
+
]);
|
|
533
|
+
console.log('');
|
|
534
|
+
return status;
|
|
535
|
+
} catch (err) {
|
|
536
|
+
error(err.message);
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export async function launchBrowserCommand(opts = {}) {
|
|
542
|
+
if (await browserServiceAvailable()) {
|
|
543
|
+
warn('Browser service is already running.');
|
|
544
|
+
await showBrowserStatus();
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const spin = spinner(`Launching ${opts.type || DEFAULT_BROWSER_TYPE} browser...`).start();
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
const controller = await startBrowserService(opts);
|
|
552
|
+
spin.succeed('Browser launched');
|
|
553
|
+
|
|
554
|
+
const status = await controller.getStatus();
|
|
555
|
+
info(`Browser service listening on ${BROWSER_PIPE_PATH}`);
|
|
556
|
+
info(`Profile: ${status.profile} | Mode: ${status.headed ? 'headed' : 'headless'}`);
|
|
557
|
+
console.log('');
|
|
558
|
+
await controller.waitUntilClosed();
|
|
559
|
+
} catch (err) {
|
|
560
|
+
spin.fail('Browser launch failed');
|
|
561
|
+
error(err.message);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export async function navigateBrowserCommand(url, opts = {}) {
|
|
566
|
+
const spin = spinner(`Navigating to ${url}...`).start();
|
|
567
|
+
try {
|
|
568
|
+
const status = await sendBrowserCommand('navigate', { url, timeout: opts.timeout });
|
|
569
|
+
spin.succeed('Navigation complete');
|
|
570
|
+
success(status.url || url);
|
|
571
|
+
return status;
|
|
572
|
+
} catch (err) {
|
|
573
|
+
spin.fail('Navigation failed');
|
|
574
|
+
error(err.message);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export async function browserScreenshotCommand(filename) {
|
|
579
|
+
const spin = spinner('Capturing screenshot...').start();
|
|
580
|
+
try {
|
|
581
|
+
const result = await sendBrowserCommand('screenshot', {
|
|
582
|
+
path: filename ? resolve(filename) : resolve(`screenshot-${Date.now()}.png`),
|
|
583
|
+
});
|
|
584
|
+
spin.succeed('Screenshot saved');
|
|
585
|
+
success(result.path);
|
|
586
|
+
return result;
|
|
587
|
+
} catch (err) {
|
|
588
|
+
spin.fail('Screenshot failed');
|
|
589
|
+
error(err.message);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export async function browserClickCommand(selector) {
|
|
594
|
+
const spin = spinner(`Clicking ${selector}...`).start();
|
|
595
|
+
try {
|
|
596
|
+
await sendBrowserCommand('click', { selector });
|
|
597
|
+
spin.succeed('Click complete');
|
|
598
|
+
success(`Clicked ${selector}`);
|
|
599
|
+
} catch (err) {
|
|
600
|
+
spin.fail('Click failed');
|
|
601
|
+
error(err.message);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export async function browserTypeCommand(selector, text) {
|
|
606
|
+
const spin = spinner(`Typing into ${selector}...`).start();
|
|
607
|
+
try {
|
|
608
|
+
await sendBrowserCommand('type', { selector, text });
|
|
609
|
+
spin.succeed('Text entered');
|
|
610
|
+
success(`Typed into ${selector}`);
|
|
611
|
+
} catch (err) {
|
|
612
|
+
spin.fail('Typing failed');
|
|
613
|
+
error(err.message);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export async function browserEvalCommand(expression) {
|
|
618
|
+
const spin = spinner('Evaluating JavaScript...').start();
|
|
619
|
+
try {
|
|
620
|
+
const result = await sendBrowserCommand('eval', { expression });
|
|
621
|
+
spin.succeed('Evaluation complete');
|
|
622
|
+
showSection('BROWSER EVAL');
|
|
623
|
+
console.log(` ${theme.bright(result.formatted)}`);
|
|
624
|
+
console.log('');
|
|
625
|
+
return result;
|
|
626
|
+
} catch (err) {
|
|
627
|
+
spin.fail('Evaluation failed');
|
|
628
|
+
error(err.message);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export async function browserCloseCommand() {
|
|
633
|
+
const spin = spinner('Closing browser...').start();
|
|
634
|
+
try {
|
|
635
|
+
await sendBrowserCommand('close');
|
|
636
|
+
spin.succeed('Browser closed');
|
|
637
|
+
} catch (err) {
|
|
638
|
+
spin.fail('Close failed');
|
|
639
|
+
error(err.message);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export async function ensurePlaywrightOrExplain() {
|
|
644
|
+
try {
|
|
645
|
+
const controller = new BrowserController();
|
|
646
|
+
await controller.ensurePlaywrightAvailable();
|
|
647
|
+
return true;
|
|
648
|
+
} catch (err) {
|
|
649
|
+
error(err.message);
|
|
650
|
+
info('Install optional dependency: npm install playwright-core --save-optional');
|
|
651
|
+
info('Install browser binary: darksol browser install');
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export const __test = {
|
|
657
|
+
setPlaywrightLoader: setPlaywrightLoaderForTests,
|
|
658
|
+
browserNotRunningError,
|
|
659
|
+
};
|