@jackwener/opencli 1.7.9 → 1.7.11
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 +3 -3
- package/README.zh-CN.md +3 -3
- package/cli-manifest.json +60 -1
- package/clis/instagram/collection-create.js +57 -0
- package/clis/instagram/collection-delete.js +91 -0
- package/clis/instagram/saved.js +21 -7
- package/dist/src/adapter-shadow.d.ts +11 -0
- package/dist/src/adapter-shadow.js +72 -0
- package/dist/src/adapter-shadow.test.d.ts +1 -0
- package/dist/src/adapter-shadow.test.js +49 -0
- package/dist/src/browser/base-page.d.ts +6 -2
- package/dist/src/browser/base-page.js +88 -6
- package/dist/src/browser/base-page.test.js +61 -1
- package/dist/src/browser/bridge.d.ts +0 -2
- package/dist/src/browser/bridge.js +4 -32
- package/dist/src/browser/cdp.js +48 -0
- package/dist/src/browser/cdp.test.js +23 -0
- package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
- package/dist/src/browser/daemon-lifecycle.js +67 -0
- package/dist/src/browser/daemon-version.d.ts +4 -0
- package/dist/src/browser/daemon-version.js +12 -0
- package/dist/src/browser/dom-helpers.d.ts +1 -1
- package/dist/src/browser/dom-helpers.js +15 -3
- package/dist/src/browser/page.js +1 -1
- package/dist/src/browser/target-resolver.d.ts +8 -0
- package/dist/src/browser/target-resolver.js +75 -0
- package/dist/src/browser/verify-fixture.d.ts +1 -0
- package/dist/src/browser/verify-fixture.js +18 -0
- package/dist/src/browser/verify-fixture.test.js +16 -1
- package/dist/src/build-manifest.d.ts +68 -33
- package/dist/src/build-manifest.js +175 -29
- package/dist/src/build-manifest.test.js +75 -1
- package/dist/src/cli.js +25 -10
- package/dist/src/cli.test.js +153 -1
- package/dist/src/commands/daemon.d.ts +2 -0
- package/dist/src/commands/daemon.js +36 -1
- package/dist/src/commands/daemon.test.js +103 -2
- package/dist/src/doctor.d.ts +3 -0
- package/dist/src/doctor.js +27 -20
- package/dist/src/doctor.test.js +71 -1
- package/dist/src/manifest-types.d.ts +39 -0
- package/dist/src/manifest-types.js +9 -0
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { CliError } from '../errors.js';
|
|
3
3
|
import { BasePage } from './base-page.js';
|
|
4
4
|
class TestPage extends BasePage {
|
|
@@ -15,6 +15,23 @@ class TestPage extends BasePage {
|
|
|
15
15
|
async tabs() { return []; }
|
|
16
16
|
async selectTab() { }
|
|
17
17
|
}
|
|
18
|
+
class ActionPage extends BasePage {
|
|
19
|
+
results = [];
|
|
20
|
+
scripts = [];
|
|
21
|
+
nativeType;
|
|
22
|
+
insertText;
|
|
23
|
+
nativeKeyPress;
|
|
24
|
+
async goto() { }
|
|
25
|
+
async evaluate(js) {
|
|
26
|
+
this.scripts.push(js);
|
|
27
|
+
return this.results.shift() ?? null;
|
|
28
|
+
}
|
|
29
|
+
async getCookies() { return []; }
|
|
30
|
+
async screenshot() { return ''; }
|
|
31
|
+
async tabs() { return []; }
|
|
32
|
+
async selectTab() { }
|
|
33
|
+
}
|
|
34
|
+
const resolveOk = { ok: true, matches_n: 1, match_level: 'exact' };
|
|
18
35
|
describe('BasePage.fetchJson', () => {
|
|
19
36
|
it('passes a narrow browser-context JSON request and parses the response in Node', async () => {
|
|
20
37
|
const page = new TestPage();
|
|
@@ -72,3 +89,46 @@ describe('BasePage.fetchJson', () => {
|
|
|
72
89
|
});
|
|
73
90
|
});
|
|
74
91
|
});
|
|
92
|
+
describe('BasePage native input routing', () => {
|
|
93
|
+
it('types rich-editor text via native Input.insertText when available', async () => {
|
|
94
|
+
const page = new ActionPage();
|
|
95
|
+
page.nativeType = vi.fn().mockResolvedValue(undefined);
|
|
96
|
+
page.results = [resolveOk, { ok: true, mode: 'contenteditable' }];
|
|
97
|
+
await expect(page.typeText('#editor', 'hello')).resolves.toEqual({ matches_n: 1, match_level: 'exact' });
|
|
98
|
+
expect(page.nativeType).toHaveBeenCalledWith('hello');
|
|
99
|
+
expect(page.scripts).toHaveLength(2);
|
|
100
|
+
expect(page.scripts[1]).toContain('nearestContentEditableHost');
|
|
101
|
+
expect(page.scripts.join('\n')).not.toContain("return 'typed'");
|
|
102
|
+
});
|
|
103
|
+
it('keeps the DOM setter fallback when native text insertion is unavailable', async () => {
|
|
104
|
+
const page = new ActionPage();
|
|
105
|
+
page.results = [resolveOk, 'typed'];
|
|
106
|
+
await page.typeText('#q', 'hello');
|
|
107
|
+
expect(page.scripts).toHaveLength(2);
|
|
108
|
+
expect(page.scripts[1]).toContain('document.execCommand');
|
|
109
|
+
expect(page.scripts[1]).toContain("return 'typed'");
|
|
110
|
+
});
|
|
111
|
+
it('falls back to DOM typing if native text insertion fails', async () => {
|
|
112
|
+
const page = new ActionPage();
|
|
113
|
+
page.nativeType = vi.fn().mockRejectedValue(new Error('native failed'));
|
|
114
|
+
page.results = [resolveOk, { ok: true, mode: 'input' }, 'typed'];
|
|
115
|
+
await page.typeText('#q', 'hello');
|
|
116
|
+
expect(page.nativeType).toHaveBeenCalledWith('hello');
|
|
117
|
+
expect(page.scripts).toHaveLength(3);
|
|
118
|
+
expect(page.scripts[2]).toContain("return 'typed'");
|
|
119
|
+
});
|
|
120
|
+
it('presses key chords through native CDP key events when available', async () => {
|
|
121
|
+
const page = new ActionPage();
|
|
122
|
+
page.nativeKeyPress = vi.fn().mockResolvedValue(undefined);
|
|
123
|
+
await page.pressKey('Control+a');
|
|
124
|
+
expect(page.nativeKeyPress).toHaveBeenCalledWith('a', ['Ctrl']);
|
|
125
|
+
expect(page.scripts).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
it('falls back to synthetic keyboard events with parsed modifiers', async () => {
|
|
128
|
+
const page = new ActionPage();
|
|
129
|
+
await page.pressKey('Meta+N');
|
|
130
|
+
expect(page.scripts).toHaveLength(1);
|
|
131
|
+
expect(page.scripts[0]).toContain('key: "N"');
|
|
132
|
+
expect(page.scripts[0]).toContain('metaKey: true');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -20,8 +20,6 @@ export declare class BrowserBridge implements IBrowserFactory {
|
|
|
20
20
|
}): Promise<IPage>;
|
|
21
21
|
close(): Promise<void>;
|
|
22
22
|
private _ensureDaemon;
|
|
23
|
-
/** Poll until daemon is fully stopped (port released). */
|
|
24
|
-
private _waitForDaemonStop;
|
|
25
23
|
/** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
|
|
26
24
|
private _pollUntilReady;
|
|
27
25
|
}
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Browser session manager — auto-spawns daemon and provides IPage.
|
|
3
3
|
*/
|
|
4
|
-
import { spawn } from 'node:child_process';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import * as path from 'node:path';
|
|
7
|
-
import * as fs from 'node:fs';
|
|
8
4
|
import { Page } from './page.js';
|
|
9
5
|
import { getDaemonHealth, requestDaemonShutdown } from './daemon-client.js';
|
|
10
6
|
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
|
11
7
|
import { BrowserConnectError } from '../errors.js';
|
|
12
8
|
import { PKG_VERSION } from '../version.js';
|
|
13
9
|
import { resolveProfileContextId } from './profile.js';
|
|
10
|
+
import { resolveDaemonLaunchSpec, spawnDaemonProcess, waitForDaemonStop } from './daemon-lifecycle.js';
|
|
14
11
|
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
|
|
15
12
|
/**
|
|
16
13
|
* Browser factory: manages daemon lifecycle and provides IPage instances.
|
|
@@ -82,7 +79,7 @@ export class BrowserBridge {
|
|
|
82
79
|
process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`);
|
|
83
80
|
}
|
|
84
81
|
const shutdownAccepted = await requestDaemonShutdown();
|
|
85
|
-
const portReleased = shutdownAccepted && await
|
|
82
|
+
const portReleased = shutdownAccepted && await waitForDaemonStop(3000);
|
|
86
83
|
if (!portReleased) {
|
|
87
84
|
// Stale daemon replacement failed — don't blindly spawn on an occupied port
|
|
88
85
|
throw new BrowserConnectError('Stale daemon could not be replaced', `A stale daemon (${reason}) is running but did not shut down.\n` +
|
|
@@ -115,24 +112,10 @@ export class BrowserBridge {
|
|
|
115
112
|
}
|
|
116
113
|
}
|
|
117
114
|
// No daemon — spawn one
|
|
118
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
119
|
-
const parentDir = path.resolve(__dirname, '..');
|
|
120
|
-
const daemonTs = path.join(parentDir, 'daemon.ts');
|
|
121
|
-
const daemonJs = path.join(parentDir, 'daemon.js');
|
|
122
|
-
const isTs = fs.existsSync(daemonTs);
|
|
123
|
-
const daemonPath = isTs ? daemonTs : daemonJs;
|
|
124
115
|
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
|
|
125
116
|
process.stderr.write('⏳ Starting daemon...\n');
|
|
126
117
|
}
|
|
127
|
-
|
|
128
|
-
? [process.execPath, '--import', 'tsx/esm', daemonPath]
|
|
129
|
-
: [process.execPath, daemonPath];
|
|
130
|
-
this._daemonProc = spawn(spawnArgs[0], spawnArgs.slice(1), {
|
|
131
|
-
detached: true,
|
|
132
|
-
stdio: 'ignore',
|
|
133
|
-
env: { ...process.env },
|
|
134
|
-
});
|
|
135
|
-
this._daemonProc.unref();
|
|
118
|
+
this._daemonProc = spawnDaemonProcess();
|
|
136
119
|
// Wait for daemon + extension
|
|
137
120
|
if (await this._pollUntilReady(timeoutMs, contextId))
|
|
138
121
|
return;
|
|
@@ -152,18 +135,7 @@ export class BrowserBridge {
|
|
|
152
135
|
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
|
|
153
136
|
' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
|
|
154
137
|
}
|
|
155
|
-
throw new BrowserConnectError('Failed to start opencli daemon', `Try running manually:\n node ${
|
|
156
|
-
}
|
|
157
|
-
/** Poll until daemon is fully stopped (port released). */
|
|
158
|
-
async _waitForDaemonStop(timeoutMs) {
|
|
159
|
-
const deadline = Date.now() + timeoutMs;
|
|
160
|
-
while (Date.now() < deadline) {
|
|
161
|
-
await new Promise(resolve => setTimeout(resolve, 200));
|
|
162
|
-
const h = await getDaemonHealth();
|
|
163
|
-
if (h.state === 'stopped')
|
|
164
|
-
return true;
|
|
165
|
-
}
|
|
166
|
-
return false;
|
|
138
|
+
throw new BrowserConnectError('Failed to start opencli daemon', `Try running manually:\n node ${resolveDaemonLaunchSpec().scriptPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`, 'daemon-not-running');
|
|
167
139
|
}
|
|
168
140
|
/** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
|
|
169
141
|
async _pollUntilReady(timeoutMs, contextId) {
|
package/dist/src/browser/cdp.js
CHANGED
|
@@ -317,6 +317,54 @@ class CDPPage extends BasePage {
|
|
|
317
317
|
async selectTab(_target) {
|
|
318
318
|
// Not supported in direct CDP mode
|
|
319
319
|
}
|
|
320
|
+
async cdp(method, params = {}) {
|
|
321
|
+
return this.bridge.send(method, params);
|
|
322
|
+
}
|
|
323
|
+
async nativeClick(x, y) {
|
|
324
|
+
await this.cdp('Input.dispatchMouseEvent', {
|
|
325
|
+
type: 'mousePressed',
|
|
326
|
+
x,
|
|
327
|
+
y,
|
|
328
|
+
button: 'left',
|
|
329
|
+
clickCount: 1,
|
|
330
|
+
});
|
|
331
|
+
await this.cdp('Input.dispatchMouseEvent', {
|
|
332
|
+
type: 'mouseReleased',
|
|
333
|
+
x,
|
|
334
|
+
y,
|
|
335
|
+
button: 'left',
|
|
336
|
+
clickCount: 1,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
async nativeType(text) {
|
|
340
|
+
await this.cdp('Input.insertText', { text });
|
|
341
|
+
}
|
|
342
|
+
async insertText(text) {
|
|
343
|
+
await this.nativeType(text);
|
|
344
|
+
}
|
|
345
|
+
async nativeKeyPress(key, modifiers = []) {
|
|
346
|
+
let modifierFlags = 0;
|
|
347
|
+
for (const mod of modifiers) {
|
|
348
|
+
if (mod === 'Alt')
|
|
349
|
+
modifierFlags |= 1;
|
|
350
|
+
if (mod === 'Ctrl' || mod === 'Control')
|
|
351
|
+
modifierFlags |= 2;
|
|
352
|
+
if (mod === 'Meta')
|
|
353
|
+
modifierFlags |= 4;
|
|
354
|
+
if (mod === 'Shift')
|
|
355
|
+
modifierFlags |= 8;
|
|
356
|
+
}
|
|
357
|
+
await this.cdp('Input.dispatchKeyEvent', {
|
|
358
|
+
type: 'keyDown',
|
|
359
|
+
key,
|
|
360
|
+
modifiers: modifierFlags,
|
|
361
|
+
});
|
|
362
|
+
await this.cdp('Input.dispatchKeyEvent', {
|
|
363
|
+
type: 'keyUp',
|
|
364
|
+
key,
|
|
365
|
+
modifiers: modifierFlags,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
320
368
|
}
|
|
321
369
|
function isCookie(value) {
|
|
322
370
|
return isRecord(value)
|
|
@@ -49,4 +49,27 @@ describe('CDPBridge cookies', () => {
|
|
|
49
49
|
{ name: 'exact', value: '2', domain: 'example.com' },
|
|
50
50
|
]);
|
|
51
51
|
});
|
|
52
|
+
it('exposes native input helpers on direct CDP pages', async () => {
|
|
53
|
+
vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1');
|
|
54
|
+
const bridge = new CDPBridge();
|
|
55
|
+
const send = vi.spyOn(bridge, 'send').mockResolvedValue({});
|
|
56
|
+
const page = await bridge.connect();
|
|
57
|
+
send.mockClear();
|
|
58
|
+
expect(page.nativeType).toBeTypeOf('function');
|
|
59
|
+
expect(page.nativeKeyPress).toBeTypeOf('function');
|
|
60
|
+
expect(page.nativeClick).toBeTypeOf('function');
|
|
61
|
+
expect(page.cdp).toBeTypeOf('function');
|
|
62
|
+
await page.nativeType('hello');
|
|
63
|
+
await page.nativeKeyPress('a', ['Ctrl']);
|
|
64
|
+
await page.nativeClick(10, 20);
|
|
65
|
+
await page.cdp('Page.getLayoutMetrics', {});
|
|
66
|
+
expect(send.mock.calls).toEqual([
|
|
67
|
+
['Input.insertText', { text: 'hello' }],
|
|
68
|
+
['Input.dispatchKeyEvent', { type: 'keyDown', key: 'a', modifiers: 2 }],
|
|
69
|
+
['Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', modifiers: 2 }],
|
|
70
|
+
['Input.dispatchMouseEvent', { type: 'mousePressed', x: 10, y: 20, button: 'left', clickCount: 1 }],
|
|
71
|
+
['Input.dispatchMouseEvent', { type: 'mouseReleased', x: 10, y: 20, button: 'left', clickCount: 1 }],
|
|
72
|
+
['Page.getLayoutMetrics', {}],
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
52
75
|
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
|
3
|
+
import { type DaemonStatus } from './daemon-client.js';
|
|
4
|
+
export interface DaemonLaunchSpec {
|
|
5
|
+
binary: string;
|
|
6
|
+
args: string[];
|
|
7
|
+
scriptPath: string;
|
|
8
|
+
}
|
|
9
|
+
export interface DaemonRestartResult {
|
|
10
|
+
previousStatus: DaemonStatus | null;
|
|
11
|
+
status: DaemonStatus | null;
|
|
12
|
+
stopped: boolean;
|
|
13
|
+
spawned: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function resolveDaemonLaunchSpec(): DaemonLaunchSpec;
|
|
16
|
+
export declare function spawnDaemonProcess(): ChildProcess;
|
|
17
|
+
export declare function waitForDaemonStop(timeoutMs: number): Promise<boolean>;
|
|
18
|
+
export declare function waitForDaemonStatus(timeoutMs: number): Promise<DaemonStatus | null>;
|
|
19
|
+
export declare function restartDaemon(opts?: {
|
|
20
|
+
stopTimeoutMs?: number;
|
|
21
|
+
startTimeoutMs?: number;
|
|
22
|
+
}): Promise<DaemonRestartResult>;
|
|
23
|
+
export { DEFAULT_DAEMON_PORT };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
|
6
|
+
import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown } from './daemon-client.js';
|
|
7
|
+
export function resolveDaemonLaunchSpec() {
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const parentDir = path.resolve(__dirname, '..');
|
|
10
|
+
const daemonTs = path.join(parentDir, 'daemon.ts');
|
|
11
|
+
const daemonJs = path.join(parentDir, 'daemon.js');
|
|
12
|
+
const isTs = fs.existsSync(daemonTs);
|
|
13
|
+
const scriptPath = isTs ? daemonTs : daemonJs;
|
|
14
|
+
return {
|
|
15
|
+
binary: process.execPath,
|
|
16
|
+
args: isTs ? ['--import', 'tsx/esm', scriptPath] : [scriptPath],
|
|
17
|
+
scriptPath,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function spawnDaemonProcess() {
|
|
21
|
+
const launch = resolveDaemonLaunchSpec();
|
|
22
|
+
const proc = spawn(launch.binary, launch.args, {
|
|
23
|
+
detached: true,
|
|
24
|
+
stdio: 'ignore',
|
|
25
|
+
env: { ...process.env },
|
|
26
|
+
});
|
|
27
|
+
proc.unref();
|
|
28
|
+
return proc;
|
|
29
|
+
}
|
|
30
|
+
export async function waitForDaemonStop(timeoutMs) {
|
|
31
|
+
const deadline = Date.now() + timeoutMs;
|
|
32
|
+
while (Date.now() < deadline) {
|
|
33
|
+
await sleep(200);
|
|
34
|
+
const h = await getDaemonHealth();
|
|
35
|
+
if (h.state === 'stopped')
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
export async function waitForDaemonStatus(timeoutMs) {
|
|
41
|
+
const deadline = Date.now() + timeoutMs;
|
|
42
|
+
while (Date.now() < deadline) {
|
|
43
|
+
const status = await fetchDaemonStatus({ timeout: Math.min(1000, Math.max(100, deadline - Date.now())) });
|
|
44
|
+
if (status)
|
|
45
|
+
return status;
|
|
46
|
+
await sleep(200);
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
export async function restartDaemon(opts = {}) {
|
|
51
|
+
const previousStatus = await fetchDaemonStatus();
|
|
52
|
+
let stopped = previousStatus === null;
|
|
53
|
+
if (previousStatus) {
|
|
54
|
+
const shutdownAccepted = await requestDaemonShutdown();
|
|
55
|
+
stopped = shutdownAccepted && await waitForDaemonStop(opts.stopTimeoutMs ?? 3000);
|
|
56
|
+
if (!stopped) {
|
|
57
|
+
return { previousStatus, status: previousStatus, stopped: false, spawned: false };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
spawnDaemonProcess();
|
|
61
|
+
const status = await waitForDaemonStatus(opts.startTimeoutMs ?? 5000);
|
|
62
|
+
return { previousStatus, status, stopped, spawned: true };
|
|
63
|
+
}
|
|
64
|
+
function sleep(ms) {
|
|
65
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
66
|
+
}
|
|
67
|
+
export { DEFAULT_DAEMON_PORT };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { DaemonStatus } from './daemon-client.js';
|
|
2
|
+
export declare function isDaemonStale(status: Pick<DaemonStatus, 'daemonVersion'> | null | undefined, cliVersion?: string): boolean;
|
|
3
|
+
export declare function formatDaemonVersion(status: Pick<DaemonStatus, 'daemonVersion'> | null | undefined): string;
|
|
4
|
+
export declare function staleDaemonIssue(status: Pick<DaemonStatus, 'daemonVersion'> | null | undefined, cliVersion: string): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function isDaemonStale(status, cliVersion) {
|
|
2
|
+
if (!status || !cliVersion)
|
|
3
|
+
return false;
|
|
4
|
+
return !status.daemonVersion || status.daemonVersion !== cliVersion;
|
|
5
|
+
}
|
|
6
|
+
export function formatDaemonVersion(status) {
|
|
7
|
+
return status?.daemonVersion ? `v${status.daemonVersion}` : 'version unknown';
|
|
8
|
+
}
|
|
9
|
+
export function staleDaemonIssue(status, cliVersion) {
|
|
10
|
+
return `Stale daemon detected: daemon ${formatDaemonVersion(status)} != CLI v${cliVersion}.\n` +
|
|
11
|
+
' Run: opencli daemon restart';
|
|
12
|
+
}
|
|
@@ -11,7 +11,7 @@ export declare function clickJs(ref: string): string;
|
|
|
11
11
|
* Uses native setter for React compat + execCommand for contenteditable. */
|
|
12
12
|
export declare function typeTextJs(ref: string, text: string): string;
|
|
13
13
|
/** Generate JS to press a keyboard key */
|
|
14
|
-
export declare function pressKeyJs(key: string): string;
|
|
14
|
+
export declare function pressKeyJs(key: string, modifiers?: string[]): string;
|
|
15
15
|
/** Generate JS to wait for text to appear in the page */
|
|
16
16
|
export declare function waitForTextJs(text: string, timeoutMs: number): string;
|
|
17
17
|
/** Generate JS for scroll */
|
|
@@ -80,12 +80,24 @@ export function typeTextJs(ref, text) {
|
|
|
80
80
|
`;
|
|
81
81
|
}
|
|
82
82
|
/** Generate JS to press a keyboard key */
|
|
83
|
-
export function pressKeyJs(key) {
|
|
83
|
+
export function pressKeyJs(key, modifiers = []) {
|
|
84
|
+
const hasCtrl = modifiers.includes('Ctrl') || modifiers.includes('Control');
|
|
85
|
+
const hasAlt = modifiers.includes('Alt');
|
|
86
|
+
const hasMeta = modifiers.includes('Meta');
|
|
87
|
+
const hasShift = modifiers.includes('Shift');
|
|
84
88
|
return `
|
|
85
89
|
(() => {
|
|
86
90
|
const el = document.activeElement || document.body;
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
const init = {
|
|
92
|
+
key: ${JSON.stringify(key)},
|
|
93
|
+
bubbles: true,
|
|
94
|
+
ctrlKey: ${hasCtrl},
|
|
95
|
+
altKey: ${hasAlt},
|
|
96
|
+
metaKey: ${hasMeta},
|
|
97
|
+
shiftKey: ${hasShift},
|
|
98
|
+
};
|
|
99
|
+
el.dispatchEvent(new KeyboardEvent('keydown', init));
|
|
100
|
+
el.dispatchEvent(new KeyboardEvent('keyup', init));
|
|
89
101
|
return 'pressed';
|
|
90
102
|
})()
|
|
91
103
|
`;
|
package/dist/src/browser/page.js
CHANGED
|
@@ -374,7 +374,7 @@ export class Page extends BasePage {
|
|
|
374
374
|
for (const mod of modifiers) {
|
|
375
375
|
if (mod === 'Alt')
|
|
376
376
|
modifierFlags |= 1;
|
|
377
|
-
if (mod === 'Ctrl')
|
|
377
|
+
if (mod === 'Ctrl' || mod === 'Control')
|
|
378
378
|
modifierFlags |= 2;
|
|
379
379
|
if (mod === 'Meta')
|
|
380
380
|
modifierFlags |= 4;
|
|
@@ -77,6 +77,14 @@ export declare function clickResolvedJs(): string;
|
|
|
77
77
|
* Generate JS for type that uses the unified resolver.
|
|
78
78
|
*/
|
|
79
79
|
export declare function typeResolvedJs(text: string): string;
|
|
80
|
+
/**
|
|
81
|
+
* Prepare the resolved element for native CDP Input.insertText.
|
|
82
|
+
*
|
|
83
|
+
* This preserves `browser type`'s existing "replace current text" semantics:
|
|
84
|
+
* focus the editable target, select its current contents, then let CDP insert
|
|
85
|
+
* real browser text input so rich editors can update their internal state.
|
|
86
|
+
*/
|
|
87
|
+
export declare function prepareNativeTypeResolvedJs(): string;
|
|
80
88
|
/**
|
|
81
89
|
* Generate JS for scrollTo that uses the unified resolver.
|
|
82
90
|
* Assumes resolveTargetJs has been called and __resolved is set.
|
|
@@ -331,6 +331,81 @@ export function typeResolvedJs(text) {
|
|
|
331
331
|
})()
|
|
332
332
|
`;
|
|
333
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Prepare the resolved element for native CDP Input.insertText.
|
|
336
|
+
*
|
|
337
|
+
* This preserves `browser type`'s existing "replace current text" semantics:
|
|
338
|
+
* focus the editable target, select its current contents, then let CDP insert
|
|
339
|
+
* real browser text input so rich editors can update their internal state.
|
|
340
|
+
*/
|
|
341
|
+
export function prepareNativeTypeResolvedJs() {
|
|
342
|
+
return `
|
|
343
|
+
(() => {
|
|
344
|
+
const original = window.__resolved;
|
|
345
|
+
if (!original) throw new Error('No resolved element');
|
|
346
|
+
|
|
347
|
+
function nearestContentEditableHost(el) {
|
|
348
|
+
let current = el;
|
|
349
|
+
while (current && current.nodeType === 1) {
|
|
350
|
+
if (current.hasAttribute && current.hasAttribute('contenteditable')) return current;
|
|
351
|
+
current = current.parentElement;
|
|
352
|
+
}
|
|
353
|
+
return el.isContentEditable ? el : null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const editableHost = original.isContentEditable ? nearestContentEditableHost(original) : null;
|
|
357
|
+
const inputTypes = new Set(['', 'text', 'search', 'url', 'tel', 'email', 'password']);
|
|
358
|
+
const isInput = original instanceof HTMLInputElement;
|
|
359
|
+
const isTextarea = original instanceof HTMLTextAreaElement;
|
|
360
|
+
const isTextControl = isTextarea || (isInput && inputTypes.has((original.getAttribute('type') || original.type || '').toLowerCase()));
|
|
361
|
+
const el = editableHost || (isTextControl ? original : null);
|
|
362
|
+
|
|
363
|
+
if (!el) {
|
|
364
|
+
return {
|
|
365
|
+
ok: false,
|
|
366
|
+
reason: 'not_editable',
|
|
367
|
+
tag: original.tagName ? original.tagName.toLowerCase() : '',
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
window.__resolved = el;
|
|
372
|
+
el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' });
|
|
373
|
+
try {
|
|
374
|
+
el.focus({ preventScroll: true });
|
|
375
|
+
} catch (_) {
|
|
376
|
+
el.focus();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (editableHost) {
|
|
380
|
+
const sel = window.getSelection();
|
|
381
|
+
if (!sel) return { ok: false, reason: 'selection_unavailable', mode: 'contenteditable' };
|
|
382
|
+
const range = document.createRange();
|
|
383
|
+
range.selectNodeContents(el);
|
|
384
|
+
sel.removeAllRanges();
|
|
385
|
+
sel.addRange(range);
|
|
386
|
+
return { ok: true, mode: 'contenteditable' };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let selected = false;
|
|
390
|
+
try {
|
|
391
|
+
if (typeof el.setSelectionRange === 'function') {
|
|
392
|
+
el.setSelectionRange(0, String(el.value || '').length);
|
|
393
|
+
selected = true;
|
|
394
|
+
}
|
|
395
|
+
} catch (_) {}
|
|
396
|
+
try {
|
|
397
|
+
if (!selected && typeof el.select === 'function') {
|
|
398
|
+
el.select();
|
|
399
|
+
selected = true;
|
|
400
|
+
}
|
|
401
|
+
} catch (_) {}
|
|
402
|
+
|
|
403
|
+
return selected
|
|
404
|
+
? { ok: true, mode: isTextarea ? 'textarea' : 'input' }
|
|
405
|
+
: { ok: false, reason: 'selection_unavailable', mode: isTextarea ? 'textarea' : 'input' };
|
|
406
|
+
})()
|
|
407
|
+
`;
|
|
408
|
+
}
|
|
334
409
|
/**
|
|
335
410
|
* Generate JS for scrollTo that uses the unified resolver.
|
|
336
411
|
* Assumes resolveTargetJs has been called and __resolved is set.
|
|
@@ -57,3 +57,4 @@ export declare function validateRows(rows: Row[], fixture: Fixture): ValidationF
|
|
|
57
57
|
* - Object form is expanded to `--key value` pairs.
|
|
58
58
|
*/
|
|
59
59
|
export declare function expandFixtureArgs(args: FixtureArgs | undefined): string[];
|
|
60
|
+
export declare function parseSeedArgs(raw: string | undefined): FixtureArgs | undefined;
|
|
@@ -196,6 +196,24 @@ export function expandFixtureArgs(args) {
|
|
|
196
196
|
}
|
|
197
197
|
return out;
|
|
198
198
|
}
|
|
199
|
+
export function parseSeedArgs(raw) {
|
|
200
|
+
if (raw === undefined)
|
|
201
|
+
return undefined;
|
|
202
|
+
const trimmed = raw.trim();
|
|
203
|
+
if (!trimmed)
|
|
204
|
+
return undefined;
|
|
205
|
+
try {
|
|
206
|
+
const parsed = JSON.parse(trimmed);
|
|
207
|
+
if (Array.isArray(parsed))
|
|
208
|
+
return parsed;
|
|
209
|
+
if (parsed !== null && typeof parsed === 'object')
|
|
210
|
+
return parsed;
|
|
211
|
+
return [parsed];
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return [raw];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
199
217
|
function jsType(v) {
|
|
200
218
|
if (v === null)
|
|
201
219
|
return 'null';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { deriveFixture, expandFixtureArgs, validateRows } from './verify-fixture.js';
|
|
2
|
+
import { deriveFixture, expandFixtureArgs, parseSeedArgs, validateRows } from './verify-fixture.js';
|
|
3
3
|
describe('validateRows', () => {
|
|
4
4
|
it('passes when rows meet all expectations', () => {
|
|
5
5
|
const fixture = {
|
|
@@ -159,3 +159,18 @@ describe('expandFixtureArgs', () => {
|
|
|
159
159
|
]);
|
|
160
160
|
});
|
|
161
161
|
});
|
|
162
|
+
describe('parseSeedArgs', () => {
|
|
163
|
+
it('treats plain text as one positional arg', () => {
|
|
164
|
+
expect(parseSeedArgs('opencli-verify')).toEqual(['opencli-verify']);
|
|
165
|
+
});
|
|
166
|
+
it('accepts JSON array seed args', () => {
|
|
167
|
+
expect(parseSeedArgs('["subject", "--limit", 3]')).toEqual(['subject', '--limit', 3]);
|
|
168
|
+
});
|
|
169
|
+
it('accepts JSON object seed args', () => {
|
|
170
|
+
expect(parseSeedArgs('{"limit":3,"sort":"hot"}')).toEqual({ limit: 3, sort: 'hot' });
|
|
171
|
+
});
|
|
172
|
+
it('ignores empty input', () => {
|
|
173
|
+
expect(parseSeedArgs(undefined)).toBeUndefined();
|
|
174
|
+
expect(parseSeedArgs(' ')).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
});
|