feat(browser): polish adapter author verify workflow

This commit is contained in:
jakevin
2026-05-03 19:03:38 +08:00
committed by GitHub
parent 2e93ac6e63
commit 7869bdb2ca
13 changed files with 330 additions and 24 deletions
+3 -3
View File
@@ -97,7 +97,7 @@ If you want to add your own commands, start with the [Extending OpenCLI guide](.
|------|------------------|
| Keep personal website commands in your own Git repo | `opencli plugin create` + `opencli plugin install file://...` |
| Quickly draft a private local adapter | `opencli browser init <site>/<command>` in `~/.opencli/clis/` |
| Modify an official adapter locally | `opencli adapter eject/status/reset` |
| Modify an official adapter locally | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
| Publish or install third-party commands | `opencli plugin install github:user/repo` |
| Wrap an existing local binary | `opencli external register <name>` |
@@ -174,7 +174,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil
2. Discover the right endpoint — network inspection, initial state, bundle search, token trace, or interceptor fallback.
3. Decide the auth strategy — `PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`.
4. Decode response fields and design output columns.
5. `opencli browser init <site>/<name>` → write adapter → `opencli browser verify <site>/<name>`.
5. `opencli browser analyze <url>` for one-shot recon, then `opencli browser init <site>/<name>` → write adapter → `opencli browser verify <site>/<name>`.
6. Persist site knowledge to `~/.opencli/sites/<site>/` so the next adapter for the same site is faster.
### CLI Hub and desktop adapters
@@ -407,7 +407,7 @@ Before writing any adapter code, read the [`opencli-adapter-author` skill](./ski
- Recon the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming).
- Discover the right endpoint via `opencli browser network`, `eval`, or the interceptor fallback.
- Decide auth strategy (`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`).
- Decode response fields, design columns, scaffold with `opencli browser init`.
- Run `opencli browser analyze <url>` for one-shot recon, decode response fields, design columns, scaffold with `opencli browser init`.
- Verify with `opencli browser verify <site>/<name>` before shipping.
For long-lived personal commands that should live in your own Git repo, use a local plugin instead; see [Extending OpenCLI](./docs/guide/extending-opencli.md). Quick private adapters can still live at `~/.opencli/clis/<site>/<name>.js`. Site knowledge (endpoints, field maps, fixtures) accumulates in `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context instead of zero.
+3 -3
View File
@@ -81,7 +81,7 @@ opencli bilibili hot --limit 5
|------|----------|
| 把个人网站命令放在自己的 Git repo | `opencli plugin create` + `opencli plugin install file://...` |
| 快速写一个本机私人 adapter | `opencli browser init <site>/<command>`,放在 `~/.opencli/clis/` |
| 本地修改官方 adapter | `opencli adapter eject/status/reset` |
| 本地修改官方 adapter | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
| 发布或安装第三方命令 | `opencli plugin install github:user/repo` |
| 包装已有本机 binary | `opencli external register <name>` |
@@ -158,7 +158,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
3. 定认证策略——`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`
4. 字段解码 + 设计输出列
5. `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
5. `opencli browser analyze <url>` 一步侦察,再 `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
6. 把站点知识沉到 `~/.opencli/sites/<site>/`,下次写同站点的其他命令直接吃缓存
### CLI 枢纽与桌面端适配器
@@ -505,7 +505,7 @@ opencli plugin uninstall my-tool # 卸载
- 侦察站点,选定 patternSPA / SSR / JSONP / Token / Streaming
-`opencli browser network``eval`、interceptor 等找到目标 endpoint
- 定认证策略(`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`
- 字段解码、设计 columns、`opencli browser init` 生成骨架
- 先用 `opencli browser analyze <url>` 一步侦察,再字段解码、设计 columns、`opencli browser init` 生成骨架
- 交付前用 `opencli browser verify <site>/<name>` 验证
在仓库外写的私有适配器放到 `~/.opencli/clis/<site>/<name>.js`;每个站点的 endpoint、字段映射、抓包样本会累积在 `~/.opencli/sites/<site>/`,下次写同站点的其他命令可以直接复用。
+6 -4
View File
@@ -11,10 +11,11 @@ From a new site URL to a passing `opencli browser verify` — one skill, one set
# skills/opencli-adapter-author/SKILL.md
# 2. Reconnaissance
opencli browser open https://example.com
opencli browser wait time 3
opencli browser network # inspect XHR / fetch calls
opencli browser state # extract __INITIAL_STATE__ / __NEXT_DATA__
opencli browser analyze https://example.com
# Fallback primitives when analyze says deeper inspection is needed:
# opencli browser open https://example.com
# opencli browser network # inspect XHR / fetch calls
# opencli browser state # extract __INITIAL_STATE__ / __NEXT_DATA__
# 3. Scaffold + verify
opencli browser init <site>/<name>
@@ -30,6 +31,7 @@ See [skills/opencli-adapter-author/SKILL.md](https://github.com/jackwener/opencl
| Command | Purpose |
|---------|---------|
| `opencli doctor` | Sanity check: bridge, Chrome, signals |
| `opencli browser analyze <url>` | One-shot site recon: anti-bot, pattern, nearest adapter, next step |
| `opencli browser open <url>` | Open a tab in the Chrome session |
| `opencli browser network` | List recent XHR / fetch calls |
| `opencli browser state` | Page state: URL, title, interactive elements |
+14 -2
View File
@@ -45,18 +45,30 @@ User adapters are loaded from:
This path is convenient for quick local automation. For code you want to version, review, or share, prefer a plugin.
If the command takes required positional args and no fixture exists yet, seed the first verify run explicitly:
```bash
opencli browser verify instagram/collection-create --write-fixture --seed-args opencli-verify
opencli browser verify example/detail --write-fixture --seed-args '["https://example.com/item/1", "--limit", 3]'
```
`--seed-args` is only used when the fixture has no `args`. Once the fixture is written, `opencli browser verify` reads args from `~/.opencli/sites/<site>/verify/<command>.json`.
## Local overrides for official adapters
Use `adapter eject` when you want to customize an existing official adapter.
```bash
opencli adapter eject twitter
opencli adapter status
# edit ~/.opencli/clis/twitter/*.js
opencli adapter reset twitter
```
Ejected adapters override the packaged official adapter on this machine. `adapter reset` removes the local override and returns to the packaged version.
Files in `~/.opencli/clis/<site>/<command>.js` override packaged adapters with the same `site/command` on this machine. `opencli browser verify <site>/<command>` also runs the local override, so a passing local verify does not prove that the packaged adapter was changed.
The packaged `cli-manifest.json` only describes bundled adapters. User adapters are discovered at runtime and do not need manifest entries.
After copying a local fix into the repository for a PR, remove the local copy or run `opencli adapter reset <site>` after merge. Otherwise the local file keeps shadowing future package updates. `opencli doctor` warns when it detects this shadowing.
## Plugins for sharing commands
+14 -2
View File
@@ -45,18 +45,30 @@ User adapter 加载路径是:
这条路径适合快速本地自动化。需要版本管理、review、共享的代码推荐做成 plugin。
如果命令有 required positional args而且 fixture 还没创建,第一次 verify 时直接传 seed
```bash
opencli browser verify instagram/collection-create --write-fixture --seed-args opencli-verify
opencli browser verify example/detail --write-fixture --seed-args '["https://example.com/item/1", "--limit", 3]'
```
`--seed-args` 只在 fixture 没有 `args` 时生效。fixture 写出后,`opencli browser verify` 会从 `~/.opencli/sites/<site>/verify/<command>.json` 读取 args。
## 本地覆盖官方 adapter
如果你想改一个已有官方 adapter`adapter eject`
```bash
opencli adapter eject twitter
opencli adapter status
# edit ~/.opencli/clis/twitter/*.js
opencli adapter reset twitter
```
Ejected adapter 会在本机覆盖 package 里的官方 adapter。`adapter reset` 会移除本地覆盖,恢复到 package 版本
`~/.opencli/clis/<site>/<command>.js` 会在本机覆盖同名 package adapter。`opencli browser verify <site>/<command>` 也会跑本地覆盖版本,所以本地 verify 通过不代表 package 里的 adapter 已经改好
Package 里的 `cli-manifest.json` 只描述 bundled adapter。User adapter 是运行时发现的,不需要写 manifest。
把本地修复复制到仓库发 PR 后merge 后要删除本地副本,或运行 `opencli adapter reset <site>`。否则本地文件会继续 shadow 后续 package 更新。`opencli doctor` 会在发现这种 shadowing 时给出 warning。
## Plugin共享命令
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { findShadowedUserAdapters, formatAdapterShadowIssue } from './adapter-shadow.js';
describe('adapter shadow detection', () => {
it('reports user adapters that shadow packaged manifest commands', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-adapter-shadow-'));
try {
const userClisDir = path.join(root, 'user-clis');
const builtinRoot = path.join(root, 'pkg');
const builtinClisDir = path.join(builtinRoot, 'clis');
fs.mkdirSync(path.join(userClisDir, 'instagram'), { recursive: true });
fs.mkdirSync(path.join(userClisDir, 'twitter'), { recursive: true });
fs.mkdirSync(path.join(builtinClisDir, 'instagram'), { recursive: true });
fs.mkdirSync(path.join(builtinClisDir, 'twitter'), { recursive: true });
fs.writeFileSync(path.join(userClisDir, 'instagram', 'saved.js'), '', 'utf-8');
fs.writeFileSync(path.join(userClisDir, 'instagram', 'utils.js'), '', 'utf-8');
fs.writeFileSync(path.join(userClisDir, 'twitter', 'search.js'), '', 'utf-8');
fs.writeFileSync(path.join(builtinClisDir, 'instagram', 'saved.js'), '', 'utf-8');
fs.writeFileSync(path.join(builtinClisDir, 'instagram', 'utils.js'), '', 'utf-8');
fs.writeFileSync(path.join(builtinClisDir, 'twitter', 'search.js'), '', 'utf-8');
fs.writeFileSync(path.join(builtinRoot, 'cli-manifest.json'), `${JSON.stringify([
{ site: 'instagram', name: 'saved', sourceFile: 'instagram/saved.js' },
])}\n`, 'utf-8');
expect(findShadowedUserAdapters({ userClisDir, builtinClisDir })).toEqual([
{
name: 'instagram/saved',
userPath: path.join(userClisDir, 'instagram', 'saved.js'),
builtinPath: path.join(builtinClisDir, 'instagram', 'saved.js'),
},
]);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
it('formats a concise doctor issue', () => {
const issue = formatAdapterShadowIssue([
{
name: 'instagram/saved',
userPath: '/home/me/.opencli/clis/instagram/saved.js',
builtinPath: '/pkg/clis/instagram/saved.js',
},
]);
expect(issue).toContain('instagram/saved');
expect(issue).toContain('opencli adapter reset <site>');
});
});
+87
View File
@@ -0,0 +1,87 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ManifestEntry } from './manifest-types.js';
import { findPackageRoot, getCliManifestPath } from './package-paths.js';
export type AdapterShadow = {
name: string;
userPath: string;
builtinPath: string;
};
export type AdapterShadowOptions = {
userClisDir?: string;
builtinClisDir?: string;
};
function defaultBuiltinClisDir(): string {
return path.join(findPackageRoot(fileURLToPath(import.meta.url)), 'clis');
}
function safeReaddir(dir: string): fs.Dirent[] {
try {
return fs.readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
}
function loadBuiltinCommandFiles(builtinClisDir: string): Set<string> {
try {
const raw = fs.readFileSync(getCliManifestPath(builtinClisDir), 'utf-8');
const entries = JSON.parse(raw) as ManifestEntry[];
const files = new Set<string>();
for (const entry of entries) {
const rel = entry.sourceFile ?? entry.modulePath;
if (rel) files.add(path.resolve(builtinClisDir, rel));
}
return files;
} catch {
return new Set();
}
}
export function findShadowedUserAdapters(opts: AdapterShadowOptions = {}): AdapterShadow[] {
const userClisDir = opts.userClisDir ?? path.join(os.homedir(), '.opencli', 'clis');
const builtinClisDir = opts.builtinClisDir ?? defaultBuiltinClisDir();
const builtinCommandFiles = loadBuiltinCommandFiles(builtinClisDir);
const shadows: AdapterShadow[] = [];
for (const siteEntry of safeReaddir(userClisDir)) {
if (!siteEntry.isDirectory()) continue;
const site = siteEntry.name;
const userSiteDir = path.join(userClisDir, site);
const builtinSiteDir = path.join(builtinClisDir, site);
for (const commandEntry of safeReaddir(userSiteDir)) {
if (!commandEntry.isFile() || !commandEntry.name.endsWith('.js')) continue;
const userPath = path.join(userSiteDir, commandEntry.name);
const builtinPath = path.join(builtinSiteDir, commandEntry.name);
const builtinResolved = path.resolve(builtinPath);
if (!builtinCommandFiles.has(builtinResolved)) continue;
shadows.push({
name: `${site}/${commandEntry.name.replace(/\.js$/, '')}`,
userPath,
builtinPath,
});
}
}
return shadows.sort((a, b) => a.name.localeCompare(b.name));
}
export function formatAdapterShadowIssue(shadows: AdapterShadow[]): string {
const visible = shadows.slice(0, 10);
const lines = ['Local adapter overrides shadow packaged adapters:'];
for (const shadow of visible) {
lines.push(` ${shadow.name}: ${shadow.userPath} overrides ${shadow.builtinPath}`);
}
if (shadows.length > visible.length) {
lines.push(` ... and ${shadows.length - visible.length} more`);
}
lines.push('Remove the local ~/.opencli/clis copy, or run opencli adapter reset <site>, when you want packaged updates.');
return lines.join('\n');
}
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { deriveFixture, expandFixtureArgs, validateRows, type Fixture } from './verify-fixture.js';
import { deriveFixture, expandFixtureArgs, parseSeedArgs, validateRows, type Fixture } from './verify-fixture.js';
describe('validateRows', () => {
it('passes when rows meet all expectations', () => {
@@ -221,3 +221,22 @@ describe('expandFixtureArgs', () => {
]);
});
});
describe('parseSeedArgs', () => {
it('treats plain text as one positional arg', () => {
expect(parseSeedArgs('opencli-verify')).toEqual(['opencli-verify']);
});
it('accepts JSON array seed args', () => {
expect(parseSeedArgs('["subject", "--limit", 3]')).toEqual(['subject', '--limit', 3]);
});
it('accepts JSON object seed args', () => {
expect(parseSeedArgs('{"limit":3,"sort":"hot"}')).toEqual({ limit: 3, sort: 'hot' });
});
it('ignores empty input', () => {
expect(parseSeedArgs(undefined)).toBeUndefined();
expect(parseSeedArgs(' ')).toBeUndefined();
});
});
+15
View File
@@ -239,6 +239,21 @@ export function expandFixtureArgs(args: FixtureArgs | undefined): string[] {
return out;
}
export function parseSeedArgs(raw: string | undefined): FixtureArgs | undefined {
if (raw === undefined) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
try {
const parsed = JSON.parse(trimmed) as unknown;
if (Array.isArray(parsed)) return parsed;
if (parsed !== null && typeof parsed === 'object') return parsed as Record<string, unknown>;
return [parsed];
} catch {
return [raw];
}
}
function jsType(v: unknown): string {
if (v === null) return 'null';
if (Array.isArray(v)) return 'array';
+56
View File
@@ -174,6 +174,62 @@ describe('browser verify', () => {
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});
it('uses --seed-args when no fixture args exist', async () => {
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-verify-seed-'));
process.env.HOME = fakeHome;
process.env.USERPROFILE = fakeHome;
try {
const adapterDir = path.join(fakeHome, '.opencli', 'clis', 'hn');
fs.mkdirSync(adapterDir, { recursive: true });
fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8');
const program = createProgram('', '');
await program.parseAsync(['node', 'opencli', 'browser', 'verify', 'hn/top', '--no-fixture', '--seed-args', 'opencli-verify']);
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
const [, execArgs] = mockExecFileSync.mock.calls[0] as [string, string[]];
expect(execArgs.slice(-5)).toEqual(['hn', 'top', 'opencli-verify', '--format', 'json']);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});
it('writes --seed-args into a starter fixture', async () => {
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-verify-write-seed-'));
process.env.HOME = fakeHome;
process.env.USERPROFILE = fakeHome;
mockExecFileSync.mockReturnValue(JSON.stringify([{ title: 'ok' }]));
try {
const adapterDir = path.join(fakeHome, '.opencli', 'clis', 'hn');
fs.mkdirSync(adapterDir, { recursive: true });
fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8');
const program = createProgram('', '');
await program.parseAsync(['node', 'opencli', 'browser', 'verify', 'hn/top', '--write-fixture', '--seed-args', 'opencli-verify']);
const fixtureFile = path.join(fakeHome, '.opencli', 'sites', 'hn', 'verify', 'top.json');
const fixture = JSON.parse(fs.readFileSync(fixtureFile, 'utf-8'));
expect(fixture.args).toEqual(['opencli-verify']);
expect(fixture.expect.columns).toEqual(['title']);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});
});
describe('profile list', () => {
+11 -8
View File
@@ -1892,6 +1892,7 @@ cli({
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, template, 'utf-8');
console.log(`Created: ${filePath}`);
console.log('First time on this site? Run: opencli browser analyze <url>');
console.log(`Edit the file to implement your adapter, then run: opencli browser verify ${name}`);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
@@ -1907,9 +1908,10 @@ cli({
.option('--update-fixture', 'Overwrite an existing fixture with one derived from current output')
.option('--no-fixture', 'Ignore any fixture file for this run (no value-level validation)')
.option('--strict-memory', 'Fail (not just warn) when ~/.opencli/sites/<site>/endpoints.json or notes.md is missing')
.option('--seed-args <value>', 'Seed args when no fixture exists; use JSON array/object for multiple args or flags')
.option('--trace <mode>', 'Trace capture for the adapter subprocess: off, on, retain-on-failure', 'off')
.description('Execute an adapter and validate output; uses fixture at ~/.opencli/sites/<site>/verify/<cmd>.json when present')
.action(async (name: string, opts: { fixture?: boolean; writeFixture?: boolean; updateFixture?: boolean; strictMemory?: boolean; trace?: string } = {}) => {
.action(async (name: string, opts: { fixture?: boolean; writeFixture?: boolean; updateFixture?: boolean; strictMemory?: boolean; seedArgs?: string; trace?: string } = {}) => {
try {
const parts = name.split('/');
if (parts.length !== 2) { console.error('Name must be site/command format'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
@@ -1921,7 +1923,7 @@ cli({
}
const { execFileSync } = await import('node:child_process');
const { loadFixture, writeFixture, deriveFixture, validateRows, fixturePath, expandFixtureArgs } = await import('./browser/verify-fixture.js');
const { loadFixture, writeFixture, deriveFixture, validateRows, fixturePath, expandFixtureArgs, parseSeedArgs } = await import('./browser/verify-fixture.js');
const filePath = path.join(os.homedir(), '.opencli', 'clis', site, `${command}.js`);
if (!fs.existsSync(filePath)) {
console.error(`Adapter not found: ${filePath}`);
@@ -1941,9 +1943,10 @@ cli({
// - array form ["123", "--limit", "3"] → verbatim (for positional subjects)
const adapterSrc = fs.readFileSync(filePath, 'utf-8');
const hasLimitArg = /['"]limit['"]/.test(adapterSrc);
const fixtureArgs = fixture?.args;
const cliArgs: string[] = expandFixtureArgs(fixtureArgs);
if (cliArgs.length === 0 && hasLimitArg) cliArgs.push('--limit', '3');
const seedArgs = parseSeedArgs(opts.seedArgs);
const explicitArgs = fixture?.args ?? seedArgs;
const cliArgs: string[] = expandFixtureArgs(explicitArgs);
if (explicitArgs === undefined && cliArgs.length === 0 && hasLimitArg) cliArgs.push('--limit', '3');
const traceArgs = opts.trace && opts.trace !== 'off' ? ['--trace', opts.trace] : [];
const argDisplay = [...cliArgs, ...traceArgs].join(' ');
@@ -1993,10 +1996,10 @@ cli({
console.log(`\n Fixture already exists at ${fixturePath(site, command)}.`);
console.log(` Use --update-fixture to overwrite.`);
} else {
const seedArgs = fixtureArgs !== undefined
? fixtureArgs
const fixtureArgs = explicitArgs !== undefined
? explicitArgs
: (hasLimitArg ? { limit: 3 } : undefined);
const derived = deriveFixture(rows, seedArgs);
const derived = deriveFixture(rows, fixtureArgs);
const p = writeFixture(site, command, derived);
console.log(`\n ${fixture ? '↻ Updated' : '✎ Wrote'} fixture: ${p}`);
console.log(` Review and hand-tune the derived expectations (add patterns / notEmpty, tighten rowCount).`);
+40 -1
View File
@@ -1,10 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockGetDaemonHealth, mockListSessions, mockConnect, mockClose } = vi.hoisted(() => ({
const { mockGetDaemonHealth, mockListSessions, mockConnect, mockClose, mockFindShadowedUserAdapters } = vi.hoisted(() => ({
mockGetDaemonHealth: vi.fn(),
mockListSessions: vi.fn(),
mockConnect: vi.fn(),
mockClose: vi.fn(),
mockFindShadowedUserAdapters: vi.fn(),
}));
vi.mock('./browser/daemon-client.js', () => ({
@@ -19,6 +20,14 @@ vi.mock('./browser/index.js', () => ({
},
}));
vi.mock('./adapter-shadow.js', async () => {
const actual = await vi.importActual<typeof import('./adapter-shadow.js')>('./adapter-shadow.js');
return {
...actual,
findShadowedUserAdapters: mockFindShadowedUserAdapters,
};
});
import { renderBrowserDoctorReport, runBrowserDoctor } from './doctor.js';
describe('doctor report rendering', () => {
@@ -26,6 +35,7 @@ describe('doctor report rendering', () => {
beforeEach(() => {
vi.clearAllMocks();
mockFindShadowedUserAdapters.mockReturnValue([]);
});
it('renders OK-style report when daemon and extension connected', () => {
@@ -42,6 +52,7 @@ describe('doctor report rendering', () => {
expect(text).toContain('(v1.7.9)');
expect(text).toContain('[OK] Extension: connected (v1.6.8)');
expect(text).toContain('Everything looks good!');
expect(text).toContain('opencli browser analyze <url>');
});
it('renders a warning when daemon version is stale', () => {
@@ -323,6 +334,34 @@ describe('doctor report rendering', () => {
]));
});
it('reports local adapter shadows as a warning issue', async () => {
const status = {
state: 'ready' as const,
status: {
daemonVersion: '1.7.9',
extensionConnected: true,
extensionVersion: '1.0.3',
},
};
mockGetDaemonHealth
.mockResolvedValueOnce(status)
.mockResolvedValueOnce(status);
mockFindShadowedUserAdapters.mockReturnValueOnce([
{
name: 'instagram/saved',
userPath: '/home/me/.opencli/clis/instagram/saved.js',
builtinPath: '/pkg/clis/instagram/saved.js',
},
]);
const report = await runBrowserDoctor({ live: false, cliVersion: '1.7.9' });
expect(report.adapterShadows).toHaveLength(1);
expect(report.issues).toEqual(expect.arrayContaining([
expect.stringContaining('Local adapter overrides shadow packaged adapters'),
]));
});
it('reports profile-required when multiple profiles are connected without a selection', async () => {
const status = {
state: 'profile-required' as const,
+8
View File
@@ -15,6 +15,7 @@ import type { BrowserSessionInfo } from './types.js';
import type { BrowserProfileStatus } from './browser/daemon-client.js';
import { aliasForContextId, loadProfileConfig } from './browser/profile.js';
import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js';
import { findShadowedUserAdapters, formatAdapterShadowIssue, type AdapterShadow } from './adapter-shadow.js';
const DOCTOR_LIVE_TIMEOUT_SECONDS = 8;
@@ -73,6 +74,7 @@ export type DoctorReport = {
connectivity?: ConnectivityResult;
sessions?: BrowserSessionInfo[];
profiles?: BrowserProfileStatus[];
adapterShadows?: AdapterShadow[];
issues: string[];
};
@@ -138,6 +140,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
}
}
const extensionVersion = health.status?.extensionVersion;
const adapterShadows = findShadowedUserAdapters();
const issues: string[] = [];
if (daemonFlaky) {
@@ -217,6 +220,9 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
' Download from: https://github.com/jackwener/opencli/releases',
);
}
if (adapterShadows.length > 0) {
issues.push(formatAdapterShadowIssue(adapterShadows));
}
return {
cliVersion: opts.cliVersion,
@@ -231,6 +237,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
connectivity,
sessions,
profiles,
adapterShadows,
issues,
};
}
@@ -329,6 +336,7 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
}
} else if (report.daemonRunning && report.extensionConnected) {
lines.push('', styleText('green', 'Everything looks good!'));
lines.push(styleText('dim', 'Tip: writing a new adapter? Run `opencli browser analyze <url>` for one-shot site recon.'));
}
return lines.join('\n');