@dsh-workbench/shared 0.1.14 → 0.1.15

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 (83) hide show
  1. package/dist/components/CardHead.js +3 -2
  2. package/dist/components/Field.css +16 -0
  3. package/dist/components/Field.d.ts +8 -0
  4. package/dist/components/Field.js +8 -0
  5. package/dist/components/Link.css +22 -0
  6. package/dist/components/Link.d.ts +10 -0
  7. package/dist/components/Link.js +8 -0
  8. package/dist/components/LongTaskRow.css +0 -15
  9. package/dist/components/LongTaskRow.js +3 -4
  10. package/dist/components/TextField.css +5 -0
  11. package/dist/host/browser-find.d.ts +38 -0
  12. package/dist/host/browser-find.js +242 -0
  13. package/dist/host/browser-read.d.ts +26 -0
  14. package/dist/host/browser-read.js +240 -0
  15. package/dist/host/http.d.ts +6 -1
  16. package/dist/host/http.js +2 -1
  17. package/dist/host/opencli-runtime.d.ts +3 -0
  18. package/dist/host/opencli-runtime.js +10 -0
  19. package/dist/host/opencli-window.d.ts +2 -0
  20. package/dist/host/opencli-window.js +8 -0
  21. package/dist/host/opencli.js +2 -0
  22. package/dist/host.d.ts +2 -0
  23. package/dist/host.js +2 -0
  24. package/dist/i18n/en.js +3 -0
  25. package/dist/i18n/index.d.ts +6 -3
  26. package/dist/i18n/zh.d.ts +5 -2
  27. package/dist/i18n/zh.js +5 -2
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.js +2 -0
  30. package/package.json +1 -1
  31. package/src/components/CardHead.test.tsx +81 -0
  32. package/src/components/CardHead.tsx +4 -3
  33. package/src/components/Field.css +16 -0
  34. package/src/components/Field.test.tsx +27 -0
  35. package/src/components/Field.tsx +19 -0
  36. package/src/components/Link.css +22 -0
  37. package/src/components/Link.test.tsx +37 -0
  38. package/src/components/Link.tsx +26 -0
  39. package/src/components/LongTaskRow.css +0 -15
  40. package/src/components/LongTaskRow.tsx +3 -4
  41. package/src/components/TextField.css +5 -0
  42. package/src/host/__tests__/browser-find.test.ts +319 -0
  43. package/src/host/__tests__/browser-read.test.ts +197 -0
  44. package/src/host/browser-find.ts +311 -0
  45. package/src/host/browser-read.ts +308 -0
  46. package/src/host/http.ts +8 -1
  47. package/src/host/opencli-runtime.ts +13 -0
  48. package/src/host/opencli-window.ts +11 -0
  49. package/src/host/opencli.ts +2 -0
  50. package/src/host.ts +2 -0
  51. package/src/i18n/en.ts +3 -0
  52. package/src/i18n/zh.ts +5 -2
  53. package/src/index.ts +2 -0
  54. package/dist/components/StateDot.d.ts +0 -5
  55. package/dist/components/StateDot.js +0 -10
  56. package/dist/host/__tests__/fakes.d.ts +0 -41
  57. package/dist/host/__tests__/fakes.js +0 -76
  58. package/dist/host/__tests__/preflight-fixtures.d.ts +0 -19
  59. package/dist/host/__tests__/preflight-fixtures.js +0 -47
  60. package/dist/host/context-inject.d.ts +0 -24
  61. package/dist/host/context-inject.js +0 -74
  62. package/dist/host/events.d.ts +0 -9
  63. package/dist/host/events.js +0 -70
  64. package/dist/host/mount-units.d.ts +0 -52
  65. package/dist/host/mount-units.js +0 -186
  66. package/dist/host/preflight.d.ts +0 -58
  67. package/dist/host/preflight.js +0 -233
  68. package/dist/host/probe-cache.d.ts +0 -6
  69. package/dist/host/probe-cache.js +0 -19
  70. package/dist/host/probe-libs.d.ts +0 -45
  71. package/dist/host/probe-libs.js +0 -61
  72. package/dist/host/probe-units.d.ts +0 -17
  73. package/dist/host/probe-units.js +0 -102
  74. package/dist/host/probe-verdict.d.ts +0 -7
  75. package/dist/host/probe-verdict.js +0 -143
  76. package/dist/host/tool-scope.d.ts +0 -32
  77. package/dist/host/tool-scope.js +0 -84
  78. package/dist/host/unit-readiness.d.ts +0 -7
  79. package/dist/host/unit-readiness.js +0 -5
  80. package/dist/host/unit-registry.d.ts +0 -9
  81. package/dist/host/unit-registry.js +0 -33
  82. package/dist/host/version-range.d.ts +0 -2
  83. package/dist/host/version-range.js +0 -73
@@ -0,0 +1,197 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { browserRead } from '../browser-read.js';
3
+
4
+ interface Step {
5
+ readonly stdout?: string;
6
+ readonly stderr?: string;
7
+ readonly exitCode?: number;
8
+ readonly timedOut?: boolean;
9
+ }
10
+
11
+ function fakeShell(plan: readonly Step[]) {
12
+ const commands: string[] = [];
13
+ let index = 0;
14
+ const shell = {
15
+ resolve(request: { command: string }) {
16
+ commands.push(request.command);
17
+ return request;
18
+ },
19
+ async execute() {
20
+ const step = plan[Math.min(index, plan.length - 1)] ?? {};
21
+ index += 1;
22
+ return {
23
+ result: async () => ({
24
+ exitCode: step.exitCode ?? 0,
25
+ timedOut: step.timedOut ?? false,
26
+ aborted: false,
27
+ stdout: { text: step.stdout ?? '', truncated: false },
28
+ stderr: { text: step.stderr ?? '', truncated: false },
29
+ }),
30
+ };
31
+ },
32
+ };
33
+ return { shell: shell as never, commands };
34
+ }
35
+
36
+ const FAST = { retryMs: 1, settleMs: 1, timeoutMs: 50 } as const;
37
+ const script = 'return 1';
38
+ const URL = 'https://example.test/wallet';
39
+
40
+ describe('browserRead · 读成功', () => {
41
+ it('页面回 JSON → ok,命令走 `browser <session> eval`', async () => {
42
+ const { shell, commands } = fakeShell([{ stdout: JSON.stringify({ ok: true, n: 3 }) }]);
43
+ const out = await browserRead({ shell, script, url: URL, ...FAST });
44
+
45
+ expect(out.ok).toBe(true);
46
+ expect(out.code).toBe('ok');
47
+ expect(out.value).toEqual({ ok: true, n: 3 });
48
+ expect(commands[0]).toContain("'browser' 'workbench' 'eval'");
49
+ });
50
+
51
+ it('脚本没回 ok 字段(例如直接回一个数)也算读到了', async () => {
52
+ const { shell } = fakeShell([{ stdout: '42' }]);
53
+ const out = await browserRead({ shell, script, ...FAST });
54
+ expect(out.ok).toBe(true);
55
+ expect(out.value).toBe(42);
56
+ });
57
+ });
58
+
59
+ describe('browserRead · 平台码归一(不许透传)', () => {
60
+ it('AUTH_UNAUTHORIZED → auth-required + helpUrl', async () => {
61
+ const { shell } = fakeShell([
62
+ { stdout: JSON.stringify({ ok: false, code: 'AUTH_UNAUTHORIZED', message: '登录已过期' }) },
63
+ ]);
64
+ const out = await browserRead({ shell, script, url: URL, loginUrl: 'https://sso.example.com', ...FAST });
65
+
66
+ expect(out.ok).toBe(false);
67
+ expect(out.code).toBe('auth-required');
68
+ expect(out.pageCode).toBe('AUTH_UNAUTHORIZED');
69
+ expect(out.helpUrl).toBe('https://sso.example.com');
70
+ expect(out.message).toBe('登录已过期');
71
+ });
72
+
73
+ it('PERMISSION_DENIED → permission-denied', async () => {
74
+ const { shell } = fakeShell([{ stdout: JSON.stringify({ ok: false, code: 'permission_denied' }) }]);
75
+ const out = await browserRead({ shell, script, url: URL, ...FAST });
76
+ expect(out.code).toBe('permission-denied');
77
+ });
78
+
79
+ it('业务码原样回(落到阻断屏的"其它"那一支)', async () => {
80
+ const { shell } = fakeShell([
81
+ { stdout: JSON.stringify({ ok: false, code: 'network', message: '连不上', hint: '稍后重试' }) },
82
+ ]);
83
+ const out = await browserRead({ shell, script, url: URL, ...FAST });
84
+ expect(out.code).toBe('network');
85
+ expect(out.pageCode).toBe('network');
86
+ expect(out.message).toBe('连不上');
87
+ expect(out.hint).toBe('稍后重试');
88
+ });
89
+ });
90
+
91
+ describe('browserRead · 两拍(不在站点上就先打开)', () => {
92
+ it('not-on-site → open(后台窗口)→ 再读一遍 → ok', async () => {
93
+ const { shell, commands } = fakeShell([
94
+ { stdout: JSON.stringify({ ok: false, code: 'not-on-site' }) },
95
+ { stdout: 'opened' },
96
+ { stdout: JSON.stringify({ ok: true }) },
97
+ ]);
98
+ const out = await browserRead({ shell, script, url: URL, ...FAST });
99
+
100
+ expect(out.ok).toBe(true);
101
+ expect(commands.some((one) => one.includes("'open'") && one.includes('--window'))).toBe(true);
102
+ expect(commands.filter((one) => one.includes("'eval'"))).toHaveLength(2);
103
+ });
104
+
105
+ it('open 关了:直接说清"没停在那套系统上"', async () => {
106
+ const { shell, commands } = fakeShell([{ stdout: JSON.stringify({ ok: false, code: 'not-on-site' }) }]);
107
+ const out = await browserRead({ shell, script, url: URL, open: false, ...FAST });
108
+
109
+ expect(out.ok).toBe(false);
110
+ expect(out.message).toContain('没停在那套系统上');
111
+ expect(commands.some((one) => one.includes("'open'"))).toBe(false);
112
+ });
113
+
114
+ it('打开之后还是不在站点上:报 cli-exec-failed(不是静默成功)', async () => {
115
+ const { shell } = fakeShell([
116
+ { stdout: JSON.stringify({ ok: false, code: 'not-on-site' }) },
117
+ { stdout: 'opened' },
118
+ { stdout: JSON.stringify({ ok: false, code: 'not-on-site' }) },
119
+ ]);
120
+ const out = await browserRead({ shell, script, url: URL, ...FAST });
121
+ expect(out.code).toBe('cli-exec-failed');
122
+ expect(out.message).toContain('仍然不在那套系统上');
123
+ });
124
+ });
125
+
126
+ describe('browserRead · 命令这一层的问题', () => {
127
+ it('刚重启时第一次失败:等一下再读一次就成功(实测的那个坑)', async () => {
128
+ const { shell, commands } = fakeShell([
129
+ { exitCode: 1, stderr: 'opencli: daemon not ready' },
130
+ { stdout: JSON.stringify({ ok: true }) },
131
+ ]);
132
+ const out = await browserRead({ shell, script, ...FAST });
133
+ expect(out.ok).toBe(true);
134
+ expect(commands.filter((one) => one.includes("'eval'"))).toHaveLength(2);
135
+ });
136
+
137
+ it('一直失败 → cli-exec-failed + 原始输出尾巴', async () => {
138
+ const { shell } = fakeShell([{ exitCode: 1, stderr: 'boom\nline2' }]);
139
+ const out = await browserRead({ shell, script, ...FAST });
140
+ expect(out.code).toBe('cli-exec-failed');
141
+ expect(out.detail).toContain('boom');
142
+ });
143
+
144
+ it('stderr 里是权限问题 → permission-denied(不当成普通失败)', async () => {
145
+ const { shell } = fakeShell([{ exitCode: 1, stderr: 'PERMISSION_DENIED: nope' }]);
146
+ const out = await browserRead({ shell, script, ...FAST });
147
+ expect(out.code).toBe('permission-denied');
148
+ });
149
+
150
+ it('超时 → cli-timeout,而且**不**重试(已经等满了)', async () => {
151
+ const { shell, commands } = fakeShell([{ timedOut: true }]);
152
+ const out = await browserRead({ shell, script, ...FAST });
153
+ expect(out.code).toBe('cli-timeout');
154
+ expect(commands.filter((one) => one.includes("'eval'"))).toHaveLength(1);
155
+ });
156
+
157
+ it('输出不是 JSON → cli-exec-failed 且说清是"不是 JSON"', async () => {
158
+ const { shell } = fakeShell([{ stdout: 'not json at all' }]);
159
+ const out = await browserRead({ shell, script, ...FAST });
160
+ expect(out.code).toBe('cli-exec-failed');
161
+ expect(out.message).toContain('不是 JSON');
162
+ });
163
+ });
164
+
165
+ function backgroundless(commands: readonly string[]): readonly string[] {
166
+ return commands
167
+ .filter((one) => one.includes("'browser'"))
168
+ .filter(
169
+ (one) => !one.includes("'--window' 'background'") || !one.includes("'OPENCLI_WINDOW=background'"),
170
+ );
171
+ }
172
+
173
+ describe('browserRead · 后台静默(每一拍都不许抢焦点)', () => {
174
+ it('不在站点上那一趟:eval → open → eval 三条全带', async () => {
175
+ const { shell, commands } = fakeShell([
176
+ { stdout: JSON.stringify({ ok: false, code: 'not-on-site' }) },
177
+ {},
178
+ { stdout: JSON.stringify({ ok: true, n: 1 }) },
179
+ ]);
180
+ const out = await browserRead({ shell, script, url: URL, ...FAST });
181
+
182
+ expect(out.ok).toBe(true);
183
+ expect(commands.filter((one) => one.includes("'browser'"))).toHaveLength(3);
184
+ expect(backgroundless(commands)).toEqual([]);
185
+ });
186
+
187
+ it('命令失败重试那一拍也带', async () => {
188
+ const { shell, commands } = fakeShell([
189
+ { exitCode: 1, stderr: 'boom' },
190
+ { stdout: JSON.stringify({ ok: true }) },
191
+ ]);
192
+ await browserRead({ shell, script, url: URL, ...FAST });
193
+
194
+ expect(commands.filter((one) => one.includes("'eval'"))).toHaveLength(2);
195
+ expect(backgroundless(commands)).toEqual([]);
196
+ });
197
+ });
@@ -0,0 +1,311 @@
1
+ import type { DshShell } from '../api/dsh.js';
2
+ import { parseJsonLoose } from '../lib/text.js';
3
+ import { BROWSER_BRIDGE_SESSION } from './browser-read.js';
4
+ import { backgroundLine } from './opencli-window.js';
5
+ import { runShell } from './shell.js';
6
+
7
+ const DEFAULT_TIMEOUT_MS = 25000;
8
+ const DEFAULT_SETTLE_MS = 2500;
9
+ const URL_TIMEOUT_MS = 8000;
10
+ const DEFAULT_LIMIT = 50;
11
+ const DEFAULT_TEXT_MAX = 200;
12
+
13
+ export interface BrowserFindEntry {
14
+ readonly text: string;
15
+ readonly tag: string;
16
+ readonly nth: number;
17
+ }
18
+
19
+ export interface BrowserFindResult {
20
+ readonly ok: boolean;
21
+ readonly code: string;
22
+ readonly at: number;
23
+ readonly entries: readonly BrowserFindEntry[];
24
+ readonly count: number;
25
+ readonly message: string;
26
+ readonly hint?: string;
27
+ readonly detail?: string;
28
+ readonly helpUrl?: string;
29
+ }
30
+
31
+ export interface BrowserFindOptions {
32
+ readonly shell: DshShell;
33
+ readonly css: string;
34
+ readonly url?: string;
35
+ readonly command?: string;
36
+ readonly session?: string;
37
+ readonly limit?: number;
38
+ readonly textMax?: number;
39
+ readonly open?: boolean;
40
+ readonly timeoutMs?: number;
41
+ readonly settleMs?: number;
42
+ }
43
+
44
+ export function cellRowsOf(
45
+ entries: readonly { readonly text?: string }[],
46
+ minCells = 2,
47
+ ): string[][] {
48
+ const rows: string[][] = [];
49
+ for (const entry of entries) {
50
+ const text = typeof entry?.text === 'string' ? entry.text : '';
51
+ const cells = text
52
+ .split('\n')
53
+ .map((one) => one.trim())
54
+ .filter((one) => one !== '');
55
+ if (cells.length >= minCells) rows.push(cells);
56
+ }
57
+ return rows;
58
+ }
59
+
60
+ export function cellRows(text: string): string[] {
61
+ return text
62
+ .split('\n')
63
+ .map((one) => one.trim())
64
+ .filter((one) => one !== '');
65
+ }
66
+
67
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
68
+ return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;
69
+ }
70
+
71
+ function textOf(value: unknown): string {
72
+ return typeof value === 'string' ? value.trim() : '';
73
+ }
74
+
75
+ export function entriesOf(value: unknown): BrowserFindEntry[] {
76
+ const record = asRecord(value);
77
+ const raw = record === undefined ? undefined : record['entries'];
78
+ if (!Array.isArray(raw)) return [];
79
+ const entries: BrowserFindEntry[] = [];
80
+ for (const one of raw) {
81
+ const item = asRecord(one);
82
+ if (item === undefined) continue;
83
+ const nth = typeof item['nth'] === 'number' ? item['nth'] : entries.length;
84
+ entries.push({ text: textOf(item['text']), tag: textOf(item['tag']), nth });
85
+ }
86
+ return entries;
87
+ }
88
+
89
+ export function reasonOf(stdout: string, stderr: string): string {
90
+ const parsed = asRecord(parseJsonLoose(stdout));
91
+ const error = parsed === undefined ? undefined : asRecord(parsed['error']);
92
+ const message = error === undefined ? '' : textOf(error['message']);
93
+ if (message !== '') return message;
94
+ const source = stderr.trim() === '' ? stdout : stderr;
95
+ const lines = source
96
+ .split('\n')
97
+ .map((one) => one.trim())
98
+ .filter((one) => one !== '');
99
+ return (lines[lines.length - 1] ?? '').slice(-200);
100
+ }
101
+
102
+ function hintOf(stdout: string): string | undefined {
103
+ const parsed = asRecord(parseJsonLoose(stdout));
104
+ const error = parsed === undefined ? undefined : asRecord(parsed['error']);
105
+ const hint = error === undefined ? '' : textOf(error['hint']);
106
+ return hint === '' ? undefined : hint;
107
+ }
108
+
109
+ export function sameOrigin(left: string, right: string): boolean {
110
+ try {
111
+ return new URL(left).origin === new URL(right).origin;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ export function urlOf(stdout: string): string {
118
+ const lines = stdout
119
+ .split('\n')
120
+ .map((one) => one.trim())
121
+ .filter((one) => one !== '');
122
+ const first = (lines[0] ?? '').replace(/^url:\s*/i, '').trim();
123
+ const bare = first.replace(/^["']+|["']+$/g, '');
124
+ if (bare.startsWith('{')) {
125
+ const parsed = asRecord(parseJsonLoose(bare));
126
+ const url = parsed === undefined ? '' : textOf(parsed['url']);
127
+ if (url !== '') return url;
128
+ }
129
+ return bare;
130
+ }
131
+
132
+ async function currentUrl(
133
+ shell: DshShell,
134
+ command: string,
135
+ session: string,
136
+ ): Promise<string> {
137
+ const line = backgroundLine(command, ['browser', session, 'get', 'url', '--window', 'background']);
138
+ try {
139
+ const result = await runShell(shell, line, { timeoutMs: URL_TIMEOUT_MS });
140
+ if (result.exitCode !== 0) return '';
141
+ return urlOf(result.stdout);
142
+ } catch {
143
+ return '';
144
+ }
145
+ }
146
+
147
+ function sleep(ms: number): Promise<void> {
148
+ return new Promise((resolve) => setTimeout(resolve, ms));
149
+ }
150
+
151
+ type FindOutcome =
152
+ | { readonly kind: 'ok'; readonly entries: readonly BrowserFindEntry[]; readonly stdout: string }
153
+ | { readonly kind: 'timeout'; readonly detail: string }
154
+ | { readonly kind: 'failed'; readonly detail: string; readonly hint?: string };
155
+
156
+ async function findOnce(
157
+ shell: DshShell,
158
+ command: string,
159
+ session: string,
160
+ css: string,
161
+ limit: number,
162
+ textMax: number,
163
+ timeoutMs: number,
164
+ ): Promise<FindOutcome | undefined> {
165
+ const line = backgroundLine(command, [
166
+ 'browser',
167
+ session,
168
+ 'find',
169
+ '--css',
170
+ css,
171
+ '--limit',
172
+ String(limit),
173
+ '--text-max',
174
+ String(textMax),
175
+ '--window',
176
+ 'background',
177
+ ]);
178
+ const result = await runShell(shell, line, { timeoutMs });
179
+ if (result.timedOut) {
180
+ return { kind: 'timeout', detail: `没在 ${Math.round(timeoutMs / 1000)} 秒内返回` };
181
+ }
182
+ if (result.exitCode !== 0) {
183
+ return {
184
+ kind: 'failed',
185
+ detail: reasonOf(result.stdout, result.stderr),
186
+ ...(hintOf(result.stdout) === undefined ? {} : { hint: hintOf(result.stdout) }),
187
+ };
188
+ }
189
+ const entries = entriesOf(parseJsonLoose(result.stdout));
190
+ return { kind: 'ok', entries, stdout: result.stdout };
191
+ }
192
+
193
+ async function openOnce(
194
+ shell: DshShell,
195
+ command: string,
196
+ session: string,
197
+ url: string,
198
+ timeoutMs: number,
199
+ ): Promise<string | undefined> {
200
+ const line = backgroundLine(command, ['browser', session, 'open', url, '--window', 'background']);
201
+ let result: Awaited<ReturnType<typeof runShell>>;
202
+ try {
203
+ result = await runShell(shell, line, { timeoutMs });
204
+ } catch (error) {
205
+ return error instanceof Error ? error.message : String(error);
206
+ }
207
+ if (result.timedOut) return `没在 ${Math.round(timeoutMs / 1000)} 秒内打开`;
208
+ if (result.exitCode !== 0) {
209
+ const reason = reasonOf(result.stdout, result.stderr);
210
+ return reason === '' ? `opencli 打开失败(退出码 ${String(result.exitCode ?? '?')})` : reason;
211
+ }
212
+ return undefined;
213
+ }
214
+
215
+ export async function browserFind(options: BrowserFindOptions): Promise<BrowserFindResult> {
216
+ const { shell, css, url, open = true } = options;
217
+ const command = options.command ?? 'opencli';
218
+ const session = options.session ?? BROWSER_BRIDGE_SESSION;
219
+ const limit = options.limit ?? DEFAULT_LIMIT;
220
+ const textMax = options.textMax ?? DEFAULT_TEXT_MAX;
221
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
222
+ const settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;
223
+ const at = Date.now();
224
+
225
+ const find = async (): Promise<FindOutcome | undefined> => {
226
+ try {
227
+ return await findOnce(shell, command, session, css, limit, textMax, timeoutMs);
228
+ } catch (error) {
229
+ return {
230
+ kind: 'failed',
231
+ detail: error instanceof Error ? error.message : String(error),
232
+ hint: '确认这台机器上 opencli 能跑(工作台会自己把它挂好)',
233
+ };
234
+ }
235
+ };
236
+
237
+ const canMove = open && url !== undefined;
238
+
239
+ if (canMove) {
240
+ const here = await currentUrl(shell, command, session);
241
+ if (here !== '' && !sameOrigin(here, url)) {
242
+ const opened = await openOnce(shell, command, session, url, timeoutMs);
243
+ if (opened !== undefined) {
244
+ return {
245
+ ok: false,
246
+ code: 'cli-exec-failed',
247
+ at,
248
+ entries: [],
249
+ count: 0,
250
+ message: '打不开那个页面',
251
+ hint: '在浏览器里手动打开一次,然后重试',
252
+ detail: opened,
253
+ helpUrl: url,
254
+ };
255
+ }
256
+ await sleep(settleMs);
257
+ }
258
+ }
259
+
260
+ let outcome = await find();
261
+ if (outcome === undefined) {
262
+ return { ok: false, code: 'cli-exec-failed', at, entries: [], count: 0, message: '没找到元素' };
263
+ }
264
+
265
+ if (outcome.kind === 'failed' && canMove) {
266
+ const opened = await openOnce(shell, command, session, url, timeoutMs);
267
+ if (opened === undefined) {
268
+ await sleep(settleMs);
269
+ const second = await find();
270
+ if (second !== undefined) outcome = second;
271
+ }
272
+ }
273
+
274
+ if (outcome.kind === 'timeout') {
275
+ return {
276
+ ok: false,
277
+ code: 'cli-timeout',
278
+ at,
279
+ entries: [],
280
+ count: 0,
281
+ message: '这次没找到元素(等超时了)',
282
+ hint: '稍后重试',
283
+ detail: outcome.detail,
284
+ ...(url === undefined ? {} : { helpUrl: url }),
285
+ };
286
+ }
287
+
288
+ if (outcome.kind === 'failed') {
289
+ const detail = outcome.detail;
290
+ return {
291
+ ok: false,
292
+ code: 'cli-exec-failed',
293
+ at,
294
+ entries: [],
295
+ count: 0,
296
+ message: detail === '' ? '没找到元素(opencli 命令失败)' : `没找到元素:${detail}`,
297
+ ...(outcome.hint === undefined ? {} : { hint: outcome.hint }),
298
+ ...(detail === '' ? {} : { detail }),
299
+ ...(url === undefined ? {} : { helpUrl: url }),
300
+ };
301
+ }
302
+
303
+ return {
304
+ ok: true,
305
+ code: 'ok',
306
+ at,
307
+ entries: outcome.entries,
308
+ count: outcome.entries.length,
309
+ message: outcome.entries.length === 0 ? '页面上没有匹配的元素' : `找到 ${String(outcome.entries.length)} 个元素`,
310
+ };
311
+ }