@jackwener/opencli 1.7.4 → 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 (181) hide show
  1. package/README.md +76 -51
  2. package/README.zh-CN.md +78 -62
  3. package/cli-manifest.json +4558 -2979
  4. package/clis/antigravity/serve.js +71 -25
  5. package/clis/baidu-scholar/search.js +87 -0
  6. package/clis/baidu-scholar/search.test.js +23 -0
  7. package/clis/bilibili/video.js +61 -0
  8. package/clis/bilibili/video.test.js +81 -0
  9. package/clis/deepseek/ask.js +94 -0
  10. package/clis/deepseek/ask.test.js +73 -0
  11. package/clis/deepseek/history.js +25 -0
  12. package/clis/deepseek/new.js +20 -0
  13. package/clis/deepseek/read.js +22 -0
  14. package/clis/deepseek/status.js +24 -0
  15. package/clis/deepseek/utils.js +291 -0
  16. package/clis/deepseek/utils.test.js +37 -0
  17. package/clis/eastmoney/_secid.js +78 -0
  18. package/clis/eastmoney/announcement.js +52 -0
  19. package/clis/eastmoney/convertible.js +73 -0
  20. package/clis/eastmoney/etf.js +65 -0
  21. package/clis/eastmoney/holders.js +78 -0
  22. package/clis/eastmoney/index-board.js +96 -0
  23. package/clis/eastmoney/kline.js +87 -0
  24. package/clis/eastmoney/kuaixun.js +54 -0
  25. package/clis/eastmoney/longhu.js +67 -0
  26. package/clis/eastmoney/money-flow.js +78 -0
  27. package/clis/eastmoney/northbound.js +57 -0
  28. package/clis/eastmoney/quote.js +107 -0
  29. package/clis/eastmoney/rank.js +94 -0
  30. package/clis/eastmoney/sectors.js +76 -0
  31. package/clis/google-scholar/search.js +58 -0
  32. package/clis/google-scholar/search.test.js +23 -0
  33. package/clis/gov-law/commands.test.js +39 -0
  34. package/clis/gov-law/recent.js +22 -0
  35. package/clis/gov-law/search.js +41 -0
  36. package/clis/gov-law/shared.js +51 -0
  37. package/clis/gov-policy/commands.test.js +27 -0
  38. package/clis/gov-policy/recent.js +47 -0
  39. package/clis/gov-policy/search.js +48 -0
  40. package/clis/jianyu/search.js +139 -3
  41. package/clis/jianyu/search.test.js +25 -0
  42. package/clis/jianyu/shared/procurement-detail.js +15 -0
  43. package/clis/jianyu/shared/procurement-detail.test.js +12 -0
  44. package/clis/nowcoder/companies.js +23 -0
  45. package/clis/nowcoder/creators.js +27 -0
  46. package/clis/nowcoder/detail.js +61 -0
  47. package/clis/nowcoder/experience.js +36 -0
  48. package/clis/nowcoder/hot.js +24 -0
  49. package/clis/nowcoder/jobs.js +21 -0
  50. package/clis/nowcoder/notifications.js +29 -0
  51. package/clis/nowcoder/papers.js +40 -0
  52. package/clis/nowcoder/practice.js +37 -0
  53. package/clis/nowcoder/recommend.js +30 -0
  54. package/clis/nowcoder/referral.js +39 -0
  55. package/clis/nowcoder/salary.js +40 -0
  56. package/clis/nowcoder/search.js +49 -0
  57. package/clis/nowcoder/suggest.js +33 -0
  58. package/clis/nowcoder/topics.js +27 -0
  59. package/clis/nowcoder/trending.js +25 -0
  60. package/clis/twitter/list-add.js +337 -0
  61. package/clis/twitter/list-add.test.js +15 -0
  62. package/clis/twitter/list-remove.js +297 -0
  63. package/clis/twitter/list-remove.test.js +14 -0
  64. package/clis/twitter/list-tweets.js +185 -0
  65. package/clis/twitter/list-tweets.test.js +108 -0
  66. package/clis/twitter/lists.js +134 -47
  67. package/clis/twitter/lists.test.js +105 -38
  68. package/clis/twitter/shared.js +7 -2
  69. package/clis/twitter/tweets.js +218 -0
  70. package/clis/twitter/tweets.test.js +125 -0
  71. package/clis/wanfang/search.js +66 -0
  72. package/clis/wanfang/search.test.js +23 -0
  73. package/clis/web/read.js +1 -1
  74. package/clis/weixin/download.js +3 -2
  75. package/clis/xiaohongshu/publish.js +149 -28
  76. package/clis/xiaohongshu/publish.test.js +319 -6
  77. package/clis/xiaoyuzhou/download.js +8 -4
  78. package/clis/xiaoyuzhou/download.test.js +23 -13
  79. package/clis/xiaoyuzhou/episode.js +9 -4
  80. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  81. package/clis/xiaoyuzhou/podcast.js +9 -4
  82. package/clis/xiaoyuzhou/utils.js +0 -40
  83. package/clis/xiaoyuzhou/utils.test.js +15 -75
  84. package/clis/youtube/channel.js +35 -0
  85. package/clis/zsxq/dynamics.js +1 -1
  86. package/clis/zsxq/utils.js +6 -3
  87. package/clis/zsxq/utils.test.js +31 -0
  88. package/dist/src/browser/base-page.d.ts +14 -4
  89. package/dist/src/browser/base-page.js +35 -25
  90. package/dist/src/browser/bridge.d.ts +1 -0
  91. package/dist/src/browser/bridge.js +1 -1
  92. package/dist/src/browser/cdp.d.ts +1 -0
  93. package/dist/src/browser/cdp.js +13 -4
  94. package/dist/src/browser/compound.d.ts +59 -0
  95. package/dist/src/browser/compound.js +112 -0
  96. package/dist/src/browser/compound.test.js +175 -0
  97. package/dist/src/browser/daemon-client.d.ts +6 -4
  98. package/dist/src/browser/daemon-client.js +6 -1
  99. package/dist/src/browser/daemon-client.test.js +40 -1
  100. package/dist/src/browser/dom-snapshot.d.ts +7 -0
  101. package/dist/src/browser/dom-snapshot.js +83 -5
  102. package/dist/src/browser/dom-snapshot.test.js +65 -0
  103. package/dist/src/browser/extract.d.ts +69 -0
  104. package/dist/src/browser/extract.js +132 -0
  105. package/dist/src/browser/extract.test.js +129 -0
  106. package/dist/src/browser/find.d.ts +76 -0
  107. package/dist/src/browser/find.js +179 -0
  108. package/dist/src/browser/find.test.js +120 -0
  109. package/dist/src/browser/html-tree.d.ts +75 -0
  110. package/dist/src/browser/html-tree.js +112 -0
  111. package/dist/src/browser/html-tree.test.d.ts +1 -0
  112. package/dist/src/browser/html-tree.test.js +181 -0
  113. package/dist/src/browser/network-cache.d.ts +48 -0
  114. package/dist/src/browser/network-cache.js +66 -0
  115. package/dist/src/browser/network-cache.test.d.ts +1 -0
  116. package/dist/src/browser/network-cache.test.js +58 -0
  117. package/dist/src/browser/network-key.d.ts +22 -0
  118. package/dist/src/browser/network-key.js +66 -0
  119. package/dist/src/browser/network-key.test.d.ts +1 -0
  120. package/dist/src/browser/network-key.test.js +49 -0
  121. package/dist/src/browser/page.d.ts +14 -4
  122. package/dist/src/browser/page.js +48 -7
  123. package/dist/src/browser/page.test.js +97 -0
  124. package/dist/src/browser/shape-filter.d.ts +52 -0
  125. package/dist/src/browser/shape-filter.js +101 -0
  126. package/dist/src/browser/shape-filter.test.d.ts +1 -0
  127. package/dist/src/browser/shape-filter.test.js +101 -0
  128. package/dist/src/browser/shape.d.ts +23 -0
  129. package/dist/src/browser/shape.js +95 -0
  130. package/dist/src/browser/shape.test.d.ts +1 -0
  131. package/dist/src/browser/shape.test.js +82 -0
  132. package/dist/src/browser/target-errors.d.ts +14 -1
  133. package/dist/src/browser/target-errors.js +13 -0
  134. package/dist/src/browser/target-errors.test.js +39 -6
  135. package/dist/src/browser/target-resolver.d.ts +57 -10
  136. package/dist/src/browser/target-resolver.js +195 -75
  137. package/dist/src/browser/target-resolver.test.js +80 -5
  138. package/dist/src/cli.js +849 -267
  139. package/dist/src/cli.test.js +961 -90
  140. package/dist/src/commanderAdapter.d.ts +0 -1
  141. package/dist/src/commanderAdapter.js +2 -16
  142. package/dist/src/commanderAdapter.test.js +1 -1
  143. package/dist/src/completion-shared.js +2 -5
  144. package/dist/src/daemon.js +8 -0
  145. package/dist/src/download/article-download.d.ts +1 -0
  146. package/dist/src/download/article-download.js +3 -0
  147. package/dist/src/download/article-download.test.d.ts +1 -0
  148. package/dist/src/download/article-download.test.js +39 -0
  149. package/dist/src/execution.js +7 -2
  150. package/dist/src/execution.test.js +54 -0
  151. package/dist/src/main.js +16 -0
  152. package/dist/src/plugin.d.ts +1 -8
  153. package/dist/src/plugin.js +1 -27
  154. package/dist/src/plugin.test.js +1 -59
  155. package/dist/src/registry.d.ts +1 -0
  156. package/dist/src/registry.js +3 -2
  157. package/dist/src/registry.test.js +22 -0
  158. package/dist/src/types.d.ts +32 -8
  159. package/package.json +1 -1
  160. package/clis/twitter/lists-parser.js +0 -77
  161. package/clis/twitter/lists.d.ts +0 -5
  162. package/dist/src/cascade.d.ts +0 -46
  163. package/dist/src/cascade.js +0 -135
  164. package/dist/src/explore.d.ts +0 -99
  165. package/dist/src/explore.js +0 -402
  166. package/dist/src/generate-verified.d.ts +0 -105
  167. package/dist/src/generate-verified.js +0 -696
  168. package/dist/src/generate-verified.test.js +0 -925
  169. package/dist/src/generate.d.ts +0 -46
  170. package/dist/src/generate.js +0 -117
  171. package/dist/src/record.d.ts +0 -96
  172. package/dist/src/record.js +0 -657
  173. package/dist/src/record.test.js +0 -293
  174. package/dist/src/skill-generate.d.ts +0 -30
  175. package/dist/src/skill-generate.js +0 -75
  176. package/dist/src/skill-generate.test.js +0 -173
  177. package/dist/src/synthesize.d.ts +0 -97
  178. package/dist/src/synthesize.js +0 -208
  179. /package/dist/src/{generate-verified.test.d.ts → browser/compound.test.d.ts} +0 -0
  180. /package/dist/src/{record.test.d.ts → browser/extract.test.d.ts} +0 -0
  181. /package/dist/src/{skill-generate.test.d.ts → browser/find.test.d.ts} +0 -0
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildFindJs, FIND_ATTR_WHITELIST, isFindError } from './find.js';
3
+ /**
4
+ * These tests validate the shape and options of the generated JS string
5
+ * (no DOM available in the default vitest unit env). Runtime behavior of
6
+ * the generated JS against a real DOM is covered by the browser e2e suite.
7
+ */
8
+ describe('buildFindJs', () => {
9
+ it('produces syntactically valid JS that can be parsed', () => {
10
+ expect(() => new Function(`return (${buildFindJs('.btn')});`)).not.toThrow();
11
+ });
12
+ it('embeds the selector via JSON.stringify (injection-safe)', () => {
13
+ const js = buildFindJs('[data-x="a\"b"]');
14
+ // Unescaped literal break-out must not appear
15
+ expect(js).not.toContain('[data-x="a"b"]');
16
+ // The JSON-encoded form (with escaped quotes) should
17
+ expect(js).toContain(JSON.stringify('[data-x="a\"b"]'));
18
+ });
19
+ it('emits invalid_selector + selector_not_found branches', () => {
20
+ const js = buildFindJs('.btn');
21
+ expect(js).toContain("code: 'invalid_selector'");
22
+ expect(js).toContain("code: 'selector_not_found'");
23
+ });
24
+ it('emits matches_n + entries + per-entry shape', () => {
25
+ const js = buildFindJs('.btn');
26
+ expect(js).toContain('matches_n: matches.length');
27
+ expect(js).toContain('entries.push(');
28
+ // Per-entry keys reviewers signed off on: nth, ref, tag, role, text, attrs, visible
29
+ expect(js).toContain('nth: i');
30
+ expect(js).toContain('ref: refNum');
31
+ expect(js).toContain('tag: el.tagName.toLowerCase()');
32
+ expect(js).toContain("el.getAttribute('role')");
33
+ expect(js).toContain('visible: isVisible(el)');
34
+ });
35
+ it('allocates fresh refs for untagged matches (write attribute + identity map)', () => {
36
+ const js = buildFindJs('.btn');
37
+ // On the just-annotated branch we must flip the attribute on the element
38
+ // so downstream `browser click <ref>` works off the find output.
39
+ expect(js).toContain("el.setAttribute('data-opencli-ref'");
40
+ // The fingerprint must also land in the shared identity map so the
41
+ // target resolver's stale-ref check has data to verify against.
42
+ expect(js).toContain('__opencli_ref_identity');
43
+ expect(js).toContain("identity['' + refNum] = fingerprintOf(el)");
44
+ // Allocation walks both the identity map and any existing data-opencli-ref
45
+ // annotations — guards against collisions after a soft nav.
46
+ expect(js).toContain("document.querySelectorAll('[data-opencli-ref]')");
47
+ });
48
+ it('fingerprint shape matches the snapshot / resolver contract', () => {
49
+ const js = buildFindJs('.btn');
50
+ // The six fields resolveTargetJs verifies in its stale_ref check.
51
+ for (const field of ['tag:', 'role:', 'text:', 'ariaLabel:', 'id:', 'testId:']) {
52
+ expect(js).toContain(field);
53
+ }
54
+ });
55
+ it('embeds defaults for limit and textMax', () => {
56
+ const js = buildFindJs('.btn');
57
+ expect(js).toContain('LIMIT = 50');
58
+ expect(js).toContain('TEXT_MAX = 120');
59
+ });
60
+ it('overrides limit and textMax when requested', () => {
61
+ const js = buildFindJs('.btn', { limit: 3, textMax: 20 });
62
+ expect(js).toContain('LIMIT = 3');
63
+ expect(js).toContain('TEXT_MAX = 20');
64
+ });
65
+ it('embeds the attribute whitelist verbatim (no style/onclick leaking)', () => {
66
+ const js = buildFindJs('.btn');
67
+ // Whitelist fields appear inside the generated JS
68
+ for (const key of FIND_ATTR_WHITELIST) {
69
+ expect(js).toContain(`"${key}"`);
70
+ }
71
+ // Sensitive / high-noise attrs must stay out of the whitelist
72
+ expect(FIND_ATTR_WHITELIST).not.toContain('style');
73
+ expect(FIND_ATTR_WHITELIST).not.toContain('onclick');
74
+ expect(FIND_ATTR_WHITELIST).not.toContain('onload');
75
+ });
76
+ it('inlines compoundInfoOf and attaches compound field per entry', () => {
77
+ const js = buildFindJs('input, select');
78
+ // Helper definition is inlined so each matched element can be classified.
79
+ expect(js).toContain('function compoundInfoOf(el)');
80
+ // The emitted entry opts in only when compound data is present — no noisy
81
+ // compound: null on every non-form element.
82
+ expect(js).toContain('const compound = compoundInfoOf(el);');
83
+ expect(js).toContain('if (compound) entry.compound = compound;');
84
+ // Spot-check all three compound families are covered in the inlined helper.
85
+ expect(js).toContain("'YYYY-MM-DD'");
86
+ expect(js).toContain("control: 'file'");
87
+ expect(js).toContain("control: 'select'");
88
+ });
89
+ it('keeps the whitelist small and explicit (guardrail against silent expansion)', () => {
90
+ expect(FIND_ATTR_WHITELIST).toEqual([
91
+ 'id',
92
+ 'class',
93
+ 'name',
94
+ 'type',
95
+ 'placeholder',
96
+ 'aria-label',
97
+ 'title',
98
+ 'href',
99
+ 'value',
100
+ 'role',
101
+ 'data-testid',
102
+ ]);
103
+ });
104
+ });
105
+ describe('isFindError', () => {
106
+ it('narrows { error: ... } as FindError', () => {
107
+ const payload = { error: { code: 'invalid_selector', message: 'x' } };
108
+ expect(isFindError(payload)).toBe(true);
109
+ if (isFindError(payload)) {
110
+ const err = payload;
111
+ expect(err.error.code).toBe('invalid_selector');
112
+ }
113
+ });
114
+ it('rejects successful envelopes', () => {
115
+ expect(isFindError({ matches_n: 0, entries: [] })).toBe(false);
116
+ expect(isFindError(null)).toBe(false);
117
+ expect(isFindError(undefined)).toBe(false);
118
+ expect(isFindError('string')).toBe(false);
119
+ });
120
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Client-side HTML → structured tree serializer.
3
+ *
4
+ * Returned as a JS string that gets passed to `page.evaluate`. The expression
5
+ * walks the DOM subtree rooted at the first selector match (or documentElement
6
+ * when no selector is given) and emits a compact `{tag, attrs, text, children}`
7
+ * tree for agents to consume instead of re-parsing raw HTML.
8
+ *
9
+ * Text handling: `text` is the concatenated text of direct text children only,
10
+ * whitespace-collapsed. Nested element text is left inside `children[].text`.
11
+ * Ordering between text and elements is not preserved — agents that need it
12
+ * should fall back to raw HTML mode.
13
+ *
14
+ * Budget knobs let the caller bound the output on large pages — previously an
15
+ * unscoped `get html --as json` could return a giant tree. Callers set any
16
+ * combination of `depth` / `childrenMax` / `textMax`; each hit is reported in
17
+ * the `truncated` envelope so agents know to narrow their selector or raise
18
+ * the budget.
19
+ *
20
+ * Compound controls (date / time / datetime-local / month / week / select /
21
+ * file) gain a `compound` field so agents inspecting the JSON tree see the
22
+ * full contract — date format, full option list (up to cap) with selections
23
+ * preserved for options beyond the cap, file `accept` and `multiple`. Without
24
+ * this wiring agents repeatedly guess values on these controls from the raw
25
+ * attributes, which is the failure mode compound.ts was built to eliminate.
26
+ */
27
+ import { type CompoundInfo } from './compound.js';
28
+ export interface BuildHtmlTreeJsOptions {
29
+ /** CSS selector to scope the tree; unscoped = documentElement */
30
+ selector?: string | null;
31
+ /** Max depth below the root (0 = root only, no children). Omit = unlimited. */
32
+ depth?: number | null;
33
+ /** Max element children per node before the rest get dropped. Omit = unlimited. */
34
+ childrenMax?: number | null;
35
+ /** Max chars of direct text per node before truncation. Omit = unlimited. */
36
+ textMax?: number | null;
37
+ }
38
+ /**
39
+ * Returns a JS expression string. When evaluated in a page context the
40
+ * expression resolves to either
41
+ * `{selector, matched, tree, truncated}` on success, or
42
+ * `{selector, invalidSelector: true, reason}` when `querySelectorAll`
43
+ * throws a `SyntaxError` for an unparseable selector.
44
+ *
45
+ * Callers must branch on `invalidSelector` to convert it into the CLI's
46
+ * `invalid_selector` structured error; otherwise the browser-level exception
47
+ * would bubble out of `page.evaluate` and bypass the structured-error
48
+ * contract that agents rely on.
49
+ */
50
+ export declare function buildHtmlTreeJs(opts?: BuildHtmlTreeJsOptions): string;
51
+ export interface HtmlNode {
52
+ tag: string;
53
+ attrs: Record<string, string>;
54
+ text: string;
55
+ children: HtmlNode[];
56
+ /**
57
+ * Rich view for date/select/file controls. Omitted for non-compound elements
58
+ * so agents can rely on `compound != null` as a signal.
59
+ */
60
+ compound?: CompoundInfo;
61
+ }
62
+ export interface HtmlTreeTruncationInfo {
63
+ /** At least one element child was dropped because depth budget was hit. */
64
+ depth?: true;
65
+ /** Count of element children dropped across the tree due to `childrenMax`. */
66
+ children_dropped?: number;
67
+ /** Count of nodes whose `text` was cut to `textMax`. */
68
+ text_truncated?: number;
69
+ }
70
+ export interface HtmlTreeResult {
71
+ selector: string | null;
72
+ matched: number;
73
+ tree: HtmlNode | null;
74
+ truncated?: HtmlTreeTruncationInfo;
75
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Client-side HTML → structured tree serializer.
3
+ *
4
+ * Returned as a JS string that gets passed to `page.evaluate`. The expression
5
+ * walks the DOM subtree rooted at the first selector match (or documentElement
6
+ * when no selector is given) and emits a compact `{tag, attrs, text, children}`
7
+ * tree for agents to consume instead of re-parsing raw HTML.
8
+ *
9
+ * Text handling: `text` is the concatenated text of direct text children only,
10
+ * whitespace-collapsed. Nested element text is left inside `children[].text`.
11
+ * Ordering between text and elements is not preserved — agents that need it
12
+ * should fall back to raw HTML mode.
13
+ *
14
+ * Budget knobs let the caller bound the output on large pages — previously an
15
+ * unscoped `get html --as json` could return a giant tree. Callers set any
16
+ * combination of `depth` / `childrenMax` / `textMax`; each hit is reported in
17
+ * the `truncated` envelope so agents know to narrow their selector or raise
18
+ * the budget.
19
+ *
20
+ * Compound controls (date / time / datetime-local / month / week / select /
21
+ * file) gain a `compound` field so agents inspecting the JSON tree see the
22
+ * full contract — date format, full option list (up to cap) with selections
23
+ * preserved for options beyond the cap, file `accept` and `multiple`. Without
24
+ * this wiring agents repeatedly guess values on these controls from the raw
25
+ * attributes, which is the failure mode compound.ts was built to eliminate.
26
+ */
27
+ import { COMPOUND_INFO_JS } from './compound.js';
28
+ /**
29
+ * Returns a JS expression string. When evaluated in a page context the
30
+ * expression resolves to either
31
+ * `{selector, matched, tree, truncated}` on success, or
32
+ * `{selector, invalidSelector: true, reason}` when `querySelectorAll`
33
+ * throws a `SyntaxError` for an unparseable selector.
34
+ *
35
+ * Callers must branch on `invalidSelector` to convert it into the CLI's
36
+ * `invalid_selector` structured error; otherwise the browser-level exception
37
+ * would bubble out of `page.evaluate` and bypass the structured-error
38
+ * contract that agents rely on.
39
+ */
40
+ export function buildHtmlTreeJs(opts = {}) {
41
+ const selectorLiteral = opts.selector ? JSON.stringify(opts.selector) : 'null';
42
+ const depthLiteral = Number.isFinite(opts.depth) && opts.depth >= 0
43
+ ? String(opts.depth)
44
+ : 'null';
45
+ const childrenMaxLiteral = Number.isFinite(opts.childrenMax) && opts.childrenMax >= 0
46
+ ? String(opts.childrenMax)
47
+ : 'null';
48
+ const textMaxLiteral = Number.isFinite(opts.textMax) && opts.textMax >= 0
49
+ ? String(opts.textMax)
50
+ : 'null';
51
+ return `(() => {
52
+ ${COMPOUND_INFO_JS}
53
+ const selector = ${selectorLiteral};
54
+ const maxDepth = ${depthLiteral};
55
+ const maxChildren = ${childrenMaxLiteral};
56
+ const maxText = ${textMaxLiteral};
57
+ let matches;
58
+ if (selector) {
59
+ try { matches = document.querySelectorAll(selector); }
60
+ catch (e) {
61
+ return { selector: selector, invalidSelector: true, reason: (e && e.message) || String(e) };
62
+ }
63
+ } else {
64
+ matches = [document.documentElement];
65
+ }
66
+ const matched = matches.length;
67
+ const root = matches[0] || null;
68
+ const trunc = { depth: false, children_dropped: 0, text_truncated: 0 };
69
+ function serialize(el, depth) {
70
+ if (!el || el.nodeType !== 1) return null;
71
+ const attrs = {};
72
+ for (const a of el.attributes) attrs[a.name] = a.value;
73
+ let text = '';
74
+ for (const n of el.childNodes) {
75
+ if (n.nodeType === 3) text += n.nodeValue;
76
+ }
77
+ text = text.replace(/\\s+/g, ' ').trim();
78
+ if (maxText !== null && text.length > maxText) {
79
+ text = text.slice(0, maxText);
80
+ trunc.text_truncated++;
81
+ }
82
+ const children = [];
83
+ if (maxDepth === null || depth < maxDepth) {
84
+ const childEls = [];
85
+ for (const n of el.childNodes) if (n.nodeType === 1) childEls.push(n);
86
+ const keep = maxChildren === null ? childEls.length : Math.min(childEls.length, maxChildren);
87
+ for (let i = 0; i < keep; i++) {
88
+ const child = serialize(childEls[i], depth + 1);
89
+ if (child) children.push(child);
90
+ }
91
+ if (maxChildren !== null && childEls.length > maxChildren) {
92
+ trunc.children_dropped += childEls.length - maxChildren;
93
+ }
94
+ } else {
95
+ // Budget hit: we're at max depth. Count any element children we would have visited.
96
+ for (const n of el.childNodes) if (n.nodeType === 1) { trunc.depth = true; break; }
97
+ }
98
+ const node = { tag: el.tagName.toLowerCase(), attrs, text, children };
99
+ const compound = compoundInfoOf(el);
100
+ if (compound) node.compound = compound;
101
+ return node;
102
+ }
103
+ const tree = root ? serialize(root, 0) : null;
104
+ const truncatedOut = {};
105
+ if (trunc.depth) truncatedOut.depth = true;
106
+ if (trunc.children_dropped > 0) truncatedOut.children_dropped = trunc.children_dropped;
107
+ if (trunc.text_truncated > 0) truncatedOut.text_truncated = trunc.text_truncated;
108
+ const envelope = { selector: selector, matched: matched, tree: tree };
109
+ if (Object.keys(truncatedOut).length > 0) envelope.truncated = truncatedOut;
110
+ return envelope;
111
+ })()`;
112
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,181 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildHtmlTreeJs } from './html-tree.js';
3
+ /**
4
+ * The serializer runs in a page context via `page.evaluate`. In unit tests we
5
+ * substitute `document` with a minimal stub that mirrors the DOM surface used
6
+ * by the expression, then Function-eval the returned JS.
7
+ */
8
+ function runTreeJs(root, selectorMatches, selector, budgets = {}) {
9
+ const js = buildHtmlTreeJs({ selector, ...budgets });
10
+ const fakeDocument = {
11
+ querySelectorAll: () => selectorMatches,
12
+ documentElement: root,
13
+ };
14
+ const fn = new Function('document', `return ${js};`);
15
+ return fn(fakeDocument);
16
+ }
17
+ function runTreeJsInvalid(selector, errorMessage) {
18
+ const js = buildHtmlTreeJs({ selector });
19
+ const fakeDocument = {
20
+ querySelectorAll: () => { const e = new Error(errorMessage); e.name = 'SyntaxError'; throw e; },
21
+ documentElement: null,
22
+ };
23
+ const fn = new Function('document', `return ${js};`);
24
+ return fn(fakeDocument);
25
+ }
26
+ function el(tag, attrs, children, extras = {}) {
27
+ return {
28
+ nodeType: 1,
29
+ tagName: tag.toUpperCase(),
30
+ attributes: Object.entries(attrs).map(([name, value]) => ({ name, value })),
31
+ childNodes: children,
32
+ getAttribute: (name) => (name in attrs ? attrs[name] : null),
33
+ value: extras.value,
34
+ multiple: extras.multiple,
35
+ files: extras.files,
36
+ options: extras.options,
37
+ };
38
+ }
39
+ function txt(value) { return { nodeType: 3, nodeValue: value }; }
40
+ describe('buildHtmlTreeJs', () => {
41
+ it('serializes a simple element into {tag, attrs, text, children}', () => {
42
+ const root = el('div', { class: 'hero', id: 'x' }, [txt('Hello')]);
43
+ const result = runTreeJs(root, [root], null);
44
+ expect(result.selector).toBeNull();
45
+ expect(result.matched).toBe(1);
46
+ expect(result.tree).toEqual({
47
+ tag: 'div',
48
+ attrs: { class: 'hero', id: 'x' },
49
+ text: 'Hello',
50
+ children: [],
51
+ });
52
+ });
53
+ it('collapses whitespace in direct text content only', () => {
54
+ const root = el('p', {}, [
55
+ txt(' line \n one '),
56
+ el('span', {}, [txt('inner text')]),
57
+ txt('\tline two\t'),
58
+ ]);
59
+ const result = runTreeJs(root, [root], null);
60
+ expect(result.tree?.text).toBe('line one line two');
61
+ expect(result.tree?.children[0].text).toBe('inner text');
62
+ });
63
+ it('recurses into element children and preserves their attrs', () => {
64
+ const root = el('ul', { role: 'list' }, [
65
+ el('li', { 'data-id': '1' }, [txt('first')]),
66
+ el('li', { 'data-id': '2' }, [txt('second')]),
67
+ ]);
68
+ const result = runTreeJs(root, [root], null);
69
+ expect(result.tree?.children).toHaveLength(2);
70
+ expect(result.tree?.children[0]).toEqual({
71
+ tag: 'li',
72
+ attrs: { 'data-id': '1' },
73
+ text: 'first',
74
+ children: [],
75
+ });
76
+ });
77
+ it('returns matched=N and serializes only the first match', () => {
78
+ const first = el('article', { id: 'a' }, [txt('first')]);
79
+ const second = el('article', { id: 'b' }, [txt('second')]);
80
+ const result = runTreeJs(null, [first, second], 'article');
81
+ expect(result.matched).toBe(2);
82
+ expect(result.tree?.attrs.id).toBe('a');
83
+ });
84
+ it('returns tree=null and matched=0 when selector matches nothing', () => {
85
+ const result = runTreeJs(null, [], '.nothing');
86
+ expect(result.matched).toBe(0);
87
+ expect(result.tree).toBeNull();
88
+ });
89
+ it('catches SyntaxError from querySelectorAll and returns {invalidSelector:true, reason}', () => {
90
+ const result = runTreeJsInvalid('##$@@', "'##$@@' is not a valid selector");
91
+ expect(result.invalidSelector).toBe(true);
92
+ expect(result.selector).toBe('##$@@');
93
+ expect(result.reason).toContain('not a valid selector');
94
+ });
95
+ it('omits `truncated` when no budget is hit', () => {
96
+ const root = el('div', {}, [el('span', {}, [txt('ok')])]);
97
+ const result = runTreeJs(root, [root], null, { depth: 5, childrenMax: 10, textMax: 100 });
98
+ expect(result.truncated).toBeUndefined();
99
+ });
100
+ });
101
+ describe('buildHtmlTreeJs budget knobs', () => {
102
+ it('caps tree at `depth` and reports truncated.depth', () => {
103
+ const deep = el('a', {}, [
104
+ el('b', {}, [
105
+ el('c', {}, [el('d', {}, [txt('deep')])]),
106
+ ]),
107
+ ]);
108
+ // depth=1 → root + one level of children; grandchildren should be dropped.
109
+ const result = runTreeJs(deep, [deep], null, { depth: 1 });
110
+ expect(result.tree?.tag).toBe('a');
111
+ expect(result.tree?.children).toHaveLength(1);
112
+ expect(result.tree?.children[0].tag).toBe('b');
113
+ // The "b" node had element children but we hit the depth budget before
114
+ // recursing into them — children array is empty, truncated.depth is true.
115
+ expect(result.tree?.children[0].children).toEqual([]);
116
+ expect(result.truncated?.depth).toBe(true);
117
+ });
118
+ it('depth=0 keeps only the root', () => {
119
+ const root = el('ul', {}, [
120
+ el('li', {}, [txt('a')]),
121
+ el('li', {}, [txt('b')]),
122
+ ]);
123
+ const result = runTreeJs(root, [root], null, { depth: 0 });
124
+ expect(result.tree?.children).toEqual([]);
125
+ expect(result.truncated?.depth).toBe(true);
126
+ });
127
+ it('caps children per node at `childrenMax` and reports children_dropped count', () => {
128
+ const root = el('ul', {}, [
129
+ el('li', {}, [txt('1')]),
130
+ el('li', {}, [txt('2')]),
131
+ el('li', {}, [txt('3')]),
132
+ el('li', {}, [txt('4')]),
133
+ el('li', {}, [txt('5')]),
134
+ ]);
135
+ const result = runTreeJs(root, [root], null, { childrenMax: 2 });
136
+ expect(result.tree?.children).toHaveLength(2);
137
+ expect(result.truncated?.children_dropped).toBe(3);
138
+ });
139
+ it('caps direct text per node at `textMax` and reports text_truncated count', () => {
140
+ const root = el('p', {}, [
141
+ txt('a'.repeat(50)),
142
+ el('span', {}, [txt('b'.repeat(50))]),
143
+ ]);
144
+ const result = runTreeJs(root, [root], null, { textMax: 10 });
145
+ expect(result.tree?.text).toHaveLength(10);
146
+ expect(result.tree?.children[0].text).toHaveLength(10);
147
+ expect(result.truncated?.text_truncated).toBe(2);
148
+ });
149
+ // Blocker B regression: compound contract must ride along with the
150
+ // json tree so `browser get html --as json` surfaces the full contract
151
+ // to agents without an extra round-trip.
152
+ it('attaches compound info to date/file/select nodes and omits it elsewhere', () => {
153
+ const date = el('input', { type: 'date', min: '2026-01-01' }, [], { value: '2026-04-21' });
154
+ const file = el('input', { type: 'file', accept: 'image/*' }, [], { multiple: true, files: [{ name: 'a.png' }] });
155
+ const sel = el('select', { name: 'country' }, [], {
156
+ options: [
157
+ { value: 'us', label: 'United States', selected: true },
158
+ { value: 'ca', label: 'Canada' },
159
+ ],
160
+ });
161
+ const plain = el('input', { type: 'text' }, [], { value: 'hi' });
162
+ const root = el('form', {}, [date, file, sel, plain]);
163
+ const result = runTreeJs(root, [root], null);
164
+ expect(result.tree?.children[0].compound).toMatchObject({ control: 'date', format: 'YYYY-MM-DD', current: '2026-04-21', min: '2026-01-01' });
165
+ expect(result.tree?.children[1].compound).toMatchObject({ control: 'file', multiple: true, current: ['a.png'], accept: 'image/*' });
166
+ expect(result.tree?.children[2].compound).toMatchObject({ control: 'select', multiple: false, current: 'United States' });
167
+ expect(result.tree?.children[3].compound).toBeUndefined();
168
+ });
169
+ it('combines budgets and reports every hit', () => {
170
+ const root = el('ul', {}, [
171
+ el('li', {}, [txt('x'.repeat(20)), el('em', {}, [txt('y')])]),
172
+ el('li', {}, []),
173
+ el('li', {}, []),
174
+ ]);
175
+ const result = runTreeJs(root, [root], null, { depth: 1, childrenMax: 2, textMax: 5 });
176
+ expect(result.tree?.children).toHaveLength(2);
177
+ expect(result.truncated?.children_dropped).toBe(1);
178
+ expect(result.truncated?.text_truncated).toBe(1);
179
+ expect(result.truncated?.depth).toBe(true);
180
+ });
181
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Persistent cache for browser network captures.
3
+ *
4
+ * The live capture buffer (JS interceptor / daemon ring) can be cleared
5
+ * by navigation or lost between CLI invocations. Agents still need
6
+ * stable references to request bodies after running other commands,
7
+ * so every `browser network` call snapshots its results to disk.
8
+ *
9
+ * Layout: <cacheDir>/browser-network/<workspace>.json
10
+ * Entries expire after DEFAULT_TTL_MS (24h).
11
+ */
12
+ export declare const DEFAULT_TTL_MS: number;
13
+ export interface CachedNetworkEntry {
14
+ key: string;
15
+ url: string;
16
+ method: string;
17
+ status: number;
18
+ /** Full body size in chars (may exceed stored body length when truncated). */
19
+ size: number;
20
+ ct: string;
21
+ body: unknown;
22
+ /**
23
+ * Truncation signals use snake_case so `--raw` (which emits cache entries
24
+ * verbatim) matches the agent-facing contract used by list / --detail.
25
+ */
26
+ body_truncated?: boolean;
27
+ body_full_size?: number;
28
+ }
29
+ export interface NetworkCacheFile {
30
+ version: 1;
31
+ workspace: string;
32
+ savedAt: string;
33
+ entries: CachedNetworkEntry[];
34
+ }
35
+ export declare function getCachePath(workspace: string, baseDir?: string): string;
36
+ export declare function saveNetworkCache(workspace: string, entries: CachedNetworkEntry[], baseDir?: string): void;
37
+ export interface LoadOptions {
38
+ baseDir?: string;
39
+ ttlMs?: number;
40
+ now?: number;
41
+ }
42
+ export interface LoadResult {
43
+ status: 'ok' | 'missing' | 'expired' | 'corrupt';
44
+ file?: NetworkCacheFile;
45
+ ageMs?: number;
46
+ }
47
+ export declare function loadNetworkCache(workspace: string, opts?: LoadOptions): LoadResult;
48
+ export declare function findEntry(file: NetworkCacheFile, key: string): CachedNetworkEntry | null;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Persistent cache for browser network captures.
3
+ *
4
+ * The live capture buffer (JS interceptor / daemon ring) can be cleared
5
+ * by navigation or lost between CLI invocations. Agents still need
6
+ * stable references to request bodies after running other commands,
7
+ * so every `browser network` call snapshots its results to disk.
8
+ *
9
+ * Layout: <cacheDir>/browser-network/<workspace>.json
10
+ * Entries expire after DEFAULT_TTL_MS (24h).
11
+ */
12
+ import * as fs from 'node:fs';
13
+ import * as os from 'node:os';
14
+ import * as path from 'node:path';
15
+ export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
16
+ function getDefaultCacheDir() {
17
+ return process.env.OPENCLI_CACHE_DIR || path.join(os.homedir(), '.opencli', 'cache');
18
+ }
19
+ export function getCachePath(workspace, baseDir = getDefaultCacheDir()) {
20
+ const safe = workspace.replace(/[^a-zA-Z0-9_-]+/g, '_');
21
+ return path.join(baseDir, 'browser-network', `${safe}.json`);
22
+ }
23
+ export function saveNetworkCache(workspace, entries, baseDir) {
24
+ const target = getCachePath(workspace, baseDir);
25
+ fs.mkdirSync(path.dirname(target), { recursive: true });
26
+ const payload = {
27
+ version: 1,
28
+ workspace,
29
+ savedAt: new Date().toISOString(),
30
+ entries,
31
+ };
32
+ fs.writeFileSync(target, JSON.stringify(payload), 'utf-8');
33
+ }
34
+ export function loadNetworkCache(workspace, opts = {}) {
35
+ const target = getCachePath(workspace, opts.baseDir);
36
+ let raw;
37
+ try {
38
+ raw = fs.readFileSync(target, 'utf-8');
39
+ }
40
+ catch {
41
+ return { status: 'missing' };
42
+ }
43
+ let parsed;
44
+ try {
45
+ const obj = JSON.parse(raw);
46
+ if (!obj || obj.version !== 1 || !Array.isArray(obj.entries)) {
47
+ return { status: 'corrupt' };
48
+ }
49
+ parsed = obj;
50
+ }
51
+ catch {
52
+ return { status: 'corrupt' };
53
+ }
54
+ const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
55
+ const now = opts.now ?? Date.now();
56
+ const savedAt = Date.parse(parsed.savedAt);
57
+ if (!Number.isFinite(savedAt))
58
+ return { status: 'corrupt' };
59
+ const ageMs = now - savedAt;
60
+ if (ageMs > ttl)
61
+ return { status: 'expired', file: parsed, ageMs };
62
+ return { status: 'ok', file: parsed, ageMs };
63
+ }
64
+ export function findEntry(file, key) {
65
+ return file.entries.find((e) => e.key === key) ?? null;
66
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { DEFAULT_TTL_MS, findEntry, getCachePath, loadNetworkCache, saveNetworkCache, } from './network-cache.js';
6
+ function makeEntry(key, body = { ok: true }) {
7
+ return { key, url: `https://x.com/${key}`, method: 'GET', status: 200, size: 2, ct: 'application/json', body };
8
+ }
9
+ describe('network-cache', () => {
10
+ let baseDir;
11
+ beforeEach(() => {
12
+ baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-netcache-'));
13
+ });
14
+ afterEach(() => {
15
+ fs.rmSync(baseDir, { recursive: true, force: true });
16
+ });
17
+ it('sanitizes workspace names into safe filenames', () => {
18
+ const p = getCachePath('browser:default', baseDir);
19
+ expect(path.basename(p)).toBe('browser_default.json');
20
+ });
21
+ it('round-trips entries through save + load', () => {
22
+ saveNetworkCache('ws', [makeEntry('UserTweets'), makeEntry('UserByScreenName')], baseDir);
23
+ const res = loadNetworkCache('ws', { baseDir });
24
+ expect(res.status).toBe('ok');
25
+ expect(res.file?.entries).toHaveLength(2);
26
+ expect(res.file?.entries[0].key).toBe('UserTweets');
27
+ });
28
+ it('reports missing when cache file does not exist', () => {
29
+ expect(loadNetworkCache('nope', { baseDir }).status).toBe('missing');
30
+ });
31
+ it('reports expired when the cache is older than ttl', () => {
32
+ saveNetworkCache('ws', [makeEntry('A')], baseDir);
33
+ const future = Date.now() + DEFAULT_TTL_MS + 60_000;
34
+ const res = loadNetworkCache('ws', { baseDir, now: future });
35
+ expect(res.status).toBe('expired');
36
+ expect(res.file?.entries).toHaveLength(1);
37
+ });
38
+ it('reports corrupt for malformed json', () => {
39
+ const file = getCachePath('ws', baseDir);
40
+ fs.mkdirSync(path.dirname(file), { recursive: true });
41
+ fs.writeFileSync(file, '{not json');
42
+ expect(loadNetworkCache('ws', { baseDir }).status).toBe('corrupt');
43
+ });
44
+ it('reports corrupt for wrong schema version', () => {
45
+ const file = getCachePath('ws', baseDir);
46
+ fs.mkdirSync(path.dirname(file), { recursive: true });
47
+ fs.writeFileSync(file, JSON.stringify({ version: 0, entries: [] }));
48
+ expect(loadNetworkCache('ws', { baseDir }).status).toBe('corrupt');
49
+ });
50
+ it('findEntry returns matching entry or null', () => {
51
+ const file = {
52
+ version: 1, workspace: 'ws', savedAt: new Date().toISOString(),
53
+ entries: [makeEntry('A'), makeEntry('B')],
54
+ };
55
+ expect(findEntry(file, 'B')?.key).toBe('B');
56
+ expect(findEntry(file, 'missing')).toBeNull();
57
+ });
58
+ });