fix: address review findings and docs cleanup
This commit is contained in:
1 parent
ee35ee723f
commit
0ea6e4a15c
22 files changed
+473
-136
No files matched your search
@@ -21,10 +21,28 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: extension/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
run: npm ci
|
||||
working-directory: extension
|
||||
|
||||
- name: Build extension
|
||||
run: npm run build
|
||||
working-directory: extension
|
||||
|
||||
- name: Prepare extension package
|
||||
run: |
|
||||
rm -rf extension-package
|
||||
mkdir -p extension-package
|
||||
cp extension/manifest.json extension-package/
|
||||
cp -R extension/dist extension-package/
|
||||
cp -R extension/icons extension-package/
|
||||
|
||||
- name: Create Extension ZIP
|
||||
run: |
|
||||
cd extension
|
||||
cd extension-package
|
||||
zip -r ../opencli-extension.zip .
|
||||
|
||||
- name: Create Extension CRX
|
||||
@@ -33,11 +51,11 @@ jobs:
|
||||
if [ -n "${{ secrets.CRX_PRIVATE_KEY }}" ]; then
|
||||
echo "Found CRX_PRIVATE_KEY, signing extension..."
|
||||
echo "${{ secrets.CRX_PRIVATE_KEY }}" > crx-key.pem
|
||||
crx3 pack extension -o opencli-extension.crx -p crx-key.pem
|
||||
crx3 pack extension-package -o opencli-extension.crx -p crx-key.pem
|
||||
rm crx-key.pem
|
||||
else
|
||||
echo "No CRX_PRIVATE_KEY configured. Generating CRX with a temporary random key..."
|
||||
crx3 pack extension -o opencli-extension.crx
|
||||
crx3 pack extension-package -o opencli-extension.crx
|
||||
fi
|
||||
|
||||
- name: Upload Artifacts (Action Run)
|
||||
|
||||
@@ -115,34 +115,34 @@ Run `opencli list` for the live registry.
|
||||
|
||||
| Site | Commands | Mode |
|
||||
|------|----------|------|
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | 🔐 Browser |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 Browser |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 🖥️ Desktop |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
|
||||
| **codex** | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` | 🖥️ Desktop |
|
||||
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 🖥️ Desktop |
|
||||
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 🖥️ Desktop |
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 🖥️ Desktop |
|
||||
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 🌐 / 🔐 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 Browser |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 🖥️ Desktop |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` | 🖥️ Desktop |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` `download` | 🔐 Browser |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | 🌐 Public |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
|
||||
| **zhihu** | `hot` `search` `question` `download` | 🔐 Browser |
|
||||
| **youtube** | `search` `video` `transcript` | 🔐 Browser |
|
||||
| **boss** | `search` `detail` | 🔐 Browser |
|
||||
| **coupang** | `search` `add-to-cart` | 🔐 Browser |
|
||||
| **bbc** | `news` | 🌐 Public |
|
||||
| **ctrip** | `search` | 🔐 Browser |
|
||||
| **github** | `search` | 🌐 Public |
|
||||
| **hackernews** | `top` | 🌐 Public |
|
||||
| **linkedin** | `search` | 🔐 Browser |
|
||||
| **reuters** | `search` | 🔐 Browser |
|
||||
| **smzdm** | `search` | 🔐 Browser |
|
||||
| **weibo** | `hot` | 🔐 Browser |
|
||||
| **yahoo-finance** | `quote` | 🔐 Browser |
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | Browser |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | Browser |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | Desktop |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | Browser |
|
||||
| **codex** | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` | Desktop |
|
||||
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | Desktop |
|
||||
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | Desktop |
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | Desktop |
|
||||
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | Public / Browser |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | Browser |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | Desktop |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` | Desktop |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` `download` | Browser |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | Public |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | Public |
|
||||
| **zhihu** | `hot` `search` `question` `download` | Browser |
|
||||
| **youtube** | `search` `video` `transcript` | Browser |
|
||||
| **boss** | `search` `detail` | Browser |
|
||||
| **coupang** | `search` `add-to-cart` | Browser |
|
||||
| **bbc** | `news` | Public |
|
||||
| **ctrip** | `search` | Browser |
|
||||
| **github** | `search` | Public |
|
||||
| **hackernews** | `top` | Public |
|
||||
| **linkedin** | `search` | Browser |
|
||||
| **reuters** | `search` | Browser |
|
||||
| **smzdm** | `search` | Browser |
|
||||
| **weibo** | `hot` | Browser |
|
||||
| **yahoo-finance** | `quote` | Browser |
|
||||
|
||||
### Desktop App Adapters
|
||||
|
||||
|
||||
+28
-28
@@ -116,34 +116,34 @@ npm install -g @jackwener/opencli@latest
|
||||
|
||||
| 站点 | 命令 | 模式 |
|
||||
|------|------|------|
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | 🔐 浏览器 |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 浏览器 |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 🖥️ 桌面端 |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 浏览器 |
|
||||
| **codex** | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` | 🖥️ 桌面端 |
|
||||
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 🖥️ 桌面端 |
|
||||
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 🖥️ 桌面端 |
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 🖥️ 桌面端 |
|
||||
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 🌐 / 🔐 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 浏览器 |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 🖥️ 桌面端 |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` | 🖥️ 桌面端 |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` `download` | 🔐 浏览器 |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | 🌐 公开 |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 🌐 公开 |
|
||||
| **zhihu** | `hot` `search` `question` `download` | 🔐 浏览器 |
|
||||
| **youtube** | `search` `video` `transcript` | 🔐 浏览器 |
|
||||
| **boss** | `search` `detail` | 🔐 浏览器 |
|
||||
| **coupang** | `search` `add-to-cart` | 🔐 浏览器 |
|
||||
| **bbc** | `news` | 🌐 公共 API |
|
||||
| **ctrip** | `search` | 🔐 浏览器 |
|
||||
| **github** | `search` | 🌐 公共 API |
|
||||
| **hackernews** | `top` | 🌐 公共 API |
|
||||
| **linkedin** | `search` | 🔐 浏览器 |
|
||||
| **reuters** | `search` | 🔐 浏览器 |
|
||||
| **smzdm** | `search` | 🔐 浏览器 |
|
||||
| **weibo** | `hot` | 🔐 浏览器 |
|
||||
| **yahoo-finance** | `quote` | 🔐 浏览器 |
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` | 浏览器 |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
|
||||
| **codex** | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` | 桌面端 |
|
||||
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 |
|
||||
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 桌面端 |
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 |
|
||||
| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 公开 / 浏览器 |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 浏览器 |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | 桌面端 |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` | 桌面端 |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `me` `user` `download` | 浏览器 |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
|
||||
| **zhihu** | `hot` `search` `question` `download` | 浏览器 |
|
||||
| **youtube** | `search` `video` `transcript` | 浏览器 |
|
||||
| **boss** | `search` `detail` | 浏览器 |
|
||||
| **coupang** | `search` `add-to-cart` | 浏览器 |
|
||||
| **bbc** | `news` | 公共 API |
|
||||
| **ctrip** | `search` | 浏览器 |
|
||||
| **github** | `search` | 公共 API |
|
||||
| **hackernews** | `top` | 公共 API |
|
||||
| **linkedin** | `search` | 浏览器 |
|
||||
| **reuters** | `search` | 浏览器 |
|
||||
| **smzdm** | `search` | 浏览器 |
|
||||
| **weibo** | `hot` | 浏览器 |
|
||||
| **yahoo-finance** | `quote` | 浏览器 |
|
||||
|
||||
### 桌面应用适配器
|
||||
|
||||
|
||||
Vendored
+19
-5
@@ -267,6 +267,19 @@ async function resolveTabId(tabId) {
|
||||
if (!newTab.id) throw new Error("Failed to create tab in automation window");
|
||||
return newTab.id;
|
||||
}
|
||||
async function listAutomationTabs() {
|
||||
if (automationWindowId === null) return [];
|
||||
try {
|
||||
return await chrome.tabs.query({ windowId: automationWindowId });
|
||||
} catch {
|
||||
automationWindowId = null;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
async function listAutomationWebTabs() {
|
||||
const tabs = await listAutomationTabs();
|
||||
return tabs.filter((tab) => isWebUrl(tab.url));
|
||||
}
|
||||
async function handleExec(cmd) {
|
||||
if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
|
||||
const tabId = await resolveTabId(cmd.tabId);
|
||||
@@ -306,8 +319,8 @@ async function handleNavigate(cmd) {
|
||||
async function handleTabs(cmd) {
|
||||
switch (cmd.op) {
|
||||
case "list": {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const data = tabs.filter((t) => isWebUrl(t.url)).map((t, i) => ({
|
||||
const tabs = await listAutomationWebTabs();
|
||||
const data = tabs.map((t, i) => ({
|
||||
index: i,
|
||||
tabId: t.id,
|
||||
url: t.url,
|
||||
@@ -317,12 +330,13 @@ async function handleTabs(cmd) {
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
case "new": {
|
||||
const tab = await chrome.tabs.create({ url: cmd.url, active: true });
|
||||
const windowId = await getAutomationWindow();
|
||||
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? "about:blank", active: true });
|
||||
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
|
||||
}
|
||||
case "close": {
|
||||
if (cmd.index !== void 0) {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const tabs = await listAutomationWebTabs();
|
||||
const target = tabs[cmd.index];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.remove(target.id);
|
||||
@@ -341,7 +355,7 @@ async function handleTabs(cmd) {
|
||||
await chrome.tabs.update(cmd.tabId, { active: true });
|
||||
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
|
||||
}
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const tabs = await listAutomationWebTabs();
|
||||
const target = tabs[cmd.index];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.update(target.id, { active: true });
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type Listener<T extends (...args: any[]) => void> = { addListener: (fn: T) => void };
|
||||
|
||||
type MockTab = {
|
||||
id: number;
|
||||
windowId: number;
|
||||
url?: string;
|
||||
title?: string;
|
||||
active?: boolean;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
class MockWebSocket {
|
||||
static OPEN = 1;
|
||||
static CONNECTING = 0;
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
constructor(_url: string) {}
|
||||
send(_data: string): void {}
|
||||
close(): void {
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
function createChromeMock() {
|
||||
let nextTabId = 10;
|
||||
const tabs: MockTab[] = [
|
||||
{ id: 1, windowId: 1, url: 'https://automation.example', title: 'automation', active: true, status: 'complete' },
|
||||
{ id: 2, windowId: 2, url: 'https://user.example', title: 'user', active: true, status: 'complete' },
|
||||
{ id: 3, windowId: 1, url: 'chrome://extensions', title: 'chrome', active: false, status: 'complete' },
|
||||
];
|
||||
|
||||
const query = vi.fn(async (queryInfo: { windowId?: number } = {}) => {
|
||||
return tabs.filter((tab) => queryInfo.windowId === undefined || tab.windowId === queryInfo.windowId);
|
||||
});
|
||||
const create = vi.fn(async ({ windowId, url, active }: { windowId?: number; url?: string; active?: boolean }) => {
|
||||
const tab: MockTab = {
|
||||
id: nextTabId++,
|
||||
windowId: windowId ?? 999,
|
||||
url,
|
||||
title: url ?? 'blank',
|
||||
active: !!active,
|
||||
status: 'complete',
|
||||
};
|
||||
tabs.push(tab);
|
||||
return tab;
|
||||
});
|
||||
const update = vi.fn(async (tabId: number, updates: { active?: boolean; url?: string }) => {
|
||||
const tab = tabs.find((entry) => entry.id === tabId);
|
||||
if (!tab) throw new Error(`Unknown tab ${tabId}`);
|
||||
if (updates.active !== undefined) tab.active = updates.active;
|
||||
if (updates.url !== undefined) tab.url = updates.url;
|
||||
return tab;
|
||||
});
|
||||
|
||||
const chrome = {
|
||||
tabs: {
|
||||
query,
|
||||
create,
|
||||
update,
|
||||
remove: vi.fn(async (_tabId: number) => {}),
|
||||
get: vi.fn(async (tabId: number) => {
|
||||
const tab = tabs.find((entry) => entry.id === tabId);
|
||||
if (!tab) throw new Error(`Unknown tab ${tabId}`);
|
||||
return tab;
|
||||
}),
|
||||
onUpdated: { addListener: vi.fn(), removeListener: vi.fn() } as Listener<(id: number, info: chrome.tabs.TabChangeInfo) => void>,
|
||||
},
|
||||
windows: {
|
||||
get: vi.fn(async (windowId: number) => ({ id: windowId })),
|
||||
create: vi.fn(async ({ url, focused, width, height, type }: any) => ({ id: 1, url, focused, width, height, type })),
|
||||
remove: vi.fn(async (_windowId: number) => {}),
|
||||
onRemoved: { addListener: vi.fn() } as Listener<(windowId: number) => void>,
|
||||
},
|
||||
alarms: {
|
||||
create: vi.fn(),
|
||||
onAlarm: { addListener: vi.fn() } as Listener<(alarm: { name: string }) => void>,
|
||||
},
|
||||
runtime: {
|
||||
onInstalled: { addListener: vi.fn() } as Listener<() => void>,
|
||||
onStartup: { addListener: vi.fn() } as Listener<() => void>,
|
||||
},
|
||||
cookies: {
|
||||
getAll: vi.fn(async () => []),
|
||||
},
|
||||
};
|
||||
|
||||
return { chrome, tabs, query, create, update };
|
||||
}
|
||||
|
||||
describe('background tab isolation', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal('WebSocket', MockWebSocket);
|
||||
});
|
||||
|
||||
it('lists only automation-window web tabs', async () => {
|
||||
const { chrome } = createChromeMock();
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
|
||||
const mod = await import('./background');
|
||||
mod.__test__.setAutomationWindowId(1);
|
||||
|
||||
const result = await mod.__test__.handleTabs({ id: '1', action: 'tabs', op: 'list' });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.data).toEqual([
|
||||
{
|
||||
index: 0,
|
||||
tabId: 1,
|
||||
url: 'https://automation.example',
|
||||
title: 'automation',
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates new tabs inside the automation window', async () => {
|
||||
const { chrome, create } = createChromeMock();
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
|
||||
const mod = await import('./background');
|
||||
mod.__test__.setAutomationWindowId(1);
|
||||
|
||||
const result = await mod.__test__.handleTabs({ id: '2', action: 'tabs', op: 'new', url: 'https://new.example' });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(create).toHaveBeenCalledWith({ windowId: 1, url: 'https://new.example', active: true });
|
||||
});
|
||||
});
|
||||
@@ -234,6 +234,21 @@ async function resolveTabId(tabId?: number): Promise<number> {
|
||||
return newTab.id;
|
||||
}
|
||||
|
||||
async function listAutomationTabs(): Promise<chrome.tabs.Tab[]> {
|
||||
if (automationWindowId === null) return [];
|
||||
try {
|
||||
return await chrome.tabs.query({ windowId: automationWindowId });
|
||||
} catch {
|
||||
automationWindowId = null;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function listAutomationWebTabs(): Promise<chrome.tabs.Tab[]> {
|
||||
const tabs = await listAutomationTabs();
|
||||
return tabs.filter((tab) => isWebUrl(tab.url));
|
||||
}
|
||||
|
||||
async function handleExec(cmd: Command): Promise<Result> {
|
||||
if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
|
||||
const tabId = await resolveTabId(cmd.tabId);
|
||||
@@ -278,9 +293,8 @@ async function handleNavigate(cmd: Command): Promise<Result> {
|
||||
async function handleTabs(cmd: Command): Promise<Result> {
|
||||
switch (cmd.op) {
|
||||
case 'list': {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const tabs = await listAutomationWebTabs();
|
||||
const data = tabs
|
||||
.filter((t) => isWebUrl(t.url))
|
||||
.map((t, i) => ({
|
||||
index: i,
|
||||
tabId: t.id,
|
||||
@@ -291,12 +305,13 @@ async function handleTabs(cmd: Command): Promise<Result> {
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
case 'new': {
|
||||
const tab = await chrome.tabs.create({ url: cmd.url, active: true });
|
||||
const windowId = await getAutomationWindow();
|
||||
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? 'about:blank', active: true });
|
||||
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
|
||||
}
|
||||
case 'close': {
|
||||
if (cmd.index !== undefined) {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const tabs = await listAutomationWebTabs();
|
||||
const target = tabs[cmd.index];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.remove(target.id);
|
||||
@@ -315,7 +330,7 @@ async function handleTabs(cmd: Command): Promise<Result> {
|
||||
await chrome.tabs.update(cmd.tabId, { active: true });
|
||||
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
|
||||
}
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const tabs = await listAutomationWebTabs();
|
||||
const target = tabs[cmd.index!];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.update(target.id, { active: true });
|
||||
@@ -368,3 +383,11 @@ async function handleCloseWindow(cmd: Command): Promise<Result> {
|
||||
}
|
||||
return { id: cmd.id, ok: true, data: { closed: true } };
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
handleTabs,
|
||||
getAutomationWindowId: () => automationWindowId,
|
||||
setAutomationWindowId: (windowId: number | null) => {
|
||||
automationWindowId = windowId;
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { BrowserBridge, __test__ } from './browser/index.js';
|
||||
import * as daemonClient from './browser/daemon-client.js';
|
||||
|
||||
describe('browser helpers', () => {
|
||||
it('extracts tab entries from string snapshots', () => {
|
||||
@@ -122,4 +123,13 @@ describe('BrowserBridge state', () => {
|
||||
|
||||
await expect(mcp.connect()).rejects.toThrow('Session is closing');
|
||||
});
|
||||
|
||||
it('fails fast when daemon is running but extension is disconnected', async () => {
|
||||
vi.spyOn(daemonClient, 'isExtensionConnected').mockResolvedValue(false);
|
||||
vi.spyOn(daemonClient, 'isDaemonRunning').mockResolvedValue(true);
|
||||
|
||||
const mcp = new BrowserBridge();
|
||||
|
||||
await expect(mcp.connect()).rejects.toThrow('Browser Extension is not connected');
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,14 @@ class CDPPage implements IPage {
|
||||
return result.result?.value;
|
||||
}
|
||||
|
||||
async getCookies(opts: { domain?: string; url?: string } = {}): Promise<any[]> {
|
||||
const result = await this.bridge.send('Network.getCookies', opts.url ? { urls: [opts.url] } : {});
|
||||
const cookies = Array.isArray(result?.cookies) ? result.cookies : [];
|
||||
return opts.domain
|
||||
? cookies.filter((cookie: any) => typeof cookie.domain === 'string' && cookie.domain.includes(opts.domain!))
|
||||
: cookies;
|
||||
}
|
||||
|
||||
async snapshot(opts?: any): Promise<any> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
+12
-4
@@ -35,7 +35,7 @@ export class BrowserBridge {
|
||||
this._state = 'connecting';
|
||||
|
||||
try {
|
||||
await this._ensureDaemon();
|
||||
await this._ensureDaemon(opts.timeout);
|
||||
this._page = new Page();
|
||||
this._state = 'connected';
|
||||
return this._page;
|
||||
@@ -54,8 +54,16 @@ export class BrowserBridge {
|
||||
this._state = 'closed';
|
||||
}
|
||||
|
||||
private async _ensureDaemon(): Promise<void> {
|
||||
if (await isDaemonRunning()) return;
|
||||
private async _ensureDaemon(timeoutSeconds?: number): Promise<void> {
|
||||
const timeoutMs = Math.max(1, timeoutSeconds ?? Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000)) * 1000;
|
||||
|
||||
if (await isExtensionConnected()) return;
|
||||
if (await isDaemonRunning()) {
|
||||
throw new Error(
|
||||
'Daemon is running but the Browser Extension is not connected.\n' +
|
||||
'Please install and enable the opencli Browser Bridge extension in Chrome.',
|
||||
);
|
||||
}
|
||||
|
||||
// Find daemon relative to this file — works for both:
|
||||
// npx tsx src/main.ts → src/browser/mcp.ts → src/daemon.ts
|
||||
@@ -85,7 +93,7 @@ export class BrowserBridge {
|
||||
this._daemonProc.unref();
|
||||
|
||||
// Wait for daemon to be ready AND extension to connect
|
||||
const deadline = Date.now() + DAEMON_SPAWN_TIMEOUT;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
if (await isExtensionConnected()) return;
|
||||
|
||||
@@ -52,6 +52,11 @@ export class Page implements IPage {
|
||||
return sendCommand('exec', { code, ...this._tabOpt() });
|
||||
}
|
||||
|
||||
async getCookies(opts: { domain?: string; url?: string } = {}): Promise<any[]> {
|
||||
const result = await sendCommand('cookies', opts);
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
|
||||
const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200));
|
||||
const code = `
|
||||
|
||||
@@ -25,4 +25,40 @@ describe('parseTsArgsBlock', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps hyphenated arg names from TS adapters', () => {
|
||||
const args = parseTsArgsBlock(`
|
||||
{
|
||||
name: 'tweet-url',
|
||||
help: 'Single tweet URL to download',
|
||||
},
|
||||
{
|
||||
name: 'download-images',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
help: 'Download images locally',
|
||||
},
|
||||
`);
|
||||
|
||||
expect(args).toEqual([
|
||||
{
|
||||
name: 'tweet-url',
|
||||
type: 'str',
|
||||
default: undefined,
|
||||
required: false,
|
||||
positional: undefined,
|
||||
help: 'Single tweet URL to download',
|
||||
choices: undefined,
|
||||
},
|
||||
{
|
||||
name: 'download-images',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
required: false,
|
||||
positional: undefined,
|
||||
help: 'Download images locally',
|
||||
choices: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -114,7 +114,7 @@ export function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'] {
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < argsBlock.length) {
|
||||
const nameMatch = argsBlock.slice(cursor).match(/\{\s*name\s*:\s*['"`](\w+)['"`]/);
|
||||
const nameMatch = argsBlock.slice(cursor).match(/\{\s*name\s*:\s*['"`]([^'"`]+)['"`]/);
|
||||
if (!nameMatch || nameMatch.index === undefined) break;
|
||||
|
||||
const objectStart = cursor + nameMatch.index;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
sanitizeFilename,
|
||||
getTempDir,
|
||||
exportCookiesToNetscape,
|
||||
formatCookieHeader,
|
||||
} from '../../download/index.js';
|
||||
import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
|
||||
|
||||
@@ -63,30 +64,18 @@ cli({
|
||||
const title = sanitizeFilename(data?.title || 'video');
|
||||
|
||||
// Extract cookies for authenticated downloads
|
||||
const cookieString = await page.evaluate(`(() => document.cookie)()`);
|
||||
const cookies = await page.getCookies({ domain: 'bilibili.com' });
|
||||
const cookieString = formatCookieHeader(cookies);
|
||||
|
||||
// Create output directory
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
|
||||
// Export cookies to Netscape format for yt-dlp
|
||||
let cookiesFile: string | undefined;
|
||||
if (typeof cookieString === 'string' && cookieString) {
|
||||
if (cookies.length > 0) {
|
||||
const tempDir = getTempDir();
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
cookiesFile = path.join(tempDir, `bilibili_cookies_${Date.now()}.txt`);
|
||||
|
||||
const cookies = cookieString.split(';').map((c) => {
|
||||
const [name, ...rest] = c.trim().split('=');
|
||||
return {
|
||||
name: name || '',
|
||||
value: rest.join('=') || '',
|
||||
domain: '.bilibili.com',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: false,
|
||||
};
|
||||
}).filter((c) => c.name);
|
||||
|
||||
exportCookiesToNetscape(cookies, cookiesFile);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
sanitizeFilename,
|
||||
getTempDir,
|
||||
exportCookiesToNetscape,
|
||||
formatCookieHeader,
|
||||
} from '../../download/index.js';
|
||||
import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
|
||||
|
||||
@@ -109,7 +110,8 @@ cli({
|
||||
}
|
||||
|
||||
// Extract cookies
|
||||
const cookieString = await page.evaluate(`(() => document.cookie)()`);
|
||||
const cookies = await page.getCookies({ domain: 'x.com' });
|
||||
const cookieString = formatCookieHeader(cookies);
|
||||
|
||||
// Create output directory
|
||||
const outputDir = tweetUrl
|
||||
@@ -119,23 +121,10 @@ cli({
|
||||
|
||||
// Export cookies for yt-dlp
|
||||
let cookiesFile: string | undefined;
|
||||
if (typeof cookieString === 'string' && cookieString) {
|
||||
if (cookies.length > 0) {
|
||||
const tempDir = getTempDir();
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
cookiesFile = path.join(tempDir, `twitter_cookies_${Date.now()}.txt`);
|
||||
|
||||
const cookies = cookieString.split(';').map((c) => {
|
||||
const [name, ...rest] = c.trim().split('=');
|
||||
return {
|
||||
name: name || '',
|
||||
value: rest.join('=') || '',
|
||||
domain: '.x.com',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: false,
|
||||
};
|
||||
}).filter((c) => c.name);
|
||||
|
||||
exportCookiesToNetscape(cookies, cookiesFile);
|
||||
}
|
||||
|
||||
@@ -173,7 +162,7 @@ cli({
|
||||
} else if (media.type === 'image') {
|
||||
// Direct HTTP download for images
|
||||
result = await httpDownload(media.url, destPath, {
|
||||
cookies: typeof cookieString === 'string' ? cookieString : '',
|
||||
cookies: cookieString,
|
||||
timeout: 30000,
|
||||
onProgress: (received, total) => {
|
||||
if (progressBar) progressBar.update(received, total);
|
||||
@@ -182,7 +171,7 @@ cli({
|
||||
} else {
|
||||
// Direct HTTP download for direct video URLs
|
||||
result = await httpDownload(media.url, destPath, {
|
||||
cookies: typeof cookieString === 'string' ? cookieString : '',
|
||||
cookies: cookieString,
|
||||
timeout: 60000,
|
||||
onProgress: (received, total) => {
|
||||
if (progressBar) progressBar.update(received, total);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { cli, Strategy } from '../../registry.js';
|
||||
import {
|
||||
httpDownload,
|
||||
sanitizeFilename,
|
||||
detectContentType,
|
||||
formatCookieHeader,
|
||||
} from '../../download/index.js';
|
||||
import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
|
||||
|
||||
@@ -114,7 +114,7 @@ cli({
|
||||
}
|
||||
|
||||
// Extract cookies for authenticated downloads
|
||||
const cookies = await page.evaluate(`(() => document.cookie)()`);
|
||||
const cookies = formatCookieHeader(await page.getCookies({ domain: 'xiaohongshu.com' }));
|
||||
|
||||
// Create output directory
|
||||
const outputDir = path.join(output, noteId);
|
||||
@@ -134,7 +134,7 @@ cli({
|
||||
|
||||
try {
|
||||
const result = await httpDownload(media.url, destPath, {
|
||||
cookies: typeof cookies === 'string' ? cookies : '',
|
||||
cookies,
|
||||
timeout: 60000,
|
||||
onProgress: (received, total) => {
|
||||
if (progressBar) progressBar.update(received, total);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { sanitizeFilename, httpDownload } from '../../download/index.js';
|
||||
import { sanitizeFilename, httpDownload, formatCookieHeader } from '../../download/index.js';
|
||||
import { formatBytes } from '../../download/progress.js';
|
||||
|
||||
/**
|
||||
@@ -178,7 +178,7 @@ cli({
|
||||
const imagesDir = path.join(output, 'images');
|
||||
fs.mkdirSync(imagesDir, { recursive: true });
|
||||
|
||||
const cookies = await page.evaluate(`(() => document.cookie)()`);
|
||||
const cookies = formatCookieHeader(await page.getCookies({ domain: 'zhihu.com' }));
|
||||
|
||||
for (let i = 0; i < data.images.length; i++) {
|
||||
const imgUrl = data.images[i];
|
||||
@@ -188,7 +188,7 @@ cli({
|
||||
|
||||
try {
|
||||
await httpDownload(imgUrl, imgPath, {
|
||||
cookies: typeof cookies === 'string' ? cookies : '',
|
||||
cookies,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatCookieHeader, resolveRedirectUrl } from './index.js';
|
||||
|
||||
describe('download helpers', () => {
|
||||
it('resolves relative redirects against the original URL', () => {
|
||||
expect(resolveRedirectUrl('https://example.com/a/file', '/cdn/file.bin')).toBe('https://example.com/cdn/file.bin');
|
||||
expect(resolveRedirectUrl('https://example.com/a/file', '../next')).toBe('https://example.com/next');
|
||||
});
|
||||
|
||||
it('formats browser cookies into a Cookie header', () => {
|
||||
expect(formatCookieHeader([
|
||||
{ name: 'sid', value: 'abc', domain: 'example.com' },
|
||||
{ name: 'ct0', value: 'def', domain: 'example.com' },
|
||||
])).toBe('sid=abc; ct0=def');
|
||||
});
|
||||
});
|
||||
+22
-4
@@ -26,6 +26,16 @@ export interface YtdlpOptions {
|
||||
onProgress?: (percent: number) => void;
|
||||
}
|
||||
|
||||
export interface BrowserCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path?: string;
|
||||
secure?: boolean;
|
||||
httpOnly?: boolean;
|
||||
expirationDate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if yt-dlp is available in PATH.
|
||||
*/
|
||||
@@ -142,14 +152,14 @@ export async function httpDownload(
|
||||
// Handle redirects
|
||||
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
||||
file.close();
|
||||
fs.unlinkSync(tempPath);
|
||||
httpDownload(response.headers.location, destPath, options).then(resolve);
|
||||
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
||||
httpDownload(resolveRedirectUrl(url, response.headers.location), destPath, options).then(resolve);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close();
|
||||
fs.unlinkSync(tempPath);
|
||||
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
||||
resolve({ success: false, size: 0, error: `HTTP ${response.statusCode}` });
|
||||
return;
|
||||
}
|
||||
@@ -187,11 +197,15 @@ export async function httpDownload(
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveRedirectUrl(currentUrl: string, location: string): string {
|
||||
return new URL(location, currentUrl).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export cookies to Netscape format for yt-dlp.
|
||||
*/
|
||||
export function exportCookiesToNetscape(
|
||||
cookies: Array<{ name: string; value: string; domain: string; path?: string; secure?: boolean; httpOnly?: boolean }>,
|
||||
cookies: BrowserCookie[],
|
||||
filePath: string,
|
||||
): void {
|
||||
const lines = [
|
||||
@@ -214,6 +228,10 @@ export function exportCookiesToNetscape(
|
||||
fs.writeFileSync(filePath, lines.join('\n'));
|
||||
}
|
||||
|
||||
export function formatCookieHeader(cookies: BrowserCookie[]): string {
|
||||
return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download video using yt-dlp.
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@ function createMockPage(overrides: Partial<IPage> = {}): IPage {
|
||||
return {
|
||||
goto: vi.fn(),
|
||||
evaluate: vi.fn().mockResolvedValue(null),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
snapshot: vi.fn().mockResolvedValue(''),
|
||||
click: vi.fn(),
|
||||
typeText: vi.fn(),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
generateFilename,
|
||||
exportCookiesToNetscape,
|
||||
getTempDir,
|
||||
formatCookieHeader,
|
||||
} from '../../download/index.js';
|
||||
import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
|
||||
|
||||
@@ -62,9 +63,8 @@ async function mapConcurrent<T, R>(
|
||||
*/
|
||||
async function extractBrowserCookies(page: IPage, domain?: string): Promise<string> {
|
||||
try {
|
||||
// Use browser evaluate to get document.cookie
|
||||
const cookieString = await page.evaluate(`(() => document.cookie)()`);
|
||||
return typeof cookieString === 'string' ? cookieString : '';
|
||||
const cookies = await page.getCookies(domain ? { domain } : {});
|
||||
return formatCookieHeader(cookies);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
@@ -78,20 +78,17 @@ async function extractCookiesArray(
|
||||
domain: string,
|
||||
): Promise<Array<{ name: string; value: string; domain: string; path: string; secure: boolean; httpOnly: boolean }>> {
|
||||
try {
|
||||
const cookieString = await extractBrowserCookies(page);
|
||||
if (!cookieString) return [];
|
||||
|
||||
return cookieString.split(';').map((c) => {
|
||||
const [name, ...rest] = c.trim().split('=');
|
||||
return {
|
||||
name: name || '',
|
||||
value: rest.join('=') || '',
|
||||
domain,
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: false,
|
||||
};
|
||||
}).filter((c) => c.name);
|
||||
const cookies = await page.getCookies({ domain });
|
||||
return cookies
|
||||
.filter((cookie) => cookie.name)
|
||||
.map((cookie) => ({
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
path: cookie.path ?? '/',
|
||||
secure: cookie.secure ?? false,
|
||||
httpOnly: cookie.httpOnly ?? false,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
export interface IPage {
|
||||
goto(url: string): Promise<void>;
|
||||
evaluate(js: string): Promise<any>;
|
||||
getCookies(opts?: { domain?: string; url?: string }): Promise<Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path?: string;
|
||||
secure?: boolean;
|
||||
httpOnly?: boolean;
|
||||
expirationDate?: number;
|
||||
}>>;
|
||||
snapshot(opts?: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean }): Promise<any>;
|
||||
click(ref: string): Promise<void>;
|
||||
typeText(ref: string, text: string): Promise<void>;
|
||||
|
||||
+64
-3
@@ -7,6 +7,9 @@
|
||||
*/
|
||||
|
||||
import { validateClisWithTarget, renderValidationReport, type ValidationReport } from './validate.js';
|
||||
import { spawn } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export interface VerifyOptions {
|
||||
builtinClis: string;
|
||||
@@ -18,15 +21,73 @@ export interface VerifyOptions {
|
||||
export interface VerifyReport {
|
||||
ok: boolean;
|
||||
validation: ValidationReport;
|
||||
smoke: null;
|
||||
smoke: null | {
|
||||
requested: boolean;
|
||||
executed: boolean;
|
||||
ok: boolean;
|
||||
summary: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyClis(opts: VerifyOptions): Promise<VerifyReport> {
|
||||
const report = validateClisWithTarget([opts.builtinClis, opts.userClis], opts.target);
|
||||
return { ok: report.ok, validation: report, smoke: null };
|
||||
let smoke: VerifyReport['smoke'] = null;
|
||||
if (opts.smoke) {
|
||||
smoke = await runSmokeTests(opts.builtinClis);
|
||||
}
|
||||
return { ok: report.ok && (smoke?.ok ?? true), validation: report, smoke };
|
||||
}
|
||||
|
||||
export function renderVerifyReport(report: VerifyReport): string {
|
||||
return renderValidationReport(report.validation);
|
||||
const base = renderValidationReport(report.validation);
|
||||
if (!report.smoke) return base;
|
||||
const status = report.smoke.ok ? 'PASS' : 'FAIL';
|
||||
const mode = report.smoke.executed ? 'executed' : 'skipped';
|
||||
return `${base}\nSmoke: ${status} (${mode}) — ${report.smoke.summary}`;
|
||||
}
|
||||
|
||||
async function runSmokeTests(builtinClis: string): Promise<NonNullable<VerifyReport['smoke']>> {
|
||||
const projectRoot = path.resolve(builtinClis, '..', '..');
|
||||
const smokeDir = path.join(projectRoot, 'tests', 'smoke');
|
||||
|
||||
if (!fs.existsSync(smokeDir)) {
|
||||
return {
|
||||
requested: true,
|
||||
executed: false,
|
||||
ok: false,
|
||||
summary: 'Smoke tests are unavailable in this package/environment.',
|
||||
};
|
||||
}
|
||||
|
||||
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(npx, ['vitest', 'run', 'tests/smoke/', '--reporter=dot'], {
|
||||
cwd: projectRoot,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
resolve({
|
||||
requested: true,
|
||||
executed: false,
|
||||
ok: false,
|
||||
summary: `Failed to start smoke tests: ${error.message}`,
|
||||
});
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve({
|
||||
requested: true,
|
||||
executed: true,
|
||||
ok: code === 0,
|
||||
summary: code === 0 ? 'tests/smoke passed' : (stderr.trim() || `vitest exited with code ${code}`),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user