playwright-repl 0.19.0 → 0.21.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/README.md +28 -28
- package/dist/engine.d.ts +70 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +458 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/playwright-repl.js +5 -1
- package/dist/playwright-repl.js.map +1 -1
- package/dist/repl.d.ts +7 -2
- package/dist/repl.d.ts.map +1 -1
- package/dist/repl.js +57 -119
- package/dist/repl.js.map +1 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# playwright-repl
|
|
1
|
+
# playwright-repl
|
|
2
2
|
|
|
3
|
-
Interactive terminal REPL for browser automation powered by Playwright. Type a command, see the result
|
|
3
|
+
Interactive terminal REPL for browser automation powered by Playwright. Type a command, see the result — no code, no boilerplate.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install -g playwright-repl
|
|
@@ -9,9 +9,9 @@ playwright-repl --headed
|
|
|
9
9
|
|
|
10
10
|
```
|
|
11
11
|
pw> goto https://demo.playwright.dev/todomvc/
|
|
12
|
-
pw> fill "What needs to be done?"
|
|
12
|
+
pw> fill "What needs to be done?" Buy groceries
|
|
13
13
|
pw> press Enter
|
|
14
|
-
pw> verify
|
|
14
|
+
pw> verify text 1 item left
|
|
15
15
|
pw> screenshot
|
|
16
16
|
```
|
|
17
17
|
|
|
@@ -26,10 +26,10 @@ npx playwright install
|
|
|
26
26
|
|
|
27
27
|
## Connection Modes
|
|
28
28
|
|
|
29
|
-
| Mode | Flag |
|
|
30
|
-
|
|
31
|
-
| **Standalone** | *(default)* | Launches
|
|
32
|
-
| **Bridge** | `--bridge` |
|
|
29
|
+
| Mode | Flag | How it works |
|
|
30
|
+
|------|------|--------------|
|
|
31
|
+
| **Standalone** | *(default)* | Launches Chromium via Playwright |
|
|
32
|
+
| **Bridge** | `--bridge` | Connects to your real Chrome via Dramaturg extension — cookies and logins intact |
|
|
33
33
|
|
|
34
34
|
### Standalone
|
|
35
35
|
|
|
@@ -44,18 +44,18 @@ playwright-repl --persistent # keeps profile between sessions
|
|
|
44
44
|
|
|
45
45
|
### Bridge
|
|
46
46
|
|
|
47
|
-
The bridge mode
|
|
47
|
+
The bridge mode turns your terminal into a remote console for the Chrome extension. Commands run inside your real Chrome with your existing cookies and logins.
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
-
playwright-repl --bridge # start bridge server, wait for extension
|
|
50
|
+
playwright-repl --bridge # start bridge server, wait for extension
|
|
51
51
|
playwright-repl --bridge --replay script.pw # replay a script via bridge
|
|
52
52
|
playwright-repl --bridge --replay examples/ # replay all .pw files
|
|
53
53
|
playwright-repl --bridge --bridge-port 9877 # custom port (default 9876)
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
The extension connects automatically — no need to open the side panel.
|
|
56
|
+
The extension connects automatically — no need to open the side panel.
|
|
57
57
|
|
|
58
|
-
> Requires the
|
|
58
|
+
> Requires the [Dramaturg Chrome extension](../extension/README.md).
|
|
59
59
|
|
|
60
60
|
## Usage
|
|
61
61
|
|
|
@@ -86,10 +86,10 @@ echo -e "goto https://example.com\nsnapshot" | playwright-repl
|
|
|
86
86
|
|
|
87
87
|
| Mode | Standalone | Bridge |
|
|
88
88
|
|------|:---:|:---:|
|
|
89
|
-
| **Keyword** — `click "Sign in"`, `goto https://...` |
|
|
90
|
-
| **Playwright API / JS** — `await page.title()`, `1 + 1` |
|
|
89
|
+
| **Keyword** — `click "Sign in"`, `goto https://...` | Yes | Yes |
|
|
90
|
+
| **Playwright API / JS** — `await page.title()`, `1 + 1` | No | Yes (auto-detected) |
|
|
91
91
|
|
|
92
|
-
Bridge mode auto-detects Playwright API and JavaScript expressions
|
|
92
|
+
Bridge mode auto-detects Playwright API and JavaScript expressions. For DOM access use `await page.evaluate(() => document.title)`. For keyword commands, see [Command Reference](#command-reference).
|
|
93
93
|
|
|
94
94
|
## CLI Options
|
|
95
95
|
|
|
@@ -127,7 +127,7 @@ Bridge mode auto-detects Playwright API and JavaScript expressions — just type
|
|
|
127
127
|
|
|
128
128
|
## Recording & Replay
|
|
129
129
|
|
|
130
|
-
Record
|
|
130
|
+
Record browser interactions and replay them later — great for regression tests, onboarding demos, or CI smoke tests.
|
|
131
131
|
|
|
132
132
|
### Record
|
|
133
133
|
|
|
@@ -139,9 +139,9 @@ playwright-repl --record my-test.pw --headed
|
|
|
139
139
|
pw> .record my-test
|
|
140
140
|
⏺ Recording to my-test.pw
|
|
141
141
|
pw> goto https://demo.playwright.dev/todomvc/
|
|
142
|
-
pw> fill "What needs to be done?"
|
|
142
|
+
pw> fill "What needs to be done?" Buy groceries
|
|
143
143
|
pw> press Enter
|
|
144
|
-
pw> verify
|
|
144
|
+
pw> verify text 1 item left
|
|
145
145
|
pw> .save
|
|
146
146
|
✓ Saved 4 commands to my-test.pw
|
|
147
147
|
```
|
|
@@ -162,7 +162,7 @@ playwright-repl --replay examples/ --silent
|
|
|
162
162
|
pw> .replay my-test.pw
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
-
Multi-file replay runs all files sequentially, writes a `replay-<timestamp>.log
|
|
165
|
+
Multi-file replay runs all files sequentially, writes a `replay-<timestamp>.log`, and prints a pass/fail summary. Exit code 0 if all pass, 1 if any fail.
|
|
166
166
|
|
|
167
167
|
### .pw File Format
|
|
168
168
|
|
|
@@ -173,10 +173,10 @@ Plain text — human-readable, diffable, version-controllable:
|
|
|
173
173
|
# App: https://demo.playwright.dev/todomvc/
|
|
174
174
|
|
|
175
175
|
goto https://demo.playwright.dev/todomvc/
|
|
176
|
-
fill "What needs to be done?"
|
|
176
|
+
fill "What needs to be done?" Buy groceries
|
|
177
177
|
press Enter
|
|
178
|
-
verify
|
|
179
|
-
verify
|
|
178
|
+
verify text Buy groceries
|
|
179
|
+
verify text 1 item left
|
|
180
180
|
```
|
|
181
181
|
|
|
182
182
|
## Examples
|
|
@@ -200,7 +200,7 @@ playwright-repl --replay examples/ --silent
|
|
|
200
200
|
## Requirements
|
|
201
201
|
|
|
202
202
|
- Node.js >= 20
|
|
203
|
-
- `playwright` >= 1.59
|
|
203
|
+
- `playwright` >= 1.59
|
|
204
204
|
|
|
205
205
|
---
|
|
206
206
|
|
|
@@ -273,11 +273,11 @@ pw> highlight --clear # dismiss the highlight overlay
|
|
|
273
273
|
|
|
274
274
|
| Command | Alias | Description |
|
|
275
275
|
|---------|-------|-------------|
|
|
276
|
-
| `verify
|
|
277
|
-
| `verify
|
|
278
|
-
| `verify
|
|
279
|
-
| `verify
|
|
280
|
-
| `verify
|
|
276
|
+
| `verify text <text>` | `vt` | Verify text is visible on page |
|
|
277
|
+
| `verify no-text <text>` | `vnt` | Verify text is not visible |
|
|
278
|
+
| `verify element <role> <name>` | `ve` | Verify element exists by role and name |
|
|
279
|
+
| `verify value <ref> <value>` | `vv` | Verify input/select/checkbox value |
|
|
280
|
+
| `verify list <ref> <items>` | `vl` | Verify list contains expected items |
|
|
281
281
|
|
|
282
282
|
### Tabs
|
|
283
283
|
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine — in-process Playwright backend.
|
|
3
|
+
*
|
|
4
|
+
* Wraps BrowserServerBackend directly, eliminating the daemon process.
|
|
5
|
+
* Provides the same interface as DaemonConnection: run(args), connected, close().
|
|
6
|
+
*
|
|
7
|
+
* Three connection modes:
|
|
8
|
+
* - launch: new browser via Playwright (default)
|
|
9
|
+
* - connect: existing Chrome via CDP port (--connect [port])
|
|
10
|
+
* - extension: DevTools extension CDP relay (--extension)
|
|
11
|
+
*/
|
|
12
|
+
import type { EngineOpts, EngineResult, ParsedArgs } from '@playwright-repl/core';
|
|
13
|
+
export type { EngineOpts, EngineResult, ParsedArgs };
|
|
14
|
+
interface PlaywrightDeps {
|
|
15
|
+
BrowserServerBackend: new (config: any, factory: any, opts: any) => any;
|
|
16
|
+
contextFactory: (config: any) => {
|
|
17
|
+
createContext: (info: any, signal: AbortSignal, opts: any) => Promise<{
|
|
18
|
+
browserContext: any;
|
|
19
|
+
close: () => Promise<void>;
|
|
20
|
+
}>;
|
|
21
|
+
};
|
|
22
|
+
playwright: any;
|
|
23
|
+
registry: {
|
|
24
|
+
findExecutable: (name: string) => {
|
|
25
|
+
executablePath: () => string | undefined;
|
|
26
|
+
} | undefined;
|
|
27
|
+
};
|
|
28
|
+
resolveConfig: (config: any) => Promise<any> | any;
|
|
29
|
+
commands: Record<string, any>;
|
|
30
|
+
parseCommand: (command: any, args: any) => {
|
|
31
|
+
toolName: string;
|
|
32
|
+
toolParams: Record<string, any>;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export declare class Engine {
|
|
36
|
+
private _deps;
|
|
37
|
+
private _backend;
|
|
38
|
+
private _browserContext;
|
|
39
|
+
private _close;
|
|
40
|
+
private _connected;
|
|
41
|
+
private _commandServer;
|
|
42
|
+
private _chromeProc;
|
|
43
|
+
private _isReconnecting;
|
|
44
|
+
private _reconnectInfo;
|
|
45
|
+
constructor(deps?: PlaywrightDeps);
|
|
46
|
+
get connected(): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Start the engine with given options.
|
|
49
|
+
*/
|
|
50
|
+
start(opts?: EngineOpts): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Run a command given minimist-parsed args.
|
|
53
|
+
* Returns { text, isError } matching DaemonConnection.run() shape.
|
|
54
|
+
*/
|
|
55
|
+
run(args: ParsedArgs): Promise<EngineResult>;
|
|
56
|
+
/**
|
|
57
|
+
* Select the Playwright page matching the given URL.
|
|
58
|
+
* Uses backend.callTool('browser_tabs') to properly update the tab tracker.
|
|
59
|
+
*/
|
|
60
|
+
selectPageByUrl(targetUrl: string): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Shut down the browser and backend.
|
|
63
|
+
*/
|
|
64
|
+
close(): Promise<void>;
|
|
65
|
+
private _connectToCdp;
|
|
66
|
+
private _scheduleReconnect;
|
|
67
|
+
private _doReconnect;
|
|
68
|
+
private _buildConfig;
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAMH,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAElF,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,UAAU,EAAE,CAAC;AAGrD,UAAU,cAAc;IACtB,oBAAoB,EAAE,KAAK,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,GAAG,KAAK,GAAG,CAAC;IACxE,cAAc,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK;QAAE,aAAa,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,GAAG,KAAK,OAAO,CAAC;YAAE,cAAc,EAAE,GAAG,CAAC;YAAC,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;SAAE,CAAC,CAAA;KAAE,CAAC;IAChK,UAAU,EAAE,GAAG,CAAC;IAChB,QAAQ,EAAE;QAAE,cAAc,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;YAAE,cAAc,EAAE,MAAM,MAAM,GAAG,SAAS,CAAA;SAAE,GAAG,SAAS,CAAA;KAAE,CAAC;IACzG,aAAa,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;IACnD,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAC9B,YAAY,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,GAAG,KAAK;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;KAAE,CAAC;CAClG;AA6BD,qBAAa,MAAM;IAEjB,OAAO,CAAC,KAAK,CAA6B;IAC1C,OAAO,CAAC,QAAQ,CAAa;IAC7B,OAAO,CAAC,eAAe,CAAa;IACpC,OAAO,CAAC,MAAM,CAAsC;IACpD,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,cAAc,CAA+C;IACrE,OAAO,CAAC,WAAW,CAAwD;IAC3E,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,cAAc,CAAmE;gBAG7E,IAAI,CAAC,EAAE,cAAc;IAIjC,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED;;OAEG;IACG,KAAK,CAAC,IAAI,GAAE,UAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAsGjD;;;OAGG;IACG,GAAG,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC;IAiFlD;;;OAGG;IACG,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAoBvD;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAuBd,aAAa;IAuC3B,OAAO,CAAC,kBAAkB;YAQZ,YAAY;YAsDZ,YAAY;CA0D3B"}
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine — in-process Playwright backend.
|
|
3
|
+
*
|
|
4
|
+
* Wraps BrowserServerBackend directly, eliminating the daemon process.
|
|
5
|
+
* Provides the same interface as DaemonConnection: run(args), connected, close().
|
|
6
|
+
*
|
|
7
|
+
* Three connection modes:
|
|
8
|
+
* - launch: new browser via Playwright (default)
|
|
9
|
+
* - connect: existing Chrome via CDP port (--connect [port])
|
|
10
|
+
* - extension: DevTools extension CDP relay (--extension)
|
|
11
|
+
*/
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import url from 'node:url';
|
|
15
|
+
import { replVersion } from '@playwright-repl/core';
|
|
16
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
17
|
+
// ─── Lazy-loaded Playwright dependencies ────────────────────────────────────
|
|
18
|
+
let _deps;
|
|
19
|
+
function loadDeps() {
|
|
20
|
+
if (_deps)
|
|
21
|
+
return _deps;
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
// Resolve absolute paths to bypass Playwright's exports map.
|
|
24
|
+
const pwDir = path.dirname(require.resolve('playwright/package.json'));
|
|
25
|
+
const pwReq = (sub) => require(path.join(pwDir, sub));
|
|
26
|
+
const pwCoreDir = path.dirname(require.resolve('playwright-core/package.json'));
|
|
27
|
+
const pwCoreReq = (sub) => require(path.join(pwCoreDir, sub));
|
|
28
|
+
_deps = {
|
|
29
|
+
BrowserServerBackend: pwReq('lib/mcp/browser/browserServerBackend.js').BrowserServerBackend,
|
|
30
|
+
contextFactory: pwReq('lib/mcp/browser/browserContextFactory.js').contextFactory,
|
|
31
|
+
playwright: require('playwright-core'),
|
|
32
|
+
registry: pwCoreReq('lib/server/registry/index.js').registry,
|
|
33
|
+
resolveConfig: pwReq('lib/mcp/browser/config.js').resolveConfig,
|
|
34
|
+
commands: pwReq('lib/cli/daemon/commands.js').commands,
|
|
35
|
+
parseCommand: pwReq('lib/cli/daemon/command.js').parseCommand,
|
|
36
|
+
};
|
|
37
|
+
return _deps;
|
|
38
|
+
}
|
|
39
|
+
// ─── Engine ─────────────────────────────────────────────────────────────────
|
|
40
|
+
export class Engine {
|
|
41
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
42
|
+
_deps;
|
|
43
|
+
_backend = null;
|
|
44
|
+
_browserContext = null;
|
|
45
|
+
_close = null;
|
|
46
|
+
_connected = false;
|
|
47
|
+
_commandServer = null;
|
|
48
|
+
_chromeProc = null;
|
|
49
|
+
_isReconnecting = false;
|
|
50
|
+
_reconnectInfo = null;
|
|
51
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
52
|
+
constructor(deps) {
|
|
53
|
+
this._deps = deps;
|
|
54
|
+
}
|
|
55
|
+
get connected() {
|
|
56
|
+
return this._connected;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Start the engine with given options.
|
|
60
|
+
*/
|
|
61
|
+
async start(opts = {}) {
|
|
62
|
+
const deps = this._deps || loadDeps();
|
|
63
|
+
const config = await this._buildConfig(opts, deps);
|
|
64
|
+
const cwd = url.pathToFileURL(process.cwd()).href;
|
|
65
|
+
const clientInfo = {
|
|
66
|
+
name: 'playwright-repl',
|
|
67
|
+
version: replVersion,
|
|
68
|
+
roots: [{ uri: cwd, name: 'cwd' }],
|
|
69
|
+
timestamp: Date.now(),
|
|
70
|
+
};
|
|
71
|
+
// Choose context factory based on mode.
|
|
72
|
+
if (opts.extension) {
|
|
73
|
+
const serverPort = opts.port || 6781;
|
|
74
|
+
const cdpPort = opts.cdpPort || 9222;
|
|
75
|
+
// 1. Start CommandServer for panel HTTP commands.
|
|
76
|
+
const { CommandServer } = await import('@playwright-repl/core');
|
|
77
|
+
const cmdServer = new CommandServer(this);
|
|
78
|
+
await cmdServer.start(serverPort);
|
|
79
|
+
this._commandServer = cmdServer;
|
|
80
|
+
console.log(`CommandServer listening on http://localhost:${serverPort}`);
|
|
81
|
+
// 2. Spawn Chrome (only with --spawn).
|
|
82
|
+
if (opts.spawn) {
|
|
83
|
+
const extPath = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '../../extension/dist');
|
|
84
|
+
const execInfo = deps.registry.findExecutable(opts.browser || 'chrome');
|
|
85
|
+
const execPath = execInfo?.executablePath();
|
|
86
|
+
if (!execPath)
|
|
87
|
+
throw new Error('Chrome executable not found. Make sure Chrome is installed.');
|
|
88
|
+
// Chrome 136+ requires --user-data-dir for CDP. Use a dedicated profile dir.
|
|
89
|
+
const os = await import('node:os');
|
|
90
|
+
const fs = await import('node:fs');
|
|
91
|
+
const userDataDir = opts.profile || path.join(os.default.homedir(), '.playwright-repl', 'chrome-profile');
|
|
92
|
+
fs.default.mkdirSync(userDataDir, { recursive: true });
|
|
93
|
+
const chromeArgs = [
|
|
94
|
+
`--remote-debugging-port=${cdpPort}`,
|
|
95
|
+
`--user-data-dir=${userDataDir}`,
|
|
96
|
+
`--load-extension=${extPath}`,
|
|
97
|
+
'--no-first-run',
|
|
98
|
+
'--no-default-browser-check',
|
|
99
|
+
];
|
|
100
|
+
const { spawn } = await import('node:child_process');
|
|
101
|
+
const chromeProc = spawn(execPath, chromeArgs, {
|
|
102
|
+
detached: true, stdio: 'ignore',
|
|
103
|
+
});
|
|
104
|
+
chromeProc.unref();
|
|
105
|
+
this._chromeProc = chromeProc;
|
|
106
|
+
console.log(`Chrome profile: ${userDataDir}`);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
console.log('Connecting to existing Chrome on port ' + cdpPort + ' (use --spawn to launch Chrome automatically)');
|
|
110
|
+
}
|
|
111
|
+
// 3. Wait for Chrome CDP to be ready (30s timeout).
|
|
112
|
+
console.log('Waiting for Chrome CDP...');
|
|
113
|
+
const cdpUrl = `http://localhost:${cdpPort}`;
|
|
114
|
+
const cdpTimeout = 30_000;
|
|
115
|
+
const cdpStart = Date.now();
|
|
116
|
+
while (true) {
|
|
117
|
+
if (Date.now() - cdpStart > cdpTimeout) {
|
|
118
|
+
throw new Error(`Timeout: Chrome CDP not available at ${cdpUrl} after ${cdpTimeout / 1000}s`);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const res = await fetch(`${cdpUrl}/json/version`);
|
|
122
|
+
if (res.ok)
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
catch { /* retry */ }
|
|
126
|
+
await new Promise(r => setTimeout(r, 500));
|
|
127
|
+
}
|
|
128
|
+
console.log('Chrome CDP ready. Connecting Playwright...');
|
|
129
|
+
// 4. Connect Playwright via CDP.
|
|
130
|
+
config.browser.cdpEndpoint = cdpUrl;
|
|
131
|
+
this._reconnectInfo = { opts, config, clientInfo };
|
|
132
|
+
await this._connectToCdp(deps, config, clientInfo);
|
|
133
|
+
console.log('Ready! Side panel can send commands.');
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
// Launch/connect mode: eagerly create context for immediate feedback.
|
|
137
|
+
const factory = deps.contextFactory(config);
|
|
138
|
+
const { browserContext, close } = await factory.createContext(clientInfo, new AbortController().signal, {});
|
|
139
|
+
this._browserContext = browserContext;
|
|
140
|
+
this._close = close;
|
|
141
|
+
const existingContextFactory = {
|
|
142
|
+
createContext: () => Promise.resolve({ browserContext, close }),
|
|
143
|
+
};
|
|
144
|
+
this._backend = new deps.BrowserServerBackend(config, existingContextFactory, { allTools: true });
|
|
145
|
+
await this._backend.initialize?.(clientInfo);
|
|
146
|
+
this._connected = true;
|
|
147
|
+
browserContext.on('close', () => {
|
|
148
|
+
this._connected = false;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Run a command given minimist-parsed args.
|
|
154
|
+
* Returns { text, isError } matching DaemonConnection.run() shape.
|
|
155
|
+
*/
|
|
156
|
+
async run(args) {
|
|
157
|
+
if (!this._backend)
|
|
158
|
+
throw new Error('Engine not started');
|
|
159
|
+
// ── highlight → run-code translation ──
|
|
160
|
+
if (args._[0] === 'highlight') {
|
|
161
|
+
if (args.clear) {
|
|
162
|
+
args = { _: ['run-code', `async (page) => { await page.locator('#__pw_clear__').highlight().catch(() => {}); return "Cleared"; }`] };
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
const parts = args._.slice(1);
|
|
166
|
+
if (!parts.length)
|
|
167
|
+
return { text: 'Usage: highlight <locator>', isError: true };
|
|
168
|
+
// highlight <ref> → use aria-ref selector
|
|
169
|
+
if (parts.length === 1 && /^e\d+$/.test(parts[0])) {
|
|
170
|
+
args = { _: ['run-code', `async (page) => { await page.locator('aria-ref=${parts[0]}').highlight(); return "Highlighted"; }`] };
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const nth = args.nth !== undefined ? parseInt(String(args.nth), 10) : undefined;
|
|
174
|
+
let locExpr;
|
|
175
|
+
// highlight <role> "<name>" → getByRole(role, { name })
|
|
176
|
+
if (parts.length >= 2 && /^[a-z]+$/.test(parts[0])) {
|
|
177
|
+
const role = parts[0];
|
|
178
|
+
const name = parts.slice(1).join(' ');
|
|
179
|
+
locExpr = `page.getByRole(${JSON.stringify(role)}, { name: ${JSON.stringify(name)}, exact: true })`;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
const loc = parts.join(' ');
|
|
183
|
+
const isSelector = /[.#\[\]>:=]/.test(loc);
|
|
184
|
+
locExpr = isSelector
|
|
185
|
+
? `page.locator(${JSON.stringify(loc)})`
|
|
186
|
+
: `page.getByText(${JSON.stringify(loc)})`;
|
|
187
|
+
}
|
|
188
|
+
if (nth !== undefined)
|
|
189
|
+
locExpr += `.nth(${nth})`;
|
|
190
|
+
args = { _: ['run-code', `async (page) => { await ${locExpr}.highlight(); return "Highlighted"; }`] };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// ── >> chaining → run-code translation ──
|
|
195
|
+
const LOCATOR_ACTIONS = {
|
|
196
|
+
click: 'click', dblclick: 'dblclick', hover: 'hover',
|
|
197
|
+
check: 'check', uncheck: 'uncheck',
|
|
198
|
+
fill: 'fill', select: 'selectOption',
|
|
199
|
+
};
|
|
200
|
+
if (LOCATOR_ACTIONS[args._[0]] && args._.some(a => a.includes('>>'))) {
|
|
201
|
+
const action = LOCATOR_ACTIONS[args._[0]];
|
|
202
|
+
const positional = args._.slice(1);
|
|
203
|
+
// Find last >> — everything up to the token after it is the selector,
|
|
204
|
+
// everything after that is the action argument (e.g., value for fill).
|
|
205
|
+
let lastChainIdx = -1;
|
|
206
|
+
for (let i = 0; i < positional.length; i++) {
|
|
207
|
+
if (positional[i] === '>>' || positional[i].includes('>>'))
|
|
208
|
+
lastChainIdx = i;
|
|
209
|
+
}
|
|
210
|
+
const selectorEnd = positional[lastChainIdx] !== '>>' && positional[lastChainIdx]?.includes('>>')
|
|
211
|
+
? lastChainIdx // >> inside quoted token like ".nav >> button"
|
|
212
|
+
: lastChainIdx + 1;
|
|
213
|
+
const selector = positional.slice(0, selectorEnd + 1).join(' ');
|
|
214
|
+
const rest = positional.slice(selectorEnd + 1).join(' ');
|
|
215
|
+
const locExpr = `page.locator(${JSON.stringify(selector)})`;
|
|
216
|
+
const actionCall = rest
|
|
217
|
+
? `${locExpr}.${action}(${JSON.stringify(rest)})`
|
|
218
|
+
: `${locExpr}.${action}()`;
|
|
219
|
+
args = { _: ['run-code', `async (page) => { await ${actionCall}; return "Done"; }`] };
|
|
220
|
+
}
|
|
221
|
+
const deps = this._deps || loadDeps();
|
|
222
|
+
const command = deps.commands[args._[0]];
|
|
223
|
+
if (!command)
|
|
224
|
+
throw new Error(`Unknown command: ${args._[0]}`);
|
|
225
|
+
const { toolName, toolParams } = deps.parseCommand(command, args);
|
|
226
|
+
// Commands like "close", "list", "kill-all" have empty toolName.
|
|
227
|
+
if (!toolName)
|
|
228
|
+
return { text: `Command "${args._[0]}" is not supported in engine mode.`, isError: true };
|
|
229
|
+
toolParams._meta = { cwd: args.cwd || process.cwd() };
|
|
230
|
+
const response = await this._backend.callTool(toolName, toolParams);
|
|
231
|
+
return formatResult(response);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Select the Playwright page matching the given URL.
|
|
235
|
+
* Uses backend.callTool('browser_tabs') to properly update the tab tracker.
|
|
236
|
+
*/
|
|
237
|
+
async selectPageByUrl(targetUrl) {
|
|
238
|
+
if (!this._browserContext || !this._backend || !targetUrl)
|
|
239
|
+
return;
|
|
240
|
+
const pages = this._browserContext.pages();
|
|
241
|
+
const normalize = (u) => {
|
|
242
|
+
try {
|
|
243
|
+
const p = new URL(u);
|
|
244
|
+
return (p.origin + p.pathname).replace(/\/+$/, '');
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return u.replace(/\/+$/, '');
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
const target = normalize(targetUrl);
|
|
251
|
+
for (let i = 0; i < pages.length; i++) {
|
|
252
|
+
if (normalize(pages[i].url()) === target) {
|
|
253
|
+
try {
|
|
254
|
+
await this._backend.callTool('browser_tabs', { action: 'select', index: i });
|
|
255
|
+
}
|
|
256
|
+
catch { /* ignore */ }
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Shut down the browser and backend.
|
|
263
|
+
*/
|
|
264
|
+
async close() {
|
|
265
|
+
this._connected = false;
|
|
266
|
+
if (this._commandServer) {
|
|
267
|
+
await this._commandServer.close();
|
|
268
|
+
this._commandServer = null;
|
|
269
|
+
}
|
|
270
|
+
if (this._backend) {
|
|
271
|
+
this._backend.serverClosed();
|
|
272
|
+
this._backend = null;
|
|
273
|
+
}
|
|
274
|
+
if (this._close) {
|
|
275
|
+
await this._close();
|
|
276
|
+
this._close = null;
|
|
277
|
+
}
|
|
278
|
+
if (this._chromeProc) {
|
|
279
|
+
try {
|
|
280
|
+
this._chromeProc.kill();
|
|
281
|
+
}
|
|
282
|
+
catch { /* ignore */ }
|
|
283
|
+
this._chromeProc = null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// ─── CDP connect / reconnect ─────────────────────────────────────────────
|
|
287
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
288
|
+
async _connectToCdp(deps, config, clientInfo) {
|
|
289
|
+
const factory = deps.contextFactory(config);
|
|
290
|
+
const { browserContext, close } = await factory.createContext(clientInfo, new AbortController().signal, {});
|
|
291
|
+
this._browserContext = browserContext;
|
|
292
|
+
this._close = close;
|
|
293
|
+
const existingContextFactory = {
|
|
294
|
+
createContext: () => Promise.resolve({ browserContext, close }),
|
|
295
|
+
};
|
|
296
|
+
this._backend = new deps.BrowserServerBackend(config, existingContextFactory, { allTools: true });
|
|
297
|
+
await this._backend.initialize?.(clientInfo);
|
|
298
|
+
this._connected = true;
|
|
299
|
+
// Auto-select the first visible web page.
|
|
300
|
+
const pages = browserContext.pages();
|
|
301
|
+
const INTERNAL = /^(chrome|devtools|chrome-extension|about):/;
|
|
302
|
+
let selectedIdx = -1;
|
|
303
|
+
for (let i = 0; i < pages.length; i++) {
|
|
304
|
+
const pageUrl = pages[i].url();
|
|
305
|
+
if (!pageUrl || INTERNAL.test(pageUrl))
|
|
306
|
+
continue;
|
|
307
|
+
try {
|
|
308
|
+
const state = await pages[i].evaluate(() => document.visibilityState);
|
|
309
|
+
if (state === 'visible' && selectedIdx === -1) {
|
|
310
|
+
selectedIdx = i;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
catch { /* skip */ }
|
|
314
|
+
}
|
|
315
|
+
if (selectedIdx > 0) {
|
|
316
|
+
await this._backend.callTool('browser_tabs', { action: 'select', index: selectedIdx });
|
|
317
|
+
}
|
|
318
|
+
browserContext.on('close', () => {
|
|
319
|
+
this._connected = false;
|
|
320
|
+
if (this._reconnectInfo)
|
|
321
|
+
this._scheduleReconnect();
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
_scheduleReconnect() {
|
|
325
|
+
if (this._isReconnecting)
|
|
326
|
+
return;
|
|
327
|
+
this._isReconnecting = true;
|
|
328
|
+
this._doReconnect().catch((e) => {
|
|
329
|
+
console.warn('Reconnection failed:', e instanceof Error ? e.message : String(e));
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
async _doReconnect() {
|
|
333
|
+
if (!this._reconnectInfo)
|
|
334
|
+
return;
|
|
335
|
+
const { opts, config, clientInfo } = this._reconnectInfo;
|
|
336
|
+
const deps = this._deps || loadDeps();
|
|
337
|
+
const cdpPort = opts.cdpPort || 9222;
|
|
338
|
+
const cdpUrl = `http://localhost:${cdpPort}`;
|
|
339
|
+
console.log('Browser context closed. Waiting for Chrome to reconnect...');
|
|
340
|
+
let attempt = 0;
|
|
341
|
+
while (!this._connected) {
|
|
342
|
+
attempt++;
|
|
343
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
344
|
+
// Check if engine was closed during reconnect
|
|
345
|
+
if (!this._reconnectInfo) {
|
|
346
|
+
console.log('Reconnection cancelled (engine closed).');
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const res = await fetch(`${cdpUrl}/json/version`);
|
|
351
|
+
if (!res.ok) {
|
|
352
|
+
console.warn(`Reconnect attempt ${attempt}: CDP responded with status ${res.status}`);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
console.warn(`Reconnect attempt ${attempt}: CDP not available (${e instanceof Error ? e.message : String(e)})`);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
// CDP is responding — reconnect
|
|
361
|
+
try {
|
|
362
|
+
// Clean up old backend before reconnecting
|
|
363
|
+
if (this._backend) {
|
|
364
|
+
try {
|
|
365
|
+
this._backend.serverClosed();
|
|
366
|
+
}
|
|
367
|
+
catch { /* ignore */ }
|
|
368
|
+
}
|
|
369
|
+
if (this._close) {
|
|
370
|
+
try {
|
|
371
|
+
await this._close();
|
|
372
|
+
}
|
|
373
|
+
catch { /* ignore */ }
|
|
374
|
+
}
|
|
375
|
+
this._backend = null;
|
|
376
|
+
this._browserContext = null;
|
|
377
|
+
this._close = null;
|
|
378
|
+
config.browser.cdpEndpoint = cdpUrl;
|
|
379
|
+
await this._connectToCdp(deps, config, clientInfo);
|
|
380
|
+
console.log(`Reconnected to Chrome CDP after ${attempt} attempt(s).`);
|
|
381
|
+
}
|
|
382
|
+
catch (e) {
|
|
383
|
+
console.warn(`Reconnect attempt ${attempt}: connection failed (${e instanceof Error ? e.message : String(e)})`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
this._isReconnecting = false;
|
|
387
|
+
}
|
|
388
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
389
|
+
// ─── Config builder ───────────────────────────────────────────────────────
|
|
390
|
+
async _buildConfig(opts, deps) {
|
|
391
|
+
const config = {
|
|
392
|
+
browser: {
|
|
393
|
+
browserName: 'chromium',
|
|
394
|
+
launchOptions: {
|
|
395
|
+
channel: 'chrome',
|
|
396
|
+
headless: !opts.headed,
|
|
397
|
+
},
|
|
398
|
+
contextOptions: {
|
|
399
|
+
viewport: null,
|
|
400
|
+
},
|
|
401
|
+
isolated: false,
|
|
402
|
+
userDataDir: undefined,
|
|
403
|
+
cdpEndpoint: undefined,
|
|
404
|
+
},
|
|
405
|
+
server: {},
|
|
406
|
+
network: {},
|
|
407
|
+
timeouts: {
|
|
408
|
+
action: 5000,
|
|
409
|
+
navigation: 15000,
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
// Browser selection
|
|
413
|
+
if (opts.browser) {
|
|
414
|
+
switch (opts.browser) {
|
|
415
|
+
case 'firefox':
|
|
416
|
+
config.browser.browserName = 'firefox';
|
|
417
|
+
config.browser.launchOptions.channel = undefined;
|
|
418
|
+
break;
|
|
419
|
+
case 'webkit':
|
|
420
|
+
config.browser.browserName = 'webkit';
|
|
421
|
+
config.browser.launchOptions.channel = undefined;
|
|
422
|
+
break;
|
|
423
|
+
default:
|
|
424
|
+
// chrome, msedge, chrome-beta, etc.
|
|
425
|
+
config.browser.browserName = 'chromium';
|
|
426
|
+
config.browser.launchOptions.channel = opts.browser;
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// Persistent profile
|
|
431
|
+
if (opts.persistent || opts.profile) {
|
|
432
|
+
config.browser.userDataDir = opts.profile || undefined;
|
|
433
|
+
}
|
|
434
|
+
else if (!opts.extension) {
|
|
435
|
+
config.browser.isolated = true;
|
|
436
|
+
}
|
|
437
|
+
// CDP connect mode
|
|
438
|
+
if (opts.connect) {
|
|
439
|
+
const port = typeof opts.connect === 'number' ? opts.connect : 9222;
|
|
440
|
+
config.browser.cdpEndpoint = `http://localhost:${port}`;
|
|
441
|
+
config.browser.isolated = false;
|
|
442
|
+
}
|
|
443
|
+
return await deps.resolveConfig(config);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function formatResult(result) {
|
|
447
|
+
const isError = result.isError;
|
|
448
|
+
let text;
|
|
449
|
+
let image;
|
|
450
|
+
for (const item of result.content) {
|
|
451
|
+
if (item.type === 'text' && !text)
|
|
452
|
+
text = item.text;
|
|
453
|
+
if (item.type === 'image' && !image)
|
|
454
|
+
image = `data:${item.mimeType || 'image/png'};base64,${item.data}`;
|
|
455
|
+
}
|
|
456
|
+
return { isError, text, image };
|
|
457
|
+
}
|
|
458
|
+
//# sourceMappingURL=engine.js.map
|