@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.
Files changed (43) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/cli-manifest.json +60 -1
  4. package/clis/instagram/collection-create.js +57 -0
  5. package/clis/instagram/collection-delete.js +91 -0
  6. package/clis/instagram/saved.js +21 -7
  7. package/dist/src/adapter-shadow.d.ts +11 -0
  8. package/dist/src/adapter-shadow.js +72 -0
  9. package/dist/src/adapter-shadow.test.d.ts +1 -0
  10. package/dist/src/adapter-shadow.test.js +49 -0
  11. package/dist/src/browser/base-page.d.ts +6 -2
  12. package/dist/src/browser/base-page.js +88 -6
  13. package/dist/src/browser/base-page.test.js +61 -1
  14. package/dist/src/browser/bridge.d.ts +0 -2
  15. package/dist/src/browser/bridge.js +4 -32
  16. package/dist/src/browser/cdp.js +48 -0
  17. package/dist/src/browser/cdp.test.js +23 -0
  18. package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
  19. package/dist/src/browser/daemon-lifecycle.js +67 -0
  20. package/dist/src/browser/daemon-version.d.ts +4 -0
  21. package/dist/src/browser/daemon-version.js +12 -0
  22. package/dist/src/browser/dom-helpers.d.ts +1 -1
  23. package/dist/src/browser/dom-helpers.js +15 -3
  24. package/dist/src/browser/page.js +1 -1
  25. package/dist/src/browser/target-resolver.d.ts +8 -0
  26. package/dist/src/browser/target-resolver.js +75 -0
  27. package/dist/src/browser/verify-fixture.d.ts +1 -0
  28. package/dist/src/browser/verify-fixture.js +18 -0
  29. package/dist/src/browser/verify-fixture.test.js +16 -1
  30. package/dist/src/build-manifest.d.ts +68 -33
  31. package/dist/src/build-manifest.js +175 -29
  32. package/dist/src/build-manifest.test.js +75 -1
  33. package/dist/src/cli.js +25 -10
  34. package/dist/src/cli.test.js +153 -1
  35. package/dist/src/commands/daemon.d.ts +2 -0
  36. package/dist/src/commands/daemon.js +36 -1
  37. package/dist/src/commands/daemon.test.js +103 -2
  38. package/dist/src/doctor.d.ts +3 -0
  39. package/dist/src/doctor.js +27 -20
  40. package/dist/src/doctor.test.js +71 -1
  41. package/dist/src/manifest-types.d.ts +39 -0
  42. package/dist/src/manifest-types.js +9 -0
  43. 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 this._waitForDaemonStop(3000);
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
- const spawnArgs = isTs
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 ${daemonPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`, 'daemon-not-running');
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) {
@@ -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
- el.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true }));
88
- el.dispatchEvent(new KeyboardEvent('keyup', { key: ${JSON.stringify(key)}, bubbles: true }));
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
  `;
@@ -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
+ });