@jackwener/opencli 1.7.5 → 1.7.6

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 (69) hide show
  1. package/README.md +5 -2
  2. package/README.zh-CN.md +5 -2
  3. package/cli-manifest.json +77 -1
  4. package/clis/bilibili/video.js +61 -0
  5. package/clis/bilibili/video.test.js +81 -0
  6. package/clis/deepseek/ask.js +21 -1
  7. package/clis/deepseek/ask.test.js +73 -0
  8. package/clis/deepseek/utils.js +84 -1
  9. package/clis/deepseek/utils.test.js +37 -0
  10. package/clis/jianyu/search.js +139 -3
  11. package/clis/jianyu/search.test.js +25 -0
  12. package/clis/jianyu/shared/procurement-detail.js +15 -0
  13. package/clis/jianyu/shared/procurement-detail.test.js +12 -0
  14. package/clis/twitter/shared.js +7 -2
  15. package/clis/twitter/tweets.js +218 -0
  16. package/clis/twitter/tweets.test.js +125 -0
  17. package/clis/youtube/channel.js +35 -0
  18. package/dist/src/browser/base-page.d.ts +13 -3
  19. package/dist/src/browser/base-page.js +35 -25
  20. package/dist/src/browser/cdp.d.ts +1 -0
  21. package/dist/src/browser/cdp.js +12 -3
  22. package/dist/src/browser/compound.d.ts +59 -0
  23. package/dist/src/browser/compound.js +112 -0
  24. package/dist/src/browser/compound.test.d.ts +1 -0
  25. package/dist/src/browser/compound.test.js +175 -0
  26. package/dist/src/browser/dom-snapshot.d.ts +7 -0
  27. package/dist/src/browser/dom-snapshot.js +76 -3
  28. package/dist/src/browser/dom-snapshot.test.js +65 -0
  29. package/dist/src/browser/extract.d.ts +69 -0
  30. package/dist/src/browser/extract.js +132 -0
  31. package/dist/src/browser/extract.test.d.ts +1 -0
  32. package/dist/src/browser/extract.test.js +129 -0
  33. package/dist/src/browser/find.d.ts +76 -0
  34. package/dist/src/browser/find.js +179 -0
  35. package/dist/src/browser/find.test.d.ts +1 -0
  36. package/dist/src/browser/find.test.js +120 -0
  37. package/dist/src/browser/html-tree.d.ts +75 -0
  38. package/dist/src/browser/html-tree.js +112 -0
  39. package/dist/src/browser/html-tree.test.d.ts +1 -0
  40. package/dist/src/browser/html-tree.test.js +181 -0
  41. package/dist/src/browser/network-cache.d.ts +48 -0
  42. package/dist/src/browser/network-cache.js +66 -0
  43. package/dist/src/browser/network-cache.test.d.ts +1 -0
  44. package/dist/src/browser/network-cache.test.js +58 -0
  45. package/dist/src/browser/network-key.d.ts +22 -0
  46. package/dist/src/browser/network-key.js +66 -0
  47. package/dist/src/browser/network-key.test.d.ts +1 -0
  48. package/dist/src/browser/network-key.test.js +49 -0
  49. package/dist/src/browser/shape-filter.d.ts +52 -0
  50. package/dist/src/browser/shape-filter.js +101 -0
  51. package/dist/src/browser/shape-filter.test.d.ts +1 -0
  52. package/dist/src/browser/shape-filter.test.js +101 -0
  53. package/dist/src/browser/shape.d.ts +23 -0
  54. package/dist/src/browser/shape.js +95 -0
  55. package/dist/src/browser/shape.test.d.ts +1 -0
  56. package/dist/src/browser/shape.test.js +82 -0
  57. package/dist/src/browser/target-errors.d.ts +14 -1
  58. package/dist/src/browser/target-errors.js +13 -0
  59. package/dist/src/browser/target-errors.test.js +39 -6
  60. package/dist/src/browser/target-resolver.d.ts +57 -10
  61. package/dist/src/browser/target-resolver.js +195 -75
  62. package/dist/src/browser/target-resolver.test.js +80 -5
  63. package/dist/src/cli.js +630 -125
  64. package/dist/src/cli.test.js +794 -0
  65. package/dist/src/execution.js +7 -2
  66. package/dist/src/execution.test.js +54 -0
  67. package/dist/src/main.js +16 -0
  68. package/dist/src/types.d.ts +18 -3
  69. package/package.json +1 -1
@@ -115,6 +115,41 @@ cli({
115
115
  }
116
116
  }
117
117
 
118
+ // If Home tab has no videos, try Videos tab
119
+ if (recentVideos.length === 0) {
120
+ const videosTab = tabs.find(t => {
121
+ const tab = t.tabRenderer;
122
+ const url = tab?.endpoint?.commandMetadata?.webCommandMetadata?.url || '';
123
+ return tab?.tabIdentifier === 'VIDEOS'
124
+ || url.endsWith('/videos')
125
+ || tab?.title === 'Videos';
126
+ });
127
+ const videosTabParams = videosTab?.tabRenderer?.endpoint?.browseEndpoint?.params;
128
+ if (videosTabParams) {
129
+ const videosResp = await fetch('/youtubei/v1/browse?key=' + apiKey + '&prettyPrint=false', {
130
+ method: 'POST', credentials: 'include',
131
+ headers: {'Content-Type': 'application/json'},
132
+ body: JSON.stringify({context, browseId, params: videosTabParams})
133
+ });
134
+ if (videosResp.ok) {
135
+ const videosData = await videosResp.json();
136
+ const richGrid = videosData.contents?.twoColumnBrowseResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.richGridRenderer?.contents || [];
137
+ for (const item of richGrid) {
138
+ if (recentVideos.length >= limit) break;
139
+ const v = item.richItemRenderer?.content?.videoRenderer;
140
+ if (v) {
141
+ recentVideos.push({
142
+ title: v.title?.runs?.[0]?.text || '',
143
+ duration: v.lengthText?.simpleText || '',
144
+ views: (v.shortViewCountText?.simpleText || '') + (v.publishedTimeText?.simpleText ? ' | ' + v.publishedTimeText.simpleText : ''),
145
+ url: 'https://www.youtube.com/watch?v=' + v.videoId,
146
+ });
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+
118
153
  return {
119
154
  name: metadata.title || '',
120
155
  channelId: metadata.externalId || browseId,
@@ -9,6 +9,16 @@
9
9
  * getCookies, screenshot, tabs, etc.
10
10
  */
11
11
  import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
12
+ import { type ResolveOptions, type TargetMatchLevel } from './target-resolver.js';
13
+ export interface ResolveSuccess {
14
+ matches_n: number;
15
+ /**
16
+ * Cascading stale-ref tier the resolver traversed. Callers surface this to
17
+ * agents so `stable` / `reidentified` hits are visibly distinct from a
18
+ * clean `exact` match — the page changed, the action still succeeded.
19
+ */
20
+ match_level: TargetMatchLevel;
21
+ }
12
22
  export declare abstract class BasePage implements IPage {
13
23
  protected _lastUrl: string | null;
14
24
  /** Cached previous snapshot hashes for incremental diff marking */
@@ -34,12 +44,12 @@ export declare abstract class BasePage implements IPage {
34
44
  abstract screenshot(options?: ScreenshotOptions): Promise<string>;
35
45
  abstract tabs(): Promise<unknown[]>;
36
46
  abstract selectTab(target: number | string): Promise<void>;
37
- click(ref: string): Promise<void>;
47
+ click(ref: string, opts?: ResolveOptions): Promise<ResolveSuccess>;
38
48
  /** Override in subclasses with CDP native click support */
39
49
  protected tryNativeClick(_x: number, _y: number): Promise<boolean>;
40
- typeText(ref: string, text: string): Promise<void>;
50
+ typeText(ref: string, text: string, opts?: ResolveOptions): Promise<ResolveSuccess>;
41
51
  pressKey(key: string): Promise<void>;
42
- scrollTo(ref: string): Promise<unknown>;
52
+ scrollTo(ref: string, opts?: ResolveOptions): Promise<unknown>;
43
53
  getFormState(): Promise<Record<string, unknown>>;
44
54
  scroll(direction?: string, amount?: number): Promise<void>;
45
55
  autoScroll(options?: {
@@ -10,8 +10,26 @@
10
10
  */
11
11
  import { generateSnapshotJs, getFormStateJs } from './dom-snapshot.js';
12
12
  import { pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
13
- import { resolveTargetJs, clickResolvedJs, typeResolvedJs, scrollResolvedJs } from './target-resolver.js';
13
+ import { resolveTargetJs, clickResolvedJs, typeResolvedJs, scrollResolvedJs, } from './target-resolver.js';
14
14
  import { TargetError } from './target-errors.js';
15
+ /**
16
+ * Execute `resolveTargetJs` once, throw structured `TargetError` on failure.
17
+ * Single helper so click/typeText/scrollTo share one resolution pathway,
18
+ * which is what the selector-first contract promises agents.
19
+ */
20
+ async function runResolve(page, ref, opts = {}) {
21
+ const resolution = (await page.evaluate(resolveTargetJs(ref, opts)));
22
+ if (!resolution.ok) {
23
+ throw new TargetError({
24
+ code: resolution.code,
25
+ message: resolution.message,
26
+ hint: resolution.hint,
27
+ candidates: resolution.candidates,
28
+ matches_n: resolution.matches_n,
29
+ });
30
+ }
31
+ return { matches_n: resolution.matches_n, match_level: resolution.match_level };
32
+ }
15
33
  import { formatSnapshot } from '../snapshotFormatter.js';
16
34
  export class BasePage {
17
35
  _lastUrl = null;
@@ -37,25 +55,20 @@ export class BasePage {
37
55
  return this.evaluate(`${declarations}\n${js}`);
38
56
  }
39
57
  // ── Shared DOM helper implementations ──
40
- async click(ref) {
58
+ async click(ref, opts = {}) {
41
59
  // Phase 1: Resolve target with fingerprint verification
42
- const resolution = await this.evaluate(resolveTargetJs(ref));
43
- if (!resolution.ok) {
44
- throw new TargetError(resolution);
45
- }
60
+ const resolved = await runResolve(this, ref, opts);
46
61
  // Phase 2: Execute click on resolved element
47
62
  const result = await this.evaluate(clickResolvedJs());
48
- // Backwards compat: old format returned 'clicked' string
49
63
  if (typeof result === 'string' || result == null)
50
- return;
51
- // JS click succeeded
64
+ return resolved;
52
65
  if (result.status === 'clicked')
53
- return;
66
+ return resolved;
54
67
  // JS click failed — try CDP native click if coordinates available
55
68
  if (result.x != null && result.y != null) {
56
69
  const success = await this.tryNativeClick(result.x, result.y);
57
70
  if (success)
58
- return;
71
+ return resolved;
59
72
  }
60
73
  throw new Error(`Click failed: ${result.error ?? 'JS click and CDP fallback both failed'}`);
61
74
  }
@@ -63,26 +76,23 @@ export class BasePage {
63
76
  async tryNativeClick(_x, _y) {
64
77
  return false;
65
78
  }
66
- async typeText(ref, text) {
67
- // Phase 1: Resolve target with fingerprint verification
68
- const resolution = await this.evaluate(resolveTargetJs(ref));
69
- if (!resolution.ok) {
70
- throw new TargetError(resolution);
71
- }
72
- // Phase 2: Execute type on resolved element
79
+ async typeText(ref, text, opts = {}) {
80
+ const resolved = await runResolve(this, ref, opts);
73
81
  await this.evaluate(typeResolvedJs(text));
82
+ return resolved;
74
83
  }
75
84
  async pressKey(key) {
76
85
  await this.evaluate(pressKeyJs(key));
77
86
  }
78
- async scrollTo(ref) {
79
- // Phase 1: Resolve target with fingerprint verification
80
- const resolution = await this.evaluate(resolveTargetJs(ref));
81
- if (!resolution.ok) {
82
- throw new TargetError(resolution);
87
+ async scrollTo(ref, opts = {}) {
88
+ const resolved = await runResolve(this, ref, opts);
89
+ const result = (await this.evaluate(scrollResolvedJs()));
90
+ // Fold match_level into the scroll payload so the user-facing envelope
91
+ // carries it the same way click / type do.
92
+ if (result && typeof result === 'object') {
93
+ return { ...result, matches_n: resolved.matches_n, match_level: resolved.match_level };
83
94
  }
84
- // Phase 2: Scroll to resolved element
85
- return this.evaluate(scrollResolvedJs());
95
+ return { matches_n: resolved.matches_n, match_level: resolved.match_level };
86
96
  }
87
97
  async getFormState() {
88
98
  return (await this.evaluate(getFormStateJs()));
@@ -15,6 +15,7 @@ export interface CDPTarget {
15
15
  title?: string;
16
16
  webSocketDebuggerUrl?: string;
17
17
  }
18
+ export declare const CDP_RESPONSE_BODY_CAPTURE_LIMIT: number;
18
19
  export declare class CDPBridge implements IBrowserFactory {
19
20
  private _ws;
20
21
  private _idCounter;
@@ -17,6 +17,12 @@ import { isRecord, saveBase64ToFile } from '../utils.js';
17
17
  import { getAllElectronApps } from '../electron-apps.js';
18
18
  import { BasePage } from './base-page.js';
19
19
  const CDP_SEND_TIMEOUT = 30_000;
20
+ // Memory guard for in-process capture. The 4k cap we used to apply everywhere
21
+ // silently truncated JSON so `JSON.parse` failed or gave partial objects — the
22
+ // primary agent-facing bug. Now we keep the full body up to a large cap and
23
+ // surface `responseBodyFullSize` + `responseBodyTruncated` so downstream layers
24
+ // can tell the agent what happened instead of lying about the payload.
25
+ export const CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024;
20
26
  export class CDPBridge {
21
27
  _ws = null;
22
28
  _idCounter = 0;
@@ -240,9 +246,12 @@ class CDPPage extends BasePage {
240
246
  const bodyFetch = this.bridge.send('Network.getResponseBody', { requestId: p.requestId }).then((result) => {
241
247
  const r = result;
242
248
  if (typeof r?.body === 'string') {
243
- this._networkEntries[idx].responsePreview = r.base64Encoded
244
- ? `base64:${r.body.slice(0, 4000)}`
245
- : r.body.slice(0, 4000);
249
+ const fullSize = r.body.length;
250
+ const truncated = fullSize > CDP_RESPONSE_BODY_CAPTURE_LIMIT;
251
+ const body = truncated ? r.body.slice(0, CDP_RESPONSE_BODY_CAPTURE_LIMIT) : r.body;
252
+ this._networkEntries[idx].responsePreview = r.base64Encoded ? `base64:${body}` : body;
253
+ this._networkEntries[idx].responseBodyFullSize = fullSize;
254
+ this._networkEntries[idx].responseBodyTruncated = truncated;
246
255
  }
247
256
  }).catch(() => {
248
257
  // Body unavailable for some requests (e.g. uploads) — non-fatal
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Compound-component expansion for high-agent-failure form controls.
3
+ *
4
+ * Agents burn turns on three recurring input categories because the raw
5
+ * attribute dump from `browser state` under-specifies them:
6
+ *
7
+ * - date / time / datetime-local / month / week — agents type
8
+ * free-form strings and the browser silently ignores mismatched formats.
9
+ * - select — the snapshot caps visible options at ~6; agents don't know
10
+ * the full option set, can't match by label, and waste turns clicking
11
+ * to open the dropdown just to read options.
12
+ * - file — the snapshot shows current filenames but not `accept` or
13
+ * `multiple`; agents re-upload or pick unsupported MIME types.
14
+ *
15
+ * `compoundInfoOf(el)` returns a structured JSON summary agents can rely
16
+ * on. Included in `browser find --css` envelope so the agent gets the
17
+ * rich view without extra round-trips.
18
+ *
19
+ * Emitted as a JS source string (`COMPOUND_INFO_JS`) so it can be inlined
20
+ * into the generated evaluate scripts under find / snapshot / eval.
21
+ */
22
+ export type DateLikeControl = 'date' | 'time' | 'datetime-local' | 'month' | 'week';
23
+ export interface DateCompound {
24
+ control: DateLikeControl;
25
+ format: string;
26
+ current: string;
27
+ min?: string;
28
+ max?: string;
29
+ }
30
+ export interface SelectOption {
31
+ label: string;
32
+ value: string;
33
+ selected: boolean;
34
+ disabled?: boolean;
35
+ }
36
+ export interface SelectCompound {
37
+ control: 'select';
38
+ multiple: boolean;
39
+ current: string | string[];
40
+ options: SelectOption[];
41
+ options_total: number;
42
+ }
43
+ export interface FileCompound {
44
+ control: 'file';
45
+ multiple: boolean;
46
+ current: string[];
47
+ accept?: string;
48
+ }
49
+ export type CompoundInfo = DateCompound | SelectCompound | FileCompound;
50
+ /** Max options included in a SelectCompound.options[]. Above this, `options_total` still reflects the true count. */
51
+ export declare const COMPOUND_SELECT_OPTIONS_CAP = 50;
52
+ /** Max characters per option label / file name. */
53
+ export declare const COMPOUND_LABEL_CAP = 80;
54
+ /**
55
+ * JavaScript source declaring `compoundInfoOf(el)`. Inlined into the JS
56
+ * emitted by `buildFindJs` (and any other evaluate script that needs the
57
+ * rich compound view). Returns a `CompoundInfo` object or `null`.
58
+ */
59
+ export declare const COMPOUND_INFO_JS = "\nfunction compoundInfoOf(el) {\n if (!el || !el.tagName) return null;\n const tag = el.tagName;\n const LABEL_CAP = 80;\n const OPTS_CAP = 50;\n if (tag === 'INPUT') {\n const type = (el.getAttribute('type') || 'text').toLowerCase();\n const FORMATS = {\n 'date': 'YYYY-MM-DD',\n 'time': 'HH:MM',\n 'datetime-local': 'YYYY-MM-DDTHH:MM',\n 'month': 'YYYY-MM',\n 'week': 'YYYY-W##',\n };\n if (FORMATS[type]) {\n const info = {\n control: type,\n format: FORMATS[type],\n current: (el.value == null ? '' : String(el.value)),\n };\n const min = el.getAttribute('min');\n if (min) info.min = min;\n const max = el.getAttribute('max');\n if (max) info.max = max;\n return info;\n }\n if (type === 'file') {\n const info = {\n control: 'file',\n multiple: !!el.multiple,\n current: [],\n };\n const accept = el.getAttribute('accept');\n if (accept) info.accept = accept;\n try {\n if (el.files && el.files.length) {\n for (let i = 0; i < el.files.length; i++) {\n const name = (el.files[i].name || '').slice(0, LABEL_CAP);\n info.current.push(name);\n }\n }\n } catch (_) {}\n return info;\n }\n return null;\n }\n if (tag === 'SELECT') {\n const multiple = !!el.multiple;\n const options = [];\n const selectedLabels = [];\n let total = 0;\n try {\n const opts = el.options || [];\n total = opts.length;\n // Walk ALL options so `current` reflects selections that sit beyond the\n // serialization cap. Only the first OPTS_CAP entries get pushed into\n // options[]; anything past the cap still contributes to selectedLabels\n // so agents see the true current state of big dropdowns.\n for (let i = 0; i < opts.length; i++) {\n const o = opts[i];\n const labelRaw = (o.label != null && o.label !== '') ? o.label : (o.text || '');\n const label = String(labelRaw).trim().slice(0, LABEL_CAP);\n if (i < OPTS_CAP) {\n const entry = { label: label, value: o.value, selected: !!o.selected };\n if (o.disabled) entry.disabled = true;\n options.push(entry);\n }\n if (o.selected) selectedLabels.push(label);\n }\n } catch (_) {}\n return {\n control: 'select',\n multiple: multiple,\n current: multiple ? selectedLabels : (selectedLabels[0] || ''),\n options: options,\n options_total: total,\n };\n }\n return null;\n}\n";
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Compound-component expansion for high-agent-failure form controls.
3
+ *
4
+ * Agents burn turns on three recurring input categories because the raw
5
+ * attribute dump from `browser state` under-specifies them:
6
+ *
7
+ * - date / time / datetime-local / month / week — agents type
8
+ * free-form strings and the browser silently ignores mismatched formats.
9
+ * - select — the snapshot caps visible options at ~6; agents don't know
10
+ * the full option set, can't match by label, and waste turns clicking
11
+ * to open the dropdown just to read options.
12
+ * - file — the snapshot shows current filenames but not `accept` or
13
+ * `multiple`; agents re-upload or pick unsupported MIME types.
14
+ *
15
+ * `compoundInfoOf(el)` returns a structured JSON summary agents can rely
16
+ * on. Included in `browser find --css` envelope so the agent gets the
17
+ * rich view without extra round-trips.
18
+ *
19
+ * Emitted as a JS source string (`COMPOUND_INFO_JS`) so it can be inlined
20
+ * into the generated evaluate scripts under find / snapshot / eval.
21
+ */
22
+ /** Max options included in a SelectCompound.options[]. Above this, `options_total` still reflects the true count. */
23
+ export const COMPOUND_SELECT_OPTIONS_CAP = 50;
24
+ /** Max characters per option label / file name. */
25
+ export const COMPOUND_LABEL_CAP = 80;
26
+ /**
27
+ * JavaScript source declaring `compoundInfoOf(el)`. Inlined into the JS
28
+ * emitted by `buildFindJs` (and any other evaluate script that needs the
29
+ * rich compound view). Returns a `CompoundInfo` object or `null`.
30
+ */
31
+ export const COMPOUND_INFO_JS = `
32
+ function compoundInfoOf(el) {
33
+ if (!el || !el.tagName) return null;
34
+ const tag = el.tagName;
35
+ const LABEL_CAP = ${COMPOUND_LABEL_CAP};
36
+ const OPTS_CAP = ${COMPOUND_SELECT_OPTIONS_CAP};
37
+ if (tag === 'INPUT') {
38
+ const type = (el.getAttribute('type') || 'text').toLowerCase();
39
+ const FORMATS = {
40
+ 'date': 'YYYY-MM-DD',
41
+ 'time': 'HH:MM',
42
+ 'datetime-local': 'YYYY-MM-DDTHH:MM',
43
+ 'month': 'YYYY-MM',
44
+ 'week': 'YYYY-W##',
45
+ };
46
+ if (FORMATS[type]) {
47
+ const info = {
48
+ control: type,
49
+ format: FORMATS[type],
50
+ current: (el.value == null ? '' : String(el.value)),
51
+ };
52
+ const min = el.getAttribute('min');
53
+ if (min) info.min = min;
54
+ const max = el.getAttribute('max');
55
+ if (max) info.max = max;
56
+ return info;
57
+ }
58
+ if (type === 'file') {
59
+ const info = {
60
+ control: 'file',
61
+ multiple: !!el.multiple,
62
+ current: [],
63
+ };
64
+ const accept = el.getAttribute('accept');
65
+ if (accept) info.accept = accept;
66
+ try {
67
+ if (el.files && el.files.length) {
68
+ for (let i = 0; i < el.files.length; i++) {
69
+ const name = (el.files[i].name || '').slice(0, LABEL_CAP);
70
+ info.current.push(name);
71
+ }
72
+ }
73
+ } catch (_) {}
74
+ return info;
75
+ }
76
+ return null;
77
+ }
78
+ if (tag === 'SELECT') {
79
+ const multiple = !!el.multiple;
80
+ const options = [];
81
+ const selectedLabels = [];
82
+ let total = 0;
83
+ try {
84
+ const opts = el.options || [];
85
+ total = opts.length;
86
+ // Walk ALL options so \`current\` reflects selections that sit beyond the
87
+ // serialization cap. Only the first OPTS_CAP entries get pushed into
88
+ // options[]; anything past the cap still contributes to selectedLabels
89
+ // so agents see the true current state of big dropdowns.
90
+ for (let i = 0; i < opts.length; i++) {
91
+ const o = opts[i];
92
+ const labelRaw = (o.label != null && o.label !== '') ? o.label : (o.text || '');
93
+ const label = String(labelRaw).trim().slice(0, LABEL_CAP);
94
+ if (i < OPTS_CAP) {
95
+ const entry = { label: label, value: o.value, selected: !!o.selected };
96
+ if (o.disabled) entry.disabled = true;
97
+ options.push(entry);
98
+ }
99
+ if (o.selected) selectedLabels.push(label);
100
+ }
101
+ } catch (_) {}
102
+ return {
103
+ control: 'select',
104
+ multiple: multiple,
105
+ current: multiple ? selectedLabels : (selectedLabels[0] || ''),
106
+ options: options,
107
+ options_total: total,
108
+ };
109
+ }
110
+ return null;
111
+ }
112
+ `;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,175 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { COMPOUND_INFO_JS, COMPOUND_LABEL_CAP, COMPOUND_SELECT_OPTIONS_CAP, } from './compound.js';
3
+ /**
4
+ * Tests run the JS source in a sandbox via `new Function`, feeding it
5
+ * minimal mock elements shaped like the DOM elements the real code sees
6
+ * at runtime. Avoids a full jsdom setup while still exercising the logic
7
+ * end-to-end instead of only snapshotting string markers.
8
+ */
9
+ function runCompound(mockEl) {
10
+ const fn = new Function('el', `${COMPOUND_INFO_JS}\nreturn compoundInfoOf(el);`);
11
+ return fn(mockEl);
12
+ }
13
+ function mockInput(attrs, extras = {}) {
14
+ return {
15
+ tagName: 'INPUT',
16
+ value: extras.value,
17
+ multiple: extras.multiple,
18
+ files: extras.files,
19
+ getAttribute(name) {
20
+ return attrs[name] ?? null;
21
+ },
22
+ };
23
+ }
24
+ function mockSelect(options, multiple = false) {
25
+ const opts = options.map(o => ({ ...o, selected: !!o.selected }));
26
+ return {
27
+ tagName: 'SELECT',
28
+ multiple,
29
+ options: opts,
30
+ getAttribute: () => null,
31
+ };
32
+ }
33
+ describe('compoundInfoOf — date-like inputs', () => {
34
+ it('returns { control, format, current } for <input type=date>', () => {
35
+ const info = runCompound(mockInput({ type: 'date' }, { value: '2026-04-21' }));
36
+ expect(info).toEqual({ control: 'date', format: 'YYYY-MM-DD', current: '2026-04-21' });
37
+ });
38
+ it('surfaces min + max when present', () => {
39
+ const info = runCompound(mockInput({ type: 'date', min: '2026-01-01', max: '2026-12-31' }, { value: '2026-04-21' }));
40
+ expect(info).toMatchObject({ min: '2026-01-01', max: '2026-12-31' });
41
+ });
42
+ it('handles time / datetime-local / month / week with correct format strings', () => {
43
+ const formats = {
44
+ time: 'HH:MM',
45
+ 'datetime-local': 'YYYY-MM-DDTHH:MM',
46
+ month: 'YYYY-MM',
47
+ week: 'YYYY-W##',
48
+ };
49
+ for (const [type, fmt] of Object.entries(formats)) {
50
+ const info = runCompound(mockInput({ type }, { value: '' }));
51
+ expect(info.format).toBe(fmt);
52
+ }
53
+ });
54
+ it('coerces null value into empty string instead of crashing', () => {
55
+ const info = runCompound(mockInput({ type: 'date' }));
56
+ expect(info).toMatchObject({ control: 'date', current: '' });
57
+ });
58
+ });
59
+ describe('compoundInfoOf — file inputs', () => {
60
+ it('returns { control: file, multiple, current[] }', () => {
61
+ const info = runCompound(mockInput({ type: 'file' }, {
62
+ multiple: true,
63
+ files: [{ name: 'a.png' }, { name: 'b.jpg' }],
64
+ }));
65
+ expect(info).toEqual({ control: 'file', multiple: true, current: ['a.png', 'b.jpg'] });
66
+ });
67
+ it('includes accept when present', () => {
68
+ const info = runCompound(mockInput({ type: 'file', accept: 'image/*' }, { multiple: false }));
69
+ expect(info).toMatchObject({ control: 'file', accept: 'image/*' });
70
+ });
71
+ it('returns empty current[] when nothing uploaded', () => {
72
+ const info = runCompound(mockInput({ type: 'file' }, { multiple: false }));
73
+ expect(info).toEqual({ control: 'file', multiple: false, current: [] });
74
+ });
75
+ it('caps file name at COMPOUND_LABEL_CAP', () => {
76
+ const longName = 'x'.repeat(COMPOUND_LABEL_CAP + 50);
77
+ const info = runCompound(mockInput({ type: 'file' }, { multiple: false, files: [{ name: longName }] }));
78
+ expect(info.current[0].length).toBe(COMPOUND_LABEL_CAP);
79
+ });
80
+ });
81
+ describe('compoundInfoOf — select', () => {
82
+ it('returns full options list with labels, values, selected flag', () => {
83
+ const info = runCompound(mockSelect([
84
+ { value: 'us', label: 'United States', selected: true },
85
+ { value: 'ca', label: 'Canada' },
86
+ { value: 'fr', label: 'France' },
87
+ ]));
88
+ expect(info.options).toHaveLength(3);
89
+ expect(info.options[0]).toEqual({ label: 'United States', value: 'us', selected: true });
90
+ expect(info.options[2]).toEqual({ label: 'France', value: 'fr', selected: false });
91
+ });
92
+ it('sets current to the selected label (single-select)', () => {
93
+ const info = runCompound(mockSelect([
94
+ { value: 'a', label: 'Alpha' },
95
+ { value: 'b', label: 'Bravo', selected: true },
96
+ ]));
97
+ expect(info).toMatchObject({ control: 'select', multiple: false, current: 'Bravo' });
98
+ });
99
+ it('sets current to an array of labels when multiple=true', () => {
100
+ const info = runCompound(mockSelect([
101
+ { value: 'a', label: 'Alpha', selected: true },
102
+ { value: 'b', label: 'Bravo' },
103
+ { value: 'c', label: 'Charlie', selected: true },
104
+ ], true));
105
+ expect(info).toMatchObject({ control: 'select', multiple: true, current: ['Alpha', 'Charlie'] });
106
+ });
107
+ it('falls back from option.label to option.text', () => {
108
+ const info = runCompound(mockSelect([
109
+ { value: 'a', text: 'FromText' },
110
+ { value: 'b', label: '', text: 'EmptyLabelFallback' },
111
+ ]));
112
+ expect(info.options[0].label).toBe('FromText');
113
+ expect(info.options[1].label).toBe('EmptyLabelFallback');
114
+ });
115
+ it('marks disabled options', () => {
116
+ const info = runCompound(mockSelect([
117
+ { value: 'a', label: 'A' },
118
+ { value: 'b', label: 'B', disabled: true },
119
+ ]));
120
+ expect(info.options[0].disabled).toBeUndefined();
121
+ expect(info.options[1].disabled).toBe(true);
122
+ });
123
+ it('caps options[] at COMPOUND_SELECT_OPTIONS_CAP but keeps true options_total', () => {
124
+ const big = Array.from({ length: COMPOUND_SELECT_OPTIONS_CAP + 25 }, (_, i) => ({
125
+ value: 'v' + i,
126
+ label: 'L' + i,
127
+ }));
128
+ const info = runCompound(mockSelect(big));
129
+ expect(info.options.length).toBe(COMPOUND_SELECT_OPTIONS_CAP);
130
+ expect(info.options_total).toBe(COMPOUND_SELECT_OPTIONS_CAP + 25);
131
+ });
132
+ it('returns "" for current on single-select with no selected option', () => {
133
+ const info = runCompound(mockSelect([
134
+ { value: 'a', label: 'A' },
135
+ { value: 'b', label: 'B' },
136
+ ]));
137
+ expect(info).toMatchObject({ current: '' });
138
+ });
139
+ // Regression: the previous loop stopped walking options once it hit
140
+ // COMPOUND_SELECT_OPTIONS_CAP, so a long country dropdown with the
141
+ // selected country sitting at index 80 would be reported with current="".
142
+ // Agents then thought nothing was selected and picked another country.
143
+ it('populates current even when the selected option sits past the serialization cap', () => {
144
+ const big = Array.from({ length: COMPOUND_SELECT_OPTIONS_CAP + 25 }, (_, i) => ({
145
+ value: 'v' + i,
146
+ label: 'L' + i,
147
+ selected: i === COMPOUND_SELECT_OPTIONS_CAP + 10,
148
+ }));
149
+ const info = runCompound(mockSelect(big));
150
+ expect(info.current).toBe('L' + (COMPOUND_SELECT_OPTIONS_CAP + 10));
151
+ expect(info.options.length).toBe(COMPOUND_SELECT_OPTIONS_CAP);
152
+ expect(info.options_total).toBe(COMPOUND_SELECT_OPTIONS_CAP + 25);
153
+ });
154
+ it('multi-select: current[] includes labels for selected options beyond the cap', () => {
155
+ const big = Array.from({ length: COMPOUND_SELECT_OPTIONS_CAP + 10 }, (_, i) => ({
156
+ value: 'v' + i,
157
+ label: 'L' + i,
158
+ selected: i === 3 || i === COMPOUND_SELECT_OPTIONS_CAP + 5,
159
+ }));
160
+ const info = runCompound(mockSelect(big, true));
161
+ expect(info.current).toEqual(['L3', 'L' + (COMPOUND_SELECT_OPTIONS_CAP + 5)]);
162
+ });
163
+ });
164
+ describe('compoundInfoOf — unsupported shapes', () => {
165
+ it('returns null for plain text input', () => {
166
+ expect(runCompound(mockInput({ type: 'text' }, { value: 'hi' }))).toBeNull();
167
+ });
168
+ it('returns null for non-form tags', () => {
169
+ expect(runCompound({ tagName: 'DIV', getAttribute: () => null })).toBeNull();
170
+ });
171
+ it('returns null for null / missing element', () => {
172
+ expect(runCompound(null)).toBeNull();
173
+ expect(runCompound({})).toBeNull();
174
+ });
175
+ });
@@ -22,6 +22,13 @@
22
22
  * Additional tools:
23
23
  * - scrollToRefJs(ref) — scroll to a data-opencli-ref element
24
24
  * - getFormStateJs() — extract all form fields as structured JSON
25
+ *
26
+ * Compound sidecar:
27
+ * After the tree, a `compounds:` section lists rich JSON for every
28
+ * date/select/file ref — format, full option list (up to cap) with
29
+ * `options_total` reflecting the true count, file `accept` + `multiple`.
30
+ * This is what the snapshot's inline attr dump cannot express and what
31
+ * agents kept blowing turns on.
25
32
  */
26
33
  export interface DomSnapshotOptions {
27
34
  /** Extra pixels beyond viewport to include (default 800) */