@jackwener/opencli 1.7.13 → 1.7.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli-manifest.json +326 -44
- package/clis/bilibili/subtitle.js +1 -1
- package/clis/dianping/cityResolver.js +185 -0
- package/clis/dianping/dianping.test.js +154 -0
- package/clis/dianping/search.js +6 -3
- package/clis/douyin/_shared/browser-fetch.js +14 -2
- package/clis/douyin/_shared/browser-fetch.test.js +13 -0
- package/clis/douyin/stats.js +1 -1
- package/clis/douyin/update.js +1 -1
- package/clis/jike/search.js +1 -1
- package/clis/reddit/search.js +1 -1
- package/clis/reddit/subreddit.js +1 -1
- package/clis/reddit/user-comments.js +1 -1
- package/clis/reddit/user-posts.js +1 -1
- package/clis/reddit/user.js +1 -1
- package/clis/twitter/article.js +2 -1
- package/clis/twitter/bookmark-folder.js +189 -0
- package/clis/twitter/bookmark-folder.test.js +334 -0
- package/clis/twitter/bookmark-folders.js +117 -0
- package/clis/twitter/bookmark-folders.test.js +150 -0
- package/clis/twitter/bookmark.js +15 -6
- package/clis/twitter/bookmark.test.js +74 -0
- package/clis/twitter/bookmarks.js +7 -5
- package/clis/twitter/delete.js +11 -35
- package/clis/twitter/delete.test.js +21 -9
- package/clis/twitter/download.js +5 -5
- package/clis/twitter/followers.js +9 -3
- package/clis/twitter/following.js +11 -5
- package/clis/twitter/hide-reply.js +24 -5
- package/clis/twitter/hide-reply.test.js +76 -0
- package/clis/twitter/like.js +21 -11
- package/clis/twitter/like.test.js +73 -0
- package/clis/twitter/likes.js +8 -6
- package/clis/twitter/list-add.js +4 -4
- package/clis/twitter/list-remove.js +4 -4
- package/clis/twitter/list-tweets.js +6 -4
- package/clis/twitter/lists.js +3 -3
- package/clis/twitter/notifications.js +2 -2
- package/clis/twitter/profile.js +4 -3
- package/clis/twitter/quote.js +167 -0
- package/clis/twitter/quote.test.js +194 -0
- package/clis/twitter/reply.js +24 -178
- package/clis/twitter/reply.test.js +29 -11
- package/clis/twitter/retweet.js +94 -0
- package/clis/twitter/retweet.test.js +73 -0
- package/clis/twitter/search.js +175 -23
- package/clis/twitter/search.test.js +266 -1
- package/clis/twitter/shared.js +81 -0
- package/clis/twitter/shared.test.js +134 -1
- package/clis/twitter/thread.js +6 -4
- package/clis/twitter/timeline.js +8 -6
- package/clis/twitter/tweets.js +5 -3
- package/clis/twitter/unbookmark.js +13 -6
- package/clis/twitter/unbookmark.test.js +73 -0
- package/clis/twitter/unlike.js +80 -0
- package/clis/twitter/unlike.test.js +75 -0
- package/clis/twitter/unretweet.js +94 -0
- package/clis/twitter/unretweet.test.js +73 -0
- package/clis/twitter/utils.js +286 -0
- package/clis/twitter/utils.test.js +169 -0
- package/dist/src/browser/ax-snapshot.d.ts +37 -0
- package/dist/src/browser/ax-snapshot.js +217 -0
- package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
- package/dist/src/browser/ax-snapshot.test.js +91 -0
- package/dist/src/browser/base-page.d.ts +51 -0
- package/dist/src/browser/base-page.js +545 -2
- package/dist/src/browser/base-page.test.js +520 -4
- package/dist/src/browser/bridge.js +47 -45
- package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
- package/dist/src/browser/cdp-click-fixture.test.js +87 -0
- package/dist/src/browser/cdp.js +5 -0
- package/dist/src/browser/cdp.test.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +3 -1
- package/dist/src/browser/find.d.ts +9 -1
- package/dist/src/browser/find.js +219 -0
- package/dist/src/browser/find.test.js +61 -1
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +13 -0
- package/dist/src/browser/page.test.js +28 -0
- package/dist/src/browser/target-errors.d.ts +3 -1
- package/dist/src/browser/target-errors.js +2 -0
- package/dist/src/browser/target-resolver.d.ts +14 -0
- package/dist/src/browser/target-resolver.js +28 -0
- package/dist/src/browser/visual-refs.d.ts +11 -0
- package/dist/src/browser/visual-refs.js +108 -0
- package/dist/src/browser.test.js +18 -0
- package/dist/src/build-manifest.d.ts +23 -0
- package/dist/src/build-manifest.js +34 -0
- package/dist/src/build-manifest.test.js +108 -1
- package/dist/src/cli.js +560 -58
- package/dist/src/cli.test.js +689 -1
- package/dist/src/commanderAdapter.js +23 -4
- package/dist/src/help.d.ts +36 -0
- package/dist/src/help.js +301 -5
- package/dist/src/types.d.ts +82 -0
- package/package.json +1 -1
- package/scripts/typed-error-lint-baseline.json +18 -18
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* - selector_ambiguous: >1 matches for a write op without --nth
|
|
17
17
|
* - selector_nth_out_of_range: --nth beyond matches_n
|
|
18
18
|
* - not_editable: target exists but cannot accept text input
|
|
19
|
+
* - not_checkable: target exists but cannot be checked/unchecked
|
|
20
|
+
* - not_file_input: target exists but is not a usable file input
|
|
19
21
|
*/
|
|
20
22
|
export class TargetError extends Error {
|
|
21
23
|
code;
|
|
@@ -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
|
+
}
|
package/dist/src/browser.test.js
CHANGED
|
@@ -165,6 +165,24 @@ describe('BrowserBridge state', () => {
|
|
|
165
165
|
const bridge = new BrowserBridge();
|
|
166
166
|
await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
|
|
167
167
|
});
|
|
168
|
+
it('attempts stale daemon replacement even when extension is connected', async () => {
|
|
169
|
+
vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
|
|
170
|
+
state: 'ready',
|
|
171
|
+
status: {
|
|
172
|
+
ok: true,
|
|
173
|
+
pid: 1,
|
|
174
|
+
uptime: 0,
|
|
175
|
+
daemonVersion: '0.0.1',
|
|
176
|
+
extensionConnected: true,
|
|
177
|
+
pending: 0,
|
|
178
|
+
memoryMB: 0,
|
|
179
|
+
port: 0,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false);
|
|
183
|
+
const bridge = new BrowserBridge();
|
|
184
|
+
await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
|
|
185
|
+
});
|
|
168
186
|
});
|
|
169
187
|
describe('stealth anti-detection', () => {
|
|
170
188
|
it('generates non-empty JS string', () => {
|
|
@@ -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);
|