@jackwener/opencli 1.7.14 → 1.7.16

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 (153) hide show
  1. package/README.md +9 -6
  2. package/README.zh-CN.md +9 -6
  3. package/cli-manifest.json +374 -74
  4. package/clis/bilibili/subtitle.js +1 -1
  5. package/clis/chatgpt/ask.js +2 -1
  6. package/clis/chatgpt/detail.js +6 -1
  7. package/clis/chatgpt/read.js +2 -1
  8. package/clis/chatgpt/send.js +2 -1
  9. package/clis/chatgpt/utils.js +54 -12
  10. package/clis/chatgpt/utils.test.js +36 -1
  11. package/clis/claude/ask.js +22 -7
  12. package/clis/claude/detail.js +9 -2
  13. package/clis/claude/new.js +8 -2
  14. package/clis/claude/read.js +2 -1
  15. package/clis/claude/send.js +8 -3
  16. package/clis/claude/utils.js +27 -4
  17. package/clis/deepseek/ask.js +21 -8
  18. package/clis/deepseek/detail.js +9 -1
  19. package/clis/deepseek/new.js +13 -2
  20. package/clis/deepseek/read.js +2 -1
  21. package/clis/deepseek/utils.js +8 -1
  22. package/clis/dianping/cityResolver.js +185 -0
  23. package/clis/dianping/dianping.test.js +154 -0
  24. package/clis/dianping/search.js +6 -3
  25. package/clis/douyin/_shared/browser-fetch.js +14 -2
  26. package/clis/douyin/_shared/browser-fetch.test.js +13 -0
  27. package/clis/douyin/stats.js +1 -1
  28. package/clis/douyin/update.js +1 -1
  29. package/clis/jike/search.js +1 -1
  30. package/clis/linkedin/search.js +8 -11
  31. package/clis/maimai/search-talents.js +10 -6
  32. package/clis/openreview/author.js +58 -0
  33. package/clis/openreview/openreview.test.js +83 -1
  34. package/clis/openreview/utils.js +14 -0
  35. package/clis/reddit/comment.js +1 -0
  36. package/clis/reddit/frontpage.js +1 -0
  37. package/clis/reddit/popular.js +1 -0
  38. package/clis/reddit/read.js +2 -0
  39. package/clis/reddit/read.test.js +4 -0
  40. package/clis/reddit/save.js +1 -0
  41. package/clis/reddit/saved.js +1 -0
  42. package/clis/reddit/search.js +2 -1
  43. package/clis/reddit/subreddit.js +2 -1
  44. package/clis/reddit/subscribe.js +1 -0
  45. package/clis/reddit/upvote.js +1 -0
  46. package/clis/reddit/upvoted.js +1 -0
  47. package/clis/reddit/user-comments.js +2 -1
  48. package/clis/reddit/user-posts.js +2 -1
  49. package/clis/reddit/user.js +2 -1
  50. package/clis/twitter/article.js +9 -5
  51. package/clis/twitter/bookmark-folder.js +187 -0
  52. package/clis/twitter/bookmark-folder.test.js +337 -0
  53. package/clis/twitter/bookmark-folders.js +115 -0
  54. package/clis/twitter/bookmark-folders.test.js +152 -0
  55. package/clis/twitter/bookmark.js +15 -6
  56. package/clis/twitter/bookmark.test.js +74 -0
  57. package/clis/twitter/bookmarks.js +10 -10
  58. package/clis/twitter/delete.js +11 -35
  59. package/clis/twitter/delete.test.js +21 -9
  60. package/clis/twitter/download.js +6 -5
  61. package/clis/twitter/followers.js +10 -3
  62. package/clis/twitter/following.js +14 -11
  63. package/clis/twitter/following.test.js +2 -1
  64. package/clis/twitter/hide-reply.js +24 -5
  65. package/clis/twitter/hide-reply.test.js +76 -0
  66. package/clis/twitter/like.js +21 -11
  67. package/clis/twitter/like.test.js +73 -0
  68. package/clis/twitter/likes.js +11 -11
  69. package/clis/twitter/list-add.js +8 -7
  70. package/clis/twitter/list-add.test.js +23 -1
  71. package/clis/twitter/list-remove.js +8 -7
  72. package/clis/twitter/list-remove.test.js +23 -1
  73. package/clis/twitter/list-tweets.js +9 -9
  74. package/clis/twitter/lists.js +6 -8
  75. package/clis/twitter/notifications.js +3 -2
  76. package/clis/twitter/profile.js +11 -7
  77. package/clis/twitter/quote.js +60 -32
  78. package/clis/twitter/quote.test.js +96 -8
  79. package/clis/twitter/reply.js +24 -178
  80. package/clis/twitter/reply.test.js +29 -11
  81. package/clis/twitter/retweet.js +9 -14
  82. package/clis/twitter/retweet.test.js +5 -1
  83. package/clis/twitter/search.js +176 -23
  84. package/clis/twitter/search.test.js +266 -1
  85. package/clis/twitter/shared.js +43 -0
  86. package/clis/twitter/shared.test.js +107 -1
  87. package/clis/twitter/thread.js +11 -11
  88. package/clis/twitter/timeline.js +13 -13
  89. package/clis/twitter/trending.js +4 -4
  90. package/clis/twitter/tweets.js +8 -9
  91. package/clis/twitter/unbookmark.js +13 -6
  92. package/clis/twitter/unbookmark.test.js +73 -0
  93. package/clis/twitter/unlike.js +6 -13
  94. package/clis/twitter/unlike.test.js +5 -2
  95. package/clis/twitter/unretweet.js +9 -14
  96. package/clis/twitter/unretweet.test.js +5 -1
  97. package/clis/twitter/utils.js +286 -0
  98. package/clis/twitter/utils.test.js +169 -0
  99. package/clis/youtube/like.js +6 -2
  100. package/clis/youtube/subscribe.js +6 -2
  101. package/clis/youtube/unlike.js +6 -2
  102. package/clis/youtube/unsubscribe.js +6 -2
  103. package/clis/youtube/utils.js +19 -13
  104. package/clis/youtube/utils.test.js +17 -1
  105. package/dist/src/browser/ax-snapshot.d.ts +37 -0
  106. package/dist/src/browser/ax-snapshot.js +217 -0
  107. package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
  108. package/dist/src/browser/ax-snapshot.test.js +91 -0
  109. package/dist/src/browser/base-page.d.ts +51 -0
  110. package/dist/src/browser/base-page.js +545 -2
  111. package/dist/src/browser/base-page.test.js +520 -4
  112. package/dist/src/browser/bridge.d.ts +1 -0
  113. package/dist/src/browser/bridge.js +1 -1
  114. package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
  115. package/dist/src/browser/cdp-click-fixture.test.js +87 -0
  116. package/dist/src/browser/cdp.d.ts +1 -0
  117. package/dist/src/browser/cdp.js +5 -0
  118. package/dist/src/browser/cdp.test.js +1 -0
  119. package/dist/src/browser/daemon-client.d.ts +5 -3
  120. package/dist/src/browser/daemon-client.js +6 -3
  121. package/dist/src/browser/daemon-client.test.js +10 -0
  122. package/dist/src/browser/find.d.ts +9 -1
  123. package/dist/src/browser/find.js +219 -0
  124. package/dist/src/browser/find.test.js +61 -1
  125. package/dist/src/browser/page.d.ts +4 -2
  126. package/dist/src/browser/page.js +18 -1
  127. package/dist/src/browser/page.test.js +28 -0
  128. package/dist/src/browser/target-errors.d.ts +3 -1
  129. package/dist/src/browser/target-errors.js +2 -0
  130. package/dist/src/browser/target-resolver.d.ts +14 -0
  131. package/dist/src/browser/target-resolver.js +28 -0
  132. package/dist/src/browser/visual-refs.d.ts +11 -0
  133. package/dist/src/browser/visual-refs.js +108 -0
  134. package/dist/src/build-manifest.d.ts +23 -0
  135. package/dist/src/build-manifest.js +34 -0
  136. package/dist/src/build-manifest.test.js +108 -1
  137. package/dist/src/cli.js +630 -60
  138. package/dist/src/cli.test.js +731 -1
  139. package/dist/src/commanderAdapter.js +7 -0
  140. package/dist/src/doctor.js +2 -2
  141. package/dist/src/doctor.test.js +4 -4
  142. package/dist/src/execution.d.ts +2 -0
  143. package/dist/src/execution.js +31 -6
  144. package/dist/src/execution.test.js +43 -16
  145. package/dist/src/external-clis.yaml +24 -0
  146. package/dist/src/help.d.ts +33 -0
  147. package/dist/src/help.js +174 -0
  148. package/dist/src/main.js +4 -14
  149. package/dist/src/runtime.d.ts +3 -0
  150. package/dist/src/runtime.js +1 -0
  151. package/dist/src/types.d.ts +83 -1
  152. package/package.json +1 -1
  153. package/scripts/typed-error-lint-baseline.json +18 -18
@@ -68,9 +68,23 @@ export type TargetMatchLevel = 'exact' | 'stable' | 'reidentified';
68
68
  * The resolved element is stored in `window.__resolved` for downstream helpers.
69
69
  */
70
70
  export declare function resolveTargetJs(ref: string, opts?: ResolveOptions): string;
71
+ /**
72
+ * Generate JS that scrolls + measures `__resolved` without clicking.
73
+ *
74
+ * Generic click prefers CDP `Input.dispatchMouseEvent`, which fires the full
75
+ * pointer/mouse chain that Radix/MUI/shadcn dropdowns rely on. Keep measurement
76
+ * separate so the CDP-primary path does not call DOM `el.click()` first.
77
+ */
78
+ export declare function boundingRectResolvedJs(opts?: {
79
+ skipScroll?: boolean;
80
+ }): string;
71
81
  /**
72
82
  * Generate JS for click that uses the unified resolver.
73
83
  * Assumes resolveTargetJs has been called and __resolved is set.
84
+ *
85
+ * This is the JS fallback path. BasePage.click uses boundingRectResolvedJs for
86
+ * the CDP-primary path and only reaches this when native click is unavailable
87
+ * or the target has no usable rect.
74
88
  */
75
89
  export declare function clickResolvedJs(opts?: {
76
90
  skipScroll?: boolean;
@@ -273,9 +273,37 @@ export function resolveTargetJs(ref, opts = {}) {
273
273
  })()
274
274
  `;
275
275
  }
276
+ /**
277
+ * Generate JS that scrolls + measures `__resolved` without clicking.
278
+ *
279
+ * Generic click prefers CDP `Input.dispatchMouseEvent`, which fires the full
280
+ * pointer/mouse chain that Radix/MUI/shadcn dropdowns rely on. Keep measurement
281
+ * separate so the CDP-primary path does not call DOM `el.click()` first.
282
+ */
283
+ export function boundingRectResolvedJs(opts = {}) {
284
+ const shouldScroll = opts.skipScroll ? 'false' : 'true';
285
+ return `
286
+ (() => {
287
+ const el = window.__resolved;
288
+ if (!el) throw new Error('No resolved element');
289
+ if (${shouldScroll}) el.scrollIntoView({ behavior: 'instant', block: 'center' });
290
+ const rect = el.getBoundingClientRect();
291
+ const w = Math.round(rect.width);
292
+ const h = Math.round(rect.height);
293
+ const x = Math.round(rect.left + rect.width / 2);
294
+ const y = Math.round(rect.top + rect.height / 2);
295
+ const visible = w > 0 && h > 0;
296
+ return { x, y, w, h, visible };
297
+ })()
298
+ `;
299
+ }
276
300
  /**
277
301
  * Generate JS for click that uses the unified resolver.
278
302
  * Assumes resolveTargetJs has been called and __resolved is set.
303
+ *
304
+ * This is the JS fallback path. BasePage.click uses boundingRectResolvedJs for
305
+ * the CDP-primary path and only reaches this when native click is unavailable
306
+ * or the target has no usable rect.
279
307
  */
280
308
  export function clickResolvedJs(opts = {}) {
281
309
  const shouldScroll = opts.skipScroll ? 'false' : 'true';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Visual ref overlay for annotated screenshots.
3
+ *
4
+ * The overlay is intentionally DOM-side and temporary. It reuses the same
5
+ * `data-opencli-ref` attributes produced by the DOM snapshot path so the
6
+ * screenshot labels map back to normal `browser click <ref>` targets.
7
+ */
8
+ export declare function installVisualRefOverlayJs(opts?: {
9
+ maxRefs?: number;
10
+ }): string;
11
+ export declare function removeVisualRefOverlayJs(): string;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Visual ref overlay for annotated screenshots.
3
+ *
4
+ * The overlay is intentionally DOM-side and temporary. It reuses the same
5
+ * `data-opencli-ref` attributes produced by the DOM snapshot path so the
6
+ * screenshot labels map back to normal `browser click <ref>` targets.
7
+ */
8
+ const OVERLAY_ID = '__opencli_visual_ref_overlay';
9
+ export function installVisualRefOverlayJs(opts = {}) {
10
+ const maxRefs = Math.max(1, Math.min(opts.maxRefs ?? 120, 500));
11
+ return `
12
+ (() => {
13
+ const OVERLAY_ID = ${JSON.stringify(OVERLAY_ID)};
14
+ const MAX_REFS = ${maxRefs};
15
+ document.getElementById(OVERLAY_ID)?.remove();
16
+
17
+ const overlay = document.createElement('div');
18
+ overlay.id = OVERLAY_ID;
19
+ overlay.setAttribute('aria-hidden', 'true');
20
+ Object.assign(overlay.style, {
21
+ position: 'fixed',
22
+ inset: '0',
23
+ zIndex: '2147483647',
24
+ pointerEvents: 'none',
25
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
26
+ });
27
+
28
+ const refs = Array.from(document.querySelectorAll('[data-opencli-ref]'))
29
+ .map((el) => {
30
+ const rawRef = el.getAttribute('data-opencli-ref') || '';
31
+ const ref = Number(rawRef);
32
+ const rect = el.getBoundingClientRect();
33
+ const visible = Number.isFinite(ref)
34
+ && rect.width >= 2
35
+ && rect.height >= 2
36
+ && rect.bottom >= 0
37
+ && rect.right >= 0
38
+ && rect.top <= window.innerHeight
39
+ && rect.left <= window.innerWidth;
40
+ return { el, rawRef, ref, rect, visible };
41
+ })
42
+ .filter((entry) => entry.visible)
43
+ .sort((a, b) => a.ref - b.ref)
44
+ .slice(0, MAX_REFS);
45
+
46
+ for (const entry of refs) {
47
+ const left = Math.max(0, Math.min(window.innerWidth - 1, entry.rect.left));
48
+ const top = Math.max(0, Math.min(window.innerHeight - 1, entry.rect.top));
49
+ const right = Math.max(0, Math.min(window.innerWidth, entry.rect.right));
50
+ const bottom = Math.max(0, Math.min(window.innerHeight, entry.rect.bottom));
51
+ const width = Math.max(2, right - left);
52
+ const height = Math.max(2, bottom - top);
53
+
54
+ const box = document.createElement('div');
55
+ Object.assign(box.style, {
56
+ position: 'fixed',
57
+ left: left + 'px',
58
+ top: top + 'px',
59
+ width: width + 'px',
60
+ height: height + 'px',
61
+ border: '2px solid #ff3b30',
62
+ borderRadius: '4px',
63
+ boxSizing: 'border-box',
64
+ boxShadow: '0 0 0 1px rgba(255,255,255,.9), 0 4px 16px rgba(0,0,0,.25)',
65
+ background: 'rgba(255,59,48,.08)',
66
+ });
67
+
68
+ const badge = document.createElement('div');
69
+ badge.textContent = entry.rawRef;
70
+ Object.assign(badge.style, {
71
+ position: 'fixed',
72
+ left: left + 'px',
73
+ top: Math.max(0, top - 20) + 'px',
74
+ minWidth: '18px',
75
+ height: '18px',
76
+ padding: '0 5px',
77
+ borderRadius: '999px',
78
+ border: '1px solid rgba(255,255,255,.95)',
79
+ background: '#ff3b30',
80
+ color: '#fff',
81
+ fontSize: '12px',
82
+ fontWeight: '700',
83
+ lineHeight: '18px',
84
+ textAlign: 'center',
85
+ textShadow: '0 1px 1px rgba(0,0,0,.25)',
86
+ boxShadow: '0 2px 8px rgba(0,0,0,.35)',
87
+ });
88
+
89
+ overlay.appendChild(box);
90
+ overlay.appendChild(badge);
91
+ }
92
+
93
+ document.documentElement.appendChild(overlay);
94
+ return {
95
+ annotated: refs.length,
96
+ truncated: refs.length >= MAX_REFS,
97
+ };
98
+ })()
99
+ `.trim();
100
+ }
101
+ export function removeVisualRefOverlayJs() {
102
+ return `
103
+ (() => {
104
+ document.getElementById(${JSON.stringify(OVERLAY_ID)})?.remove();
105
+ return true;
106
+ })()
107
+ `.trim();
108
+ }
@@ -66,6 +66,29 @@ export declare function loadManifestEntries(filePath: string, site: string, impo
66
66
  export declare function scanClisDir(clisDir: string, importer?: (moduleHref: string) => Promise<unknown>): Promise<BuildManifestResult>;
67
67
  export declare function buildManifest(): Promise<BuildManifestResult>;
68
68
  export declare function serializeManifest(manifest: ManifestEntry[]): string;
69
+ /**
70
+ * Metadata audit: every positional arg must carry a non-empty `help` string.
71
+ *
72
+ * Why this is a hard gate (not advisory):
73
+ * - `opencli twitter followers --help` rendered `Arguments:\n user ` with
74
+ * an empty trailing column. Agents and humans both saw a blank field —
75
+ * impossible to recover the parameter's purpose without reading source.
76
+ * - This is metadata completeness, not stylistic taste; failing closed is
77
+ * the only way to keep the help surface trustworthy as adapters land.
78
+ *
79
+ * Note: semantic quality (e.g. "what does the optional positional mean when
80
+ * omitted?") is intentionally NOT enforced here. That belongs to a follow-up
81
+ * advisory audit — see PR plan `Arg metadata v2` for the structured
82
+ * `when_omitted / when_present / value_format` schema.
83
+ */
84
+ export interface ManifestMetadataIssue {
85
+ site: string;
86
+ command: string;
87
+ arg: string;
88
+ sourceFile?: string;
89
+ reason: string;
90
+ }
91
+ export declare function findManifestMetadataIssues(entries: readonly ManifestEntry[]): ManifestMetadataIssue[];
69
92
  /**
70
93
  * Diff helper: returns site/name keys that exist in `prev` but not in
71
94
  * `next`. Used as a safety net to detect accidental mass-deletions caused
@@ -209,6 +209,28 @@ export async function buildManifest() {
209
209
  export function serializeManifest(manifest) {
210
210
  return `${JSON.stringify(manifest, null, 2)}\n`;
211
211
  }
212
+ export function findManifestMetadataIssues(entries) {
213
+ const issues = [];
214
+ for (const entry of entries) {
215
+ if (!Array.isArray(entry.args))
216
+ continue;
217
+ for (const arg of entry.args) {
218
+ if (!arg.positional)
219
+ continue;
220
+ const help = typeof arg.help === 'string' ? arg.help.trim() : '';
221
+ if (help === '') {
222
+ issues.push({
223
+ site: entry.site,
224
+ command: entry.name,
225
+ arg: arg.name,
226
+ sourceFile: entry.sourceFile,
227
+ reason: 'positional arg missing non-empty `help` text',
228
+ });
229
+ }
230
+ }
231
+ }
232
+ return issues;
233
+ }
212
234
  /**
213
235
  * Diff helper: returns site/name keys that exist in `prev` but not in
214
236
  * `next`. Used as a safety net to detect accidental mass-deletions caused
@@ -278,6 +300,18 @@ async function main() {
278
300
  + `Always run via tsx (\`npm run build-manifest\`), not against compiled dist/.\n`);
279
301
  process.exit(1);
280
302
  }
303
+ const metadataIssues = findManifestMetadataIssues(entries);
304
+ if (metadataIssues.length > 0) {
305
+ process.stderr.write(`❌ ${metadataIssues.length} positional arg(s) missing \`help\` text:\n`);
306
+ for (const issue of metadataIssues) {
307
+ const where = issue.sourceFile ? ` (${issue.sourceFile})` : '';
308
+ process.stderr.write(` - ${issue.site}/${issue.command} positional "${issue.arg}"${where}\n`);
309
+ }
310
+ process.stderr.write(`\nEvery positional arg must declare a non-empty \`help\` string so\n`
311
+ + `\`opencli <site> <cmd> --help\` shows callers what the parameter is for.\n`
312
+ + `Add \`help: '...'\` to each arg above and re-run the build.\n`);
313
+ process.exit(1);
314
+ }
281
315
  const existing = readExistingManifest(OUTPUT);
282
316
  if (existing) {
283
317
  const removed = diffRemovedEntries(existing, entries);
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import { cli, getRegistry, Strategy } from './registry.js';
6
- import { ManifestImportError, diffRemovedEntries, loadManifestEntries, normalizeManifestPath, parseBuildManifestArgs, scanClisDir, serializeManifest, } from './build-manifest.js';
6
+ import { ManifestImportError, diffRemovedEntries, findManifestMetadataIssues, loadManifestEntries, normalizeManifestPath, parseBuildManifestArgs, scanClisDir, serializeManifest, } from './build-manifest.js';
7
7
  describe('manifest helper rules', () => {
8
8
  const tempDirs = [];
9
9
  afterEach(() => {
@@ -222,6 +222,113 @@ describe('manifest helper rules', () => {
222
222
  expect(diffRemovedEntries(prev, prev)).toEqual([]);
223
223
  expect(diffRemovedEntries([], next)).toEqual([]);
224
224
  });
225
+ it('findManifestMetadataIssues flags positionals with empty/missing help', () => {
226
+ // The build-time hard gate. A positional with `help: ''` or no `help` at
227
+ // all renders `Arguments:\n <name>` with a blank trailing column —
228
+ // unrecoverable for both humans and agents reading help. Failing closed
229
+ // here is the only way to keep help text trustworthy as adapters land.
230
+ //
231
+ // Semantic quality (e.g. what does an *optional* positional mean when
232
+ // omitted?) is intentionally NOT enforced — that belongs to the planned
233
+ // Arg metadata v2 advisory pass.
234
+ const entries = [
235
+ // Positional with usable help — clean.
236
+ {
237
+ site: 'demo',
238
+ name: 'ok',
239
+ access: 'read',
240
+ description: '',
241
+ strategy: 'public',
242
+ browser: false,
243
+ args: [
244
+ { name: 'q', positional: true, required: true, help: 'Search query' },
245
+ ],
246
+ type: 'js',
247
+ sourceFile: 'demo/ok.js',
248
+ },
249
+ // Positional with empty help string — must flag.
250
+ {
251
+ site: 'demo',
252
+ name: 'empty-help',
253
+ access: 'read',
254
+ description: '',
255
+ strategy: 'public',
256
+ browser: false,
257
+ args: [
258
+ { name: 'user', positional: true, required: false, help: '' },
259
+ ],
260
+ type: 'js',
261
+ sourceFile: 'demo/empty.js',
262
+ },
263
+ // Positional with whitespace-only help — must flag.
264
+ {
265
+ site: 'demo',
266
+ name: 'whitespace-help',
267
+ access: 'read',
268
+ description: '',
269
+ strategy: 'public',
270
+ browser: false,
271
+ args: [
272
+ { name: 'id', positional: true, required: true, help: ' ' },
273
+ ],
274
+ type: 'js',
275
+ },
276
+ // Positional with no help field at all — must flag.
277
+ {
278
+ site: 'demo',
279
+ name: 'missing-help',
280
+ access: 'read',
281
+ description: '',
282
+ strategy: 'public',
283
+ browser: false,
284
+ args: [
285
+ { name: 'name', positional: true, required: true },
286
+ ],
287
+ type: 'js',
288
+ },
289
+ // NON-positional flag with empty help — must NOT flag (gate is
290
+ // intentionally scoped to positionals; named flags carry the flag
291
+ // name itself in the help line).
292
+ {
293
+ site: 'demo',
294
+ name: 'flag-only',
295
+ access: 'read',
296
+ description: '',
297
+ strategy: 'public',
298
+ browser: false,
299
+ args: [
300
+ { name: 'limit', required: false, help: '' },
301
+ ],
302
+ type: 'js',
303
+ },
304
+ ];
305
+ const issues = findManifestMetadataIssues(entries);
306
+ expect(issues).toHaveLength(3);
307
+ expect(issues.map(i => `${i.site}/${i.command}/${i.arg}`).sort()).toEqual([
308
+ 'demo/empty-help/user',
309
+ 'demo/missing-help/name',
310
+ 'demo/whitespace-help/id',
311
+ ]);
312
+ // sourceFile flows through when present so the build error points at the
313
+ // exact file to fix.
314
+ const emptyHelp = issues.find(i => i.command === 'empty-help');
315
+ expect(emptyHelp?.sourceFile).toBe('demo/empty.js');
316
+ });
317
+ it('findManifestMetadataIssues returns [] for fully-documented entries', () => {
318
+ expect(findManifestMetadataIssues([])).toEqual([]);
319
+ expect(findManifestMetadataIssues([
320
+ {
321
+ site: 'demo',
322
+ name: 'no-args',
323
+ access: 'read',
324
+ description: '',
325
+ strategy: 'public',
326
+ browser: false,
327
+ args: [],
328
+ type: 'js',
329
+ },
330
+ ])).toEqual([]);
331
+ });
225
332
  it('parseBuildManifestArgs reads --allow-removals[=N]', () => {
226
333
  expect(parseBuildManifestArgs([]).allowRemovals).toBe(0);
227
334
  expect(parseBuildManifestArgs(['--allow-removals=5']).allowRemovals).toBe(5);