@jackwener/opencli 1.5.6 → 1.5.8
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/CHANGELOG.md +34 -0
- package/README.md +4 -2
- package/README.zh-CN.md +4 -1
- package/SKILL.md +879 -0
- package/dist/browser/cdp.d.ts +1 -0
- package/dist/browser/cdp.js +30 -27
- package/dist/browser/daemon-client.d.ts +7 -1
- package/dist/browser/daemon-client.js +3 -0
- package/dist/browser/dom-helpers.js +1 -0
- package/dist/browser/dom-helpers.test.js +14 -1
- package/dist/browser/mcp.js +18 -13
- package/dist/browser/page.js +22 -2
- package/dist/browser/page.test.d.ts +1 -0
- package/dist/browser/page.test.js +44 -0
- package/dist/browser/stealth.js +198 -0
- package/dist/browser/stealth.test.d.ts +1 -0
- package/dist/browser/stealth.test.js +134 -0
- package/dist/browser.test.js +1 -1
- package/dist/build-manifest.d.ts +1 -0
- package/dist/build-manifest.js +5 -1
- package/dist/build-manifest.test.js +2 -0
- package/dist/cli-manifest.json +544 -137
- package/dist/cli.js +20 -3
- package/dist/clis/antigravity/serve.d.ts +1 -1
- package/dist/clis/antigravity/serve.js +5 -8
- package/dist/clis/bilibili/subtitle.js +4 -0
- package/dist/clis/bilibili/subtitle.test.d.ts +1 -0
- package/dist/clis/bilibili/subtitle.test.js +48 -0
- package/dist/clis/chatwise/ask.js +0 -2
- package/dist/clis/chatwise/export.js +0 -2
- package/dist/clis/chatwise/history.js +0 -2
- package/dist/clis/chatwise/model.js +0 -2
- package/dist/clis/chatwise/new.js +1 -2
- package/dist/clis/chatwise/read.js +0 -2
- package/dist/clis/chatwise/screenshot.js +1 -2
- package/dist/clis/chatwise/send.js +0 -2
- package/dist/clis/chatwise/status.js +1 -2
- package/dist/clis/ctrip/search.d.ts +13 -0
- package/dist/clis/ctrip/search.js +73 -48
- package/dist/clis/ctrip/search.test.d.ts +1 -0
- package/dist/clis/ctrip/search.test.js +64 -0
- package/dist/clis/douyin/_shared/sts2.js +8 -2
- package/dist/clis/douyin/_shared/sts2.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/sts2.test.js +27 -0
- package/dist/clis/douyin/activities.js +4 -2
- package/dist/clis/douyin/activities.test.js +34 -1
- package/dist/clis/douyin/collections.js +1 -1
- package/dist/clis/douyin/collections.test.js +24 -2
- package/dist/clis/douyin/draft.d.ts +8 -11
- package/dist/clis/douyin/draft.js +302 -185
- package/dist/clis/douyin/draft.test.d.ts +1 -1
- package/dist/clis/douyin/draft.test.js +357 -2
- package/dist/clis/douyin/hashtag.js +9 -2
- package/dist/clis/douyin/hashtag.test.js +35 -2
- package/dist/clis/douyin/profile.js +1 -1
- package/dist/clis/douyin/profile.test.js +36 -1
- package/dist/clis/douyin/videos.js +22 -5
- package/dist/clis/douyin/videos.test.js +45 -2
- package/dist/clis/facebook/search.test.d.ts +5 -0
- package/dist/clis/facebook/search.test.js +60 -0
- package/dist/clis/facebook/search.yaml +4 -3
- package/dist/clis/instagram/download.d.ts +16 -0
- package/dist/clis/instagram/download.js +225 -0
- package/dist/clis/instagram/download.test.d.ts +1 -0
- package/dist/clis/instagram/download.test.js +118 -0
- package/dist/clis/notebooklm/bind-current.d.ts +1 -0
- package/dist/clis/notebooklm/bind-current.js +29 -0
- package/dist/clis/notebooklm/bind-current.test.d.ts +1 -0
- package/dist/clis/notebooklm/bind-current.test.js +35 -0
- package/dist/clis/notebooklm/binding.test.d.ts +1 -0
- package/dist/clis/notebooklm/binding.test.js +44 -0
- package/dist/clis/notebooklm/compat.test.d.ts +3 -0
- package/dist/clis/notebooklm/compat.test.js +16 -0
- package/dist/clis/notebooklm/current.d.ts +1 -0
- package/dist/clis/notebooklm/current.js +28 -0
- package/dist/clis/notebooklm/get.d.ts +1 -0
- package/dist/clis/notebooklm/get.js +37 -0
- package/dist/clis/notebooklm/history.d.ts +1 -0
- package/dist/clis/notebooklm/history.js +25 -0
- package/dist/clis/notebooklm/history.test.d.ts +1 -0
- package/dist/clis/notebooklm/history.test.js +58 -0
- package/dist/clis/notebooklm/list.d.ts +1 -0
- package/dist/clis/notebooklm/list.js +35 -0
- package/dist/clis/notebooklm/note-list.d.ts +1 -0
- package/dist/clis/notebooklm/note-list.js +28 -0
- package/dist/clis/notebooklm/note-list.test.d.ts +1 -0
- package/dist/clis/notebooklm/note-list.test.js +56 -0
- package/dist/clis/notebooklm/notes-get.d.ts +1 -0
- package/dist/clis/notebooklm/notes-get.js +47 -0
- package/dist/clis/notebooklm/notes-get.test.d.ts +1 -0
- package/dist/clis/notebooklm/notes-get.test.js +72 -0
- package/dist/clis/notebooklm/rpc.d.ts +36 -0
- package/dist/clis/notebooklm/rpc.js +189 -0
- package/dist/clis/notebooklm/rpc.test.d.ts +1 -0
- package/dist/clis/notebooklm/rpc.test.js +105 -0
- package/dist/clis/notebooklm/shared.d.ts +87 -0
- package/dist/clis/notebooklm/shared.js +3 -0
- package/dist/clis/notebooklm/source-fulltext.d.ts +1 -0
- package/dist/clis/notebooklm/source-fulltext.js +44 -0
- package/dist/clis/notebooklm/source-fulltext.test.d.ts +1 -0
- package/dist/clis/notebooklm/source-fulltext.test.js +106 -0
- package/dist/clis/notebooklm/source-get.d.ts +1 -0
- package/dist/clis/notebooklm/source-get.js +40 -0
- package/dist/clis/notebooklm/source-get.test.d.ts +1 -0
- package/dist/clis/notebooklm/source-get.test.js +84 -0
- package/dist/clis/notebooklm/source-guide.d.ts +1 -0
- package/dist/clis/notebooklm/source-guide.js +44 -0
- package/dist/clis/notebooklm/source-guide.test.d.ts +1 -0
- package/dist/clis/notebooklm/source-guide.test.js +104 -0
- package/dist/clis/notebooklm/source-list.d.ts +1 -0
- package/dist/clis/notebooklm/source-list.js +30 -0
- package/dist/clis/notebooklm/status.d.ts +1 -0
- package/dist/clis/notebooklm/status.js +31 -0
- package/dist/clis/notebooklm/summary.d.ts +1 -0
- package/dist/clis/notebooklm/summary.js +30 -0
- package/dist/clis/notebooklm/summary.test.d.ts +1 -0
- package/dist/clis/notebooklm/summary.test.js +78 -0
- package/dist/clis/notebooklm/utils.d.ts +37 -0
- package/dist/clis/notebooklm/utils.js +739 -0
- package/dist/clis/notebooklm/utils.test.d.ts +1 -0
- package/dist/clis/notebooklm/utils.test.js +390 -0
- package/dist/clis/substack/utils.d.ts +4 -0
- package/dist/clis/substack/utils.js +8 -2
- package/dist/clis/substack/utils.test.d.ts +1 -0
- package/dist/clis/substack/utils.test.js +46 -0
- package/dist/clis/v2ex/hot.yaml +4 -1
- package/dist/clis/v2ex/latest.yaml +4 -1
- package/dist/clis/v2ex/topic.yaml +6 -1
- package/dist/clis/weixin/download.d.ts +9 -0
- package/dist/clis/weixin/download.js +76 -6
- package/dist/clis/weread/book.js +108 -2
- package/dist/clis/weread/commands.test.js +262 -152
- package/dist/clis/weread/utils.d.ts +10 -0
- package/dist/clis/weread/utils.js +27 -7
- package/dist/clis/xiaohongshu/comments.d.ts +3 -0
- package/dist/clis/xiaohongshu/comments.js +76 -17
- package/dist/clis/xiaohongshu/comments.test.js +70 -9
- package/dist/clis/xiaohongshu/download.d.ts +4 -1
- package/dist/clis/xiaohongshu/download.js +83 -22
- package/dist/clis/xiaohongshu/download.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/download.test.js +75 -0
- package/dist/clis/xiaohongshu/note-helpers.d.ts +12 -0
- package/dist/clis/xiaohongshu/note-helpers.js +23 -0
- package/dist/clis/xiaohongshu/note.d.ts +7 -0
- package/dist/clis/xiaohongshu/note.js +76 -0
- package/dist/clis/xiaohongshu/note.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/note.test.js +136 -0
- package/dist/clis/xiaohongshu/search.js +9 -0
- package/dist/clis/xiaohongshu/search.test.js +10 -4
- package/dist/clis/youtube/search.js +57 -17
- package/dist/clis/zhihu/question.js +19 -17
- package/dist/clis/zhihu/question.test.d.ts +1 -0
- package/dist/clis/zhihu/question.test.js +54 -0
- package/dist/commanderAdapter.js +9 -0
- package/dist/commanderAdapter.test.js +25 -0
- package/dist/commands/daemon.d.ts +9 -0
- package/dist/commands/daemon.js +124 -0
- package/dist/commands/daemon.test.d.ts +1 -0
- package/dist/commands/daemon.test.js +185 -0
- package/dist/completion.js +3 -1
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/daemon.d.ts +1 -1
- package/dist/daemon.js +25 -14
- package/dist/daemon.test.d.ts +1 -0
- package/dist/daemon.test.js +65 -0
- package/dist/discovery.d.ts +9 -0
- package/dist/discovery.js +47 -2
- package/dist/electron-apps.d.ts +29 -0
- package/dist/electron-apps.js +65 -0
- package/dist/electron-apps.test.d.ts +1 -0
- package/dist/electron-apps.test.js +43 -0
- package/dist/engine.test.js +41 -9
- package/dist/execution.js +20 -16
- package/dist/extension-manifest-regression.test.js +1 -0
- package/dist/idle-manager.d.ts +19 -0
- package/dist/idle-manager.js +54 -0
- package/dist/launcher.d.ts +36 -0
- package/dist/launcher.js +152 -0
- package/dist/launcher.test.d.ts +1 -0
- package/dist/launcher.test.js +57 -0
- package/dist/main.js +3 -3
- package/dist/registry.d.ts +1 -0
- package/dist/registry.js +31 -3
- package/dist/registry.test.js +13 -0
- package/dist/runtime.d.ts +5 -3
- package/dist/runtime.js +12 -5
- package/dist/serialization.d.ts +1 -0
- package/dist/serialization.js +3 -0
- package/dist/serialization.test.js +17 -1
- package/dist/tui.d.ts +7 -0
- package/dist/tui.js +52 -0
- package/dist/tui.test.d.ts +1 -0
- package/dist/tui.test.js +19 -0
- package/dist/weixin-download.test.js +14 -0
- package/docs/.vitepress/config.mts +1 -0
- package/docs/adapters/browser/notebooklm.md +69 -0
- package/docs/adapters/browser/xiaohongshu.md +19 -10
- package/docs/adapters/index.md +67 -66
- package/docs/guide/browser-bridge.md +12 -0
- package/docs/guide/troubleshooting.md +9 -4
- package/docs/superpowers/plans/2026-03-31-daemon-lifecycle-redesign.md +857 -0
- package/docs/superpowers/specs/2026-03-31-daemon-lifecycle-redesign.md +208 -0
- package/docs/zh/guide/browser-bridge.md +12 -0
- package/extension/dist/background.js +250 -11
- package/extension/manifest.json +2 -1
- package/extension/src/background.test.ts +202 -2
- package/extension/src/background.ts +175 -10
- package/extension/src/cdp.test.ts +75 -0
- package/extension/src/cdp.ts +89 -3
- package/extension/src/protocol.ts +7 -5
- package/package.json +1 -1
- package/src/browser/cdp.ts +24 -17
- package/src/browser/daemon-client.ts +7 -1
- package/src/browser/dom-helpers.test.ts +15 -1
- package/src/browser/dom-helpers.ts +1 -0
- package/src/browser/mcp.ts +18 -13
- package/src/browser/page.test.ts +58 -0
- package/src/browser/page.ts +18 -2
- package/src/browser/stealth.test.ts +153 -0
- package/src/browser/stealth.ts +198 -0
- package/src/browser.test.ts +1 -1
- package/src/build-manifest.test.ts +2 -0
- package/src/build-manifest.ts +6 -1
- package/src/cli.ts +21 -3
- package/src/clis/antigravity/SKILL.md +3 -12
- package/src/clis/antigravity/serve.ts +5 -10
- package/src/clis/bilibili/subtitle.test.ts +60 -0
- package/src/clis/bilibili/subtitle.ts +4 -0
- package/src/clis/chatwise/ask.ts +0 -2
- package/src/clis/chatwise/export.ts +0 -2
- package/src/clis/chatwise/history.ts +0 -2
- package/src/clis/chatwise/model.ts +0 -2
- package/src/clis/chatwise/new.ts +1 -2
- package/src/clis/chatwise/read.ts +0 -2
- package/src/clis/chatwise/screenshot.ts +1 -2
- package/src/clis/chatwise/send.ts +0 -2
- package/src/clis/chatwise/status.ts +1 -2
- package/src/clis/ctrip/search.test.ts +73 -0
- package/src/clis/ctrip/search.ts +97 -47
- package/src/clis/douyin/_shared/sts2.test.ts +31 -0
- package/src/clis/douyin/_shared/sts2.ts +11 -3
- package/src/clis/douyin/activities.test.ts +41 -1
- package/src/clis/douyin/activities.ts +12 -3
- package/src/clis/douyin/collections.test.ts +35 -2
- package/src/clis/douyin/collections.ts +1 -1
- package/src/clis/douyin/draft.test.ts +444 -2
- package/src/clis/douyin/draft.ts +382 -218
- package/src/clis/douyin/hashtag.test.ts +42 -2
- package/src/clis/douyin/hashtag.ts +11 -3
- package/src/clis/douyin/profile.test.ts +43 -1
- package/src/clis/douyin/profile.ts +9 -2
- package/src/clis/douyin/videos.test.ts +52 -2
- package/src/clis/douyin/videos.ts +49 -15
- package/src/clis/facebook/search.test.ts +70 -0
- package/src/clis/facebook/search.yaml +4 -3
- package/src/clis/instagram/download.test.ts +159 -0
- package/src/clis/instagram/download.ts +286 -0
- package/src/clis/notebooklm/bind-current.test.ts +43 -0
- package/src/clis/notebooklm/bind-current.ts +36 -0
- package/src/clis/notebooklm/binding.test.ts +53 -0
- package/src/clis/notebooklm/compat.test.ts +19 -0
- package/src/clis/notebooklm/current.ts +38 -0
- package/src/clis/notebooklm/get.ts +53 -0
- package/src/clis/notebooklm/history.test.ts +70 -0
- package/src/clis/notebooklm/history.ts +36 -0
- package/src/clis/notebooklm/list.ts +40 -0
- package/src/clis/notebooklm/note-list.test.ts +64 -0
- package/src/clis/notebooklm/note-list.ts +42 -0
- package/src/clis/notebooklm/notes-get.test.ts +88 -0
- package/src/clis/notebooklm/notes-get.ts +67 -0
- package/src/clis/notebooklm/rpc.test.ts +126 -0
- package/src/clis/notebooklm/rpc.ts +286 -0
- package/src/clis/notebooklm/shared.ts +98 -0
- package/src/clis/notebooklm/source-fulltext.test.ts +123 -0
- package/src/clis/notebooklm/source-fulltext.ts +69 -0
- package/src/clis/notebooklm/source-get.test.ts +100 -0
- package/src/clis/notebooklm/source-get.ts +60 -0
- package/src/clis/notebooklm/source-guide.test.ts +121 -0
- package/src/clis/notebooklm/source-guide.ts +69 -0
- package/src/clis/notebooklm/source-list.ts +45 -0
- package/src/clis/notebooklm/status.ts +34 -0
- package/src/clis/notebooklm/summary.test.ts +94 -0
- package/src/clis/notebooklm/summary.ts +45 -0
- package/src/clis/notebooklm/utils.test.ts +446 -0
- package/src/clis/notebooklm/utils.ts +893 -0
- package/src/clis/substack/utils.test.ts +54 -0
- package/src/clis/substack/utils.ts +10 -2
- package/src/clis/v2ex/hot.yaml +4 -1
- package/src/clis/v2ex/latest.yaml +4 -1
- package/src/clis/v2ex/topic.yaml +6 -1
- package/src/clis/weixin/download.ts +95 -6
- package/src/clis/weread/book.ts +142 -2
- package/src/clis/weread/commands.test.ts +314 -154
- package/src/clis/weread/utils.ts +33 -4
- package/src/clis/xiaohongshu/comments.test.ts +85 -9
- package/src/clis/xiaohongshu/comments.ts +76 -17
- package/src/clis/xiaohongshu/download.test.ts +96 -0
- package/src/clis/xiaohongshu/download.ts +83 -22
- package/src/clis/xiaohongshu/note-helpers.ts +25 -0
- package/src/clis/xiaohongshu/note.test.ts +164 -0
- package/src/clis/xiaohongshu/note.ts +86 -0
- package/src/clis/xiaohongshu/search.test.ts +11 -4
- package/src/clis/xiaohongshu/search.ts +13 -0
- package/src/clis/youtube/search.ts +57 -17
- package/src/clis/zhihu/question.test.ts +71 -0
- package/src/clis/zhihu/question.ts +27 -15
- package/src/commanderAdapter.test.ts +30 -0
- package/src/commanderAdapter.ts +7 -0
- package/src/commands/daemon.test.ts +238 -0
- package/src/commands/daemon.ts +135 -0
- package/src/completion.ts +2 -1
- package/src/constants.ts +3 -0
- package/src/daemon.test.ts +88 -0
- package/src/daemon.ts +26 -14
- package/src/discovery.ts +52 -2
- package/src/electron-apps.test.ts +50 -0
- package/src/electron-apps.ts +89 -0
- package/src/engine.test.ts +45 -9
- package/src/execution.ts +24 -19
- package/src/extension-manifest-regression.test.ts +1 -0
- package/src/idle-manager.ts +60 -0
- package/src/launcher.test.ts +67 -0
- package/src/launcher.ts +185 -0
- package/src/main.ts +3 -2
- package/src/registry.test.ts +15 -0
- package/src/registry.ts +32 -3
- package/src/runtime.ts +13 -7
- package/src/serialization.test.ts +19 -1
- package/src/serialization.ts +2 -0
- package/src/tui.test.ts +23 -0
- package/src/tui.ts +65 -0
- package/src/weixin-download.test.ts +27 -0
- package/tests/e2e/browser-public-extended.test.ts +6 -2
- package/chatwise-opencli.ps1 +0 -82
- package/dist/clis/chatwise/shared.d.ts +0 -2
- package/dist/clis/chatwise/shared.js +0 -6
- package/src/clis/chatwise/shared.ts +0 -8
|
@@ -35,8 +35,12 @@ function createChromeMock() {
|
|
|
35
35
|
{ id: 3, windowId: 1, url: 'chrome://extensions', title: 'chrome', active: false, status: 'complete' },
|
|
36
36
|
];
|
|
37
37
|
|
|
38
|
-
const query = vi.fn(async (queryInfo: { windowId?: number } = {}) => {
|
|
39
|
-
return tabs.filter((tab) =>
|
|
38
|
+
const query = vi.fn(async (queryInfo: { windowId?: number; active?: boolean } = {}) => {
|
|
39
|
+
return tabs.filter((tab) => {
|
|
40
|
+
if (queryInfo.windowId !== undefined && tab.windowId !== queryInfo.windowId) return false;
|
|
41
|
+
if (queryInfo.active !== undefined && !!tab.active !== queryInfo.active) return false;
|
|
42
|
+
return true;
|
|
43
|
+
});
|
|
40
44
|
});
|
|
41
45
|
const create = vi.fn(async ({ windowId, url, active }: { windowId?: number; url?: string; active?: boolean }) => {
|
|
42
46
|
const tab: MockTab = {
|
|
@@ -84,6 +88,8 @@ function createChromeMock() {
|
|
|
84
88
|
runtime: {
|
|
85
89
|
onInstalled: { addListener: vi.fn() } as Listener<() => void>,
|
|
86
90
|
onStartup: { addListener: vi.fn() } as Listener<() => void>,
|
|
91
|
+
onMessage: { addListener: vi.fn() } as Listener<(msg: unknown, sender: unknown, sendResponse: (value: unknown) => void) => void>,
|
|
92
|
+
getManifest: vi.fn(() => ({ version: 'test-version' })),
|
|
87
93
|
},
|
|
88
94
|
cookies: {
|
|
89
95
|
getAll: vi.fn(async () => []),
|
|
@@ -193,4 +199,198 @@ describe('background tab isolation', () => {
|
|
|
193
199
|
expect.objectContaining({ workspace: 'site:zhihu', windowId: 2 }),
|
|
194
200
|
]));
|
|
195
201
|
});
|
|
202
|
+
|
|
203
|
+
it('rebinds site:notebooklm to the active notebook tab instead of a home tab', async () => {
|
|
204
|
+
const { chrome, tabs } = createChromeMock();
|
|
205
|
+
tabs[0].url = 'https://notebooklm.google.com/';
|
|
206
|
+
tabs[0].title = 'NotebookLM Home';
|
|
207
|
+
tabs[1].url = 'https://notebooklm.google.com/notebook/nb-live';
|
|
208
|
+
tabs[1].title = 'Live Notebook';
|
|
209
|
+
vi.stubGlobal('chrome', chrome);
|
|
210
|
+
|
|
211
|
+
const mod = await import('./background');
|
|
212
|
+
mod.__test__.setAutomationWindowId('site:notebooklm', 1);
|
|
213
|
+
|
|
214
|
+
const tabId = await mod.__test__.resolveTabId(undefined, 'site:notebooklm');
|
|
215
|
+
|
|
216
|
+
expect(tabId).toBe(2);
|
|
217
|
+
expect(mod.__test__.getSession('site:notebooklm')).toEqual(expect.objectContaining({
|
|
218
|
+
windowId: 2,
|
|
219
|
+
preferredTabId: 2,
|
|
220
|
+
owned: false,
|
|
221
|
+
}));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('prefers a notebook tab over an active home tab for site:notebooklm', async () => {
|
|
225
|
+
const { chrome, tabs } = createChromeMock();
|
|
226
|
+
tabs[0].url = 'https://notebooklm.google.com/';
|
|
227
|
+
tabs[0].title = 'NotebookLM Home';
|
|
228
|
+
tabs[0].active = true;
|
|
229
|
+
tabs[1].url = 'https://notebooklm.google.com/notebook/nb-passive';
|
|
230
|
+
tabs[1].title = 'Notebook';
|
|
231
|
+
tabs[1].active = false;
|
|
232
|
+
vi.stubGlobal('chrome', chrome);
|
|
233
|
+
|
|
234
|
+
const mod = await import('./background');
|
|
235
|
+
mod.__test__.setAutomationWindowId('site:notebooklm', 1);
|
|
236
|
+
|
|
237
|
+
const tabId = await mod.__test__.resolveTabId(undefined, 'site:notebooklm');
|
|
238
|
+
|
|
239
|
+
expect(tabId).toBe(2);
|
|
240
|
+
expect(mod.__test__.getSession('site:notebooklm')).toEqual(expect.objectContaining({
|
|
241
|
+
windowId: 2,
|
|
242
|
+
preferredTabId: 2,
|
|
243
|
+
owned: false,
|
|
244
|
+
}));
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('detaches an adopted workspace session on idle instead of closing the user window', async () => {
|
|
248
|
+
const { chrome } = createChromeMock();
|
|
249
|
+
vi.stubGlobal('chrome', chrome);
|
|
250
|
+
vi.useFakeTimers();
|
|
251
|
+
|
|
252
|
+
const mod = await import('./background');
|
|
253
|
+
mod.__test__.setSession('site:notebooklm', {
|
|
254
|
+
windowId: 2,
|
|
255
|
+
preferredTabId: 2,
|
|
256
|
+
owned: false,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
mod.__test__.resetWindowIdleTimer('site:notebooklm');
|
|
260
|
+
await vi.advanceTimersByTimeAsync(30001);
|
|
261
|
+
|
|
262
|
+
expect(chrome.windows.remove).not.toHaveBeenCalled();
|
|
263
|
+
expect(mod.__test__.getSession('site:notebooklm')).toBeNull();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('binds the active NotebookLM tab into the workspace explicitly', async () => {
|
|
267
|
+
const { chrome, tabs } = createChromeMock();
|
|
268
|
+
tabs[1].url = 'https://notebooklm.google.com/notebook/nb-active';
|
|
269
|
+
tabs[1].title = 'Bound Notebook';
|
|
270
|
+
tabs[1].active = true;
|
|
271
|
+
vi.stubGlobal('chrome', chrome);
|
|
272
|
+
|
|
273
|
+
const mod = await import('./background');
|
|
274
|
+
const result = await mod.__test__.handleBindCurrent(
|
|
275
|
+
{
|
|
276
|
+
id: 'bind-current',
|
|
277
|
+
action: 'bind-current',
|
|
278
|
+
workspace: 'site:notebooklm',
|
|
279
|
+
matchDomain: 'notebooklm.google.com',
|
|
280
|
+
matchPathPrefix: '/notebook/',
|
|
281
|
+
},
|
|
282
|
+
'site:notebooklm',
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
expect(result).toEqual({
|
|
286
|
+
id: 'bind-current',
|
|
287
|
+
ok: true,
|
|
288
|
+
data: expect.objectContaining({
|
|
289
|
+
tabId: 2,
|
|
290
|
+
windowId: 2,
|
|
291
|
+
url: 'https://notebooklm.google.com/notebook/nb-active',
|
|
292
|
+
title: 'Bound Notebook',
|
|
293
|
+
workspace: 'site:notebooklm',
|
|
294
|
+
}),
|
|
295
|
+
});
|
|
296
|
+
expect(mod.__test__.getSession('site:notebooklm')).toEqual(expect.objectContaining({
|
|
297
|
+
windowId: 2,
|
|
298
|
+
preferredTabId: 2,
|
|
299
|
+
owned: false,
|
|
300
|
+
}));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('bind-current falls back to another matching notebook tab in the current window', async () => {
|
|
304
|
+
const { chrome, tabs } = createChromeMock();
|
|
305
|
+
tabs[0].windowId = 2;
|
|
306
|
+
tabs[0].url = 'https://notebooklm.google.com/';
|
|
307
|
+
tabs[0].title = 'NotebookLM Home';
|
|
308
|
+
tabs[0].active = true;
|
|
309
|
+
tabs[1].url = 'https://notebooklm.google.com/notebook/nb-passive';
|
|
310
|
+
tabs[1].title = 'Passive Notebook';
|
|
311
|
+
tabs[1].active = false;
|
|
312
|
+
vi.stubGlobal('chrome', chrome);
|
|
313
|
+
|
|
314
|
+
const mod = await import('./background');
|
|
315
|
+
const result = await mod.__test__.handleBindCurrent(
|
|
316
|
+
{
|
|
317
|
+
id: 'bind-fallback',
|
|
318
|
+
action: 'bind-current',
|
|
319
|
+
workspace: 'site:notebooklm',
|
|
320
|
+
matchDomain: 'notebooklm.google.com',
|
|
321
|
+
matchPathPrefix: '/notebook/',
|
|
322
|
+
},
|
|
323
|
+
'site:notebooklm',
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
expect(result).toEqual({
|
|
327
|
+
id: 'bind-fallback',
|
|
328
|
+
ok: true,
|
|
329
|
+
data: expect.objectContaining({
|
|
330
|
+
tabId: 2,
|
|
331
|
+
windowId: 2,
|
|
332
|
+
url: 'https://notebooklm.google.com/notebook/nb-passive',
|
|
333
|
+
title: 'Passive Notebook',
|
|
334
|
+
}),
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('bind-current falls back to a matching notebook tab in another window of the same profile', async () => {
|
|
339
|
+
const { chrome, tabs } = createChromeMock();
|
|
340
|
+
tabs[0].windowId = 3;
|
|
341
|
+
tabs[0].url = 'https://notebooklm.google.com/';
|
|
342
|
+
tabs[0].title = 'NotebookLM Home';
|
|
343
|
+
tabs[0].active = true;
|
|
344
|
+
tabs[1].windowId = 2;
|
|
345
|
+
tabs[1].url = 'https://notebooklm.google.com/notebook/nb-other-window';
|
|
346
|
+
tabs[1].title = 'Notebook In Other Window';
|
|
347
|
+
tabs[1].active = false;
|
|
348
|
+
vi.stubGlobal('chrome', chrome);
|
|
349
|
+
|
|
350
|
+
const mod = await import('./background');
|
|
351
|
+
const result = await mod.__test__.handleBindCurrent(
|
|
352
|
+
{
|
|
353
|
+
id: 'bind-cross-window',
|
|
354
|
+
action: 'bind-current',
|
|
355
|
+
workspace: 'site:notebooklm',
|
|
356
|
+
matchDomain: 'notebooklm.google.com',
|
|
357
|
+
matchPathPrefix: '/notebook/',
|
|
358
|
+
},
|
|
359
|
+
'site:notebooklm',
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
expect(result).toEqual({
|
|
363
|
+
id: 'bind-cross-window',
|
|
364
|
+
ok: true,
|
|
365
|
+
data: expect.objectContaining({
|
|
366
|
+
tabId: 2,
|
|
367
|
+
windowId: 2,
|
|
368
|
+
url: 'https://notebooklm.google.com/notebook/nb-other-window',
|
|
369
|
+
title: 'Notebook In Other Window',
|
|
370
|
+
}),
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('rejects bind-current when the active tab is not NotebookLM', async () => {
|
|
375
|
+
const { chrome } = createChromeMock();
|
|
376
|
+
vi.stubGlobal('chrome', chrome);
|
|
377
|
+
|
|
378
|
+
const mod = await import('./background');
|
|
379
|
+
const result = await mod.__test__.handleBindCurrent(
|
|
380
|
+
{
|
|
381
|
+
id: 'bind-miss',
|
|
382
|
+
action: 'bind-current',
|
|
383
|
+
workspace: 'site:notebooklm',
|
|
384
|
+
matchDomain: 'notebooklm.google.com',
|
|
385
|
+
matchPathPrefix: '/notebook/',
|
|
386
|
+
},
|
|
387
|
+
'site:notebooklm',
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
expect(result).toEqual({
|
|
391
|
+
id: 'bind-miss',
|
|
392
|
+
ok: false,
|
|
393
|
+
error: 'No visible tab matching notebooklm.google.com /notebook/',
|
|
394
|
+
});
|
|
395
|
+
});
|
|
196
396
|
});
|
|
@@ -117,6 +117,8 @@ type AutomationSession = {
|
|
|
117
117
|
windowId: number;
|
|
118
118
|
idleTimer: ReturnType<typeof setTimeout> | null;
|
|
119
119
|
idleDeadlineAt: number;
|
|
120
|
+
owned: boolean;
|
|
121
|
+
preferredTabId: number | null;
|
|
120
122
|
};
|
|
121
123
|
|
|
122
124
|
const automationSessions = new Map<string, AutomationSession>();
|
|
@@ -134,6 +136,11 @@ function resetWindowIdleTimer(workspace: string): void {
|
|
|
134
136
|
session.idleTimer = setTimeout(async () => {
|
|
135
137
|
const current = automationSessions.get(workspace);
|
|
136
138
|
if (!current) return;
|
|
139
|
+
if (!current.owned) {
|
|
140
|
+
console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`);
|
|
141
|
+
automationSessions.delete(workspace);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
137
144
|
try {
|
|
138
145
|
await chrome.windows.remove(current.windowId);
|
|
139
146
|
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
|
|
@@ -173,6 +180,8 @@ async function getAutomationWindow(workspace: string): Promise<number> {
|
|
|
173
180
|
windowId: win.id!,
|
|
174
181
|
idleTimer: null,
|
|
175
182
|
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT,
|
|
183
|
+
owned: true,
|
|
184
|
+
preferredTabId: null,
|
|
176
185
|
};
|
|
177
186
|
automationSessions.set(workspace, session);
|
|
178
187
|
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
|
|
@@ -254,6 +263,8 @@ async function handleCommand(cmd: Command): Promise<Result> {
|
|
|
254
263
|
return await handleSessions(cmd);
|
|
255
264
|
case 'set-file-input':
|
|
256
265
|
return await handleSetFileInput(cmd, workspace);
|
|
266
|
+
case 'bind-current':
|
|
267
|
+
return await handleBindCurrent(cmd, workspace);
|
|
257
268
|
default:
|
|
258
269
|
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
|
259
270
|
}
|
|
@@ -301,6 +312,89 @@ function isTargetUrl(currentUrl: string | undefined, targetUrl: string): boolean
|
|
|
301
312
|
return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
|
|
302
313
|
}
|
|
303
314
|
|
|
315
|
+
function matchesDomain(url: string | undefined, domain: string): boolean {
|
|
316
|
+
if (!url) return false;
|
|
317
|
+
try {
|
|
318
|
+
const parsed = new URL(url);
|
|
319
|
+
return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
|
|
320
|
+
} catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function matchesBindCriteria(tab: chrome.tabs.Tab, cmd: Command): boolean {
|
|
326
|
+
if (!tab.id || !isDebuggableUrl(tab.url)) return false;
|
|
327
|
+
if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false;
|
|
328
|
+
if (cmd.matchPathPrefix) {
|
|
329
|
+
try {
|
|
330
|
+
const parsed = new URL(tab.url!);
|
|
331
|
+
if (!parsed.pathname.startsWith(cmd.matchPathPrefix)) return false;
|
|
332
|
+
} catch {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function isNotebooklmWorkspace(workspace: string): boolean {
|
|
340
|
+
return workspace === 'site:notebooklm';
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function classifyNotebooklmUrl(url?: string): 'notebook' | 'home' | 'other' {
|
|
344
|
+
if (!url) return 'other';
|
|
345
|
+
try {
|
|
346
|
+
const parsed = new URL(url);
|
|
347
|
+
if (parsed.hostname !== 'notebooklm.google.com') return 'other';
|
|
348
|
+
return parsed.pathname.startsWith('/notebook/') ? 'notebook' : 'home';
|
|
349
|
+
} catch {
|
|
350
|
+
return 'other';
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function scoreWorkspaceTab(workspace: string, tab: chrome.tabs.Tab): number {
|
|
355
|
+
if (!tab.id || !isDebuggableUrl(tab.url)) return -1;
|
|
356
|
+
if (isNotebooklmWorkspace(workspace)) {
|
|
357
|
+
const kind = classifyNotebooklmUrl(tab.url);
|
|
358
|
+
if (kind === 'other') return -1;
|
|
359
|
+
if (kind === 'notebook') return tab.active ? 400 : 300;
|
|
360
|
+
return tab.active ? 200 : 100;
|
|
361
|
+
}
|
|
362
|
+
return -1;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function setWorkspaceSession(workspace: string, session: Omit<AutomationSession, 'idleTimer' | 'idleDeadlineAt'>): void {
|
|
366
|
+
const existing = automationSessions.get(workspace);
|
|
367
|
+
if (existing?.idleTimer) clearTimeout(existing.idleTimer);
|
|
368
|
+
automationSessions.set(workspace, {
|
|
369
|
+
...session,
|
|
370
|
+
idleTimer: null,
|
|
371
|
+
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function maybeBindWorkspaceToExistingTab(workspace: string): Promise<number | null> {
|
|
376
|
+
if (!isNotebooklmWorkspace(workspace)) return null;
|
|
377
|
+
const tabs = await chrome.tabs.query({});
|
|
378
|
+
let bestTab: chrome.tabs.Tab | null = null;
|
|
379
|
+
let bestScore = -1;
|
|
380
|
+
for (const tab of tabs) {
|
|
381
|
+
const score = scoreWorkspaceTab(workspace, tab);
|
|
382
|
+
if (score > bestScore) {
|
|
383
|
+
bestScore = score;
|
|
384
|
+
bestTab = tab;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (!bestTab?.id || bestScore < 0) return null;
|
|
388
|
+
setWorkspaceSession(workspace, {
|
|
389
|
+
windowId: bestTab.windowId,
|
|
390
|
+
owned: false,
|
|
391
|
+
preferredTabId: bestTab.id,
|
|
392
|
+
});
|
|
393
|
+
console.log(`[opencli] Workspace ${workspace} bound to existing tab ${bestTab.id} in window ${bestTab.windowId}`);
|
|
394
|
+
resetWindowIdleTimer(workspace);
|
|
395
|
+
return bestTab.id;
|
|
396
|
+
}
|
|
397
|
+
|
|
304
398
|
/**
|
|
305
399
|
* Resolve target tab in the automation window.
|
|
306
400
|
* If explicit tabId is given, use that directly.
|
|
@@ -314,9 +408,12 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
|
|
|
314
408
|
try {
|
|
315
409
|
const tab = await chrome.tabs.get(tabId);
|
|
316
410
|
const session = automationSessions.get(workspace);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
411
|
+
const matchesSession = session
|
|
412
|
+
? (session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId)
|
|
413
|
+
: false;
|
|
414
|
+
if (isDebuggableUrl(tab.url) && matchesSession) return tabId;
|
|
415
|
+
if (session && !matchesSession) {
|
|
416
|
+
console.warn(`[opencli] Tab ${tabId} is not bound to workspace ${workspace}, re-resolving`);
|
|
320
417
|
} else if (!isDebuggableUrl(tab.url)) {
|
|
321
418
|
// Tab exists but URL is not debuggable — fall through to auto-resolve
|
|
322
419
|
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
|
|
@@ -327,6 +424,20 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
|
|
|
327
424
|
}
|
|
328
425
|
}
|
|
329
426
|
|
|
427
|
+
const adoptedTabId = await maybeBindWorkspaceToExistingTab(workspace);
|
|
428
|
+
if (adoptedTabId !== null) return adoptedTabId;
|
|
429
|
+
|
|
430
|
+
const existingSession = automationSessions.get(workspace);
|
|
431
|
+
if (existingSession && existingSession.preferredTabId !== null) {
|
|
432
|
+
try {
|
|
433
|
+
const preferredTabId = existingSession.preferredTabId;
|
|
434
|
+
const preferredTab = await chrome.tabs.get(preferredTabId);
|
|
435
|
+
if (isDebuggableUrl(preferredTab.url)) return preferredTab.id!;
|
|
436
|
+
} catch {
|
|
437
|
+
automationSessions.delete(workspace);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
330
441
|
// Get (or create) the automation window
|
|
331
442
|
const windowId = await getAutomationWindow(workspace);
|
|
332
443
|
|
|
@@ -359,6 +470,14 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
|
|
|
359
470
|
async function listAutomationTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
|
|
360
471
|
const session = automationSessions.get(workspace);
|
|
361
472
|
if (!session) return [];
|
|
473
|
+
if (session.preferredTabId !== null) {
|
|
474
|
+
try {
|
|
475
|
+
return [await chrome.tabs.get(session.preferredTabId)];
|
|
476
|
+
} catch {
|
|
477
|
+
automationSessions.delete(workspace);
|
|
478
|
+
return [];
|
|
479
|
+
}
|
|
480
|
+
}
|
|
362
481
|
try {
|
|
363
482
|
return await chrome.tabs.query({ windowId: session.windowId });
|
|
364
483
|
} catch {
|
|
@@ -570,10 +689,12 @@ async function handleScreenshot(cmd: Command, workspace: string): Promise<Result
|
|
|
570
689
|
async function handleCloseWindow(cmd: Command, workspace: string): Promise<Result> {
|
|
571
690
|
const session = automationSessions.get(workspace);
|
|
572
691
|
if (session) {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
692
|
+
if (session.owned) {
|
|
693
|
+
try {
|
|
694
|
+
await chrome.windows.remove(session.windowId);
|
|
695
|
+
} catch {
|
|
696
|
+
// Window may already be closed
|
|
697
|
+
}
|
|
577
698
|
}
|
|
578
699
|
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
579
700
|
automationSessions.delete(workspace);
|
|
@@ -605,11 +726,52 @@ async function handleSessions(cmd: Command): Promise<Result> {
|
|
|
605
726
|
return { id: cmd.id, ok: true, data };
|
|
606
727
|
}
|
|
607
728
|
|
|
729
|
+
async function handleBindCurrent(cmd: Command, workspace: string): Promise<Result> {
|
|
730
|
+
const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
|
731
|
+
const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true });
|
|
732
|
+
const allTabs = await chrome.tabs.query({});
|
|
733
|
+
const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd))
|
|
734
|
+
?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd))
|
|
735
|
+
?? allTabs.find((tab) => matchesBindCriteria(tab, cmd));
|
|
736
|
+
if (!boundTab?.id) {
|
|
737
|
+
return {
|
|
738
|
+
id: cmd.id,
|
|
739
|
+
ok: false,
|
|
740
|
+
error: cmd.matchDomain || cmd.matchPathPrefix
|
|
741
|
+
? `No visible tab matching ${cmd.matchDomain ?? 'domain'}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ''}`
|
|
742
|
+
: 'No active debuggable tab found',
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
setWorkspaceSession(workspace, {
|
|
747
|
+
windowId: boundTab.windowId,
|
|
748
|
+
owned: false,
|
|
749
|
+
preferredTabId: boundTab.id,
|
|
750
|
+
});
|
|
751
|
+
resetWindowIdleTimer(workspace);
|
|
752
|
+
console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`);
|
|
753
|
+
return {
|
|
754
|
+
id: cmd.id,
|
|
755
|
+
ok: true,
|
|
756
|
+
data: {
|
|
757
|
+
tabId: boundTab.id,
|
|
758
|
+
windowId: boundTab.windowId,
|
|
759
|
+
url: boundTab.url,
|
|
760
|
+
title: boundTab.title,
|
|
761
|
+
workspace,
|
|
762
|
+
},
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
608
766
|
export const __test__ = {
|
|
609
767
|
handleNavigate,
|
|
610
768
|
isTargetUrl,
|
|
611
769
|
handleTabs,
|
|
612
770
|
handleSessions,
|
|
771
|
+
handleBindCurrent,
|
|
772
|
+
resolveTabId,
|
|
773
|
+
resetWindowIdleTimer,
|
|
774
|
+
getSession: (workspace: string = 'default') => automationSessions.get(workspace) ?? null,
|
|
613
775
|
getAutomationWindowId: (workspace: string = 'default') => automationSessions.get(workspace)?.windowId ?? null,
|
|
614
776
|
setAutomationWindowId: (workspace: string, windowId: number | null) => {
|
|
615
777
|
if (windowId === null) {
|
|
@@ -618,10 +780,13 @@ export const __test__ = {
|
|
|
618
780
|
automationSessions.delete(workspace);
|
|
619
781
|
return;
|
|
620
782
|
}
|
|
621
|
-
|
|
783
|
+
setWorkspaceSession(workspace, {
|
|
622
784
|
windowId,
|
|
623
|
-
|
|
624
|
-
|
|
785
|
+
owned: true,
|
|
786
|
+
preferredTabId: null,
|
|
625
787
|
});
|
|
626
788
|
},
|
|
789
|
+
setSession: (workspace: string, session: { windowId: number; owned: boolean; preferredTabId: number | null }) => {
|
|
790
|
+
setWorkspaceSession(workspace, session);
|
|
791
|
+
},
|
|
627
792
|
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
function createChromeMock() {
|
|
4
|
+
const tabs = {
|
|
5
|
+
get: vi.fn(async (_tabId: number) => ({
|
|
6
|
+
id: 1,
|
|
7
|
+
windowId: 1,
|
|
8
|
+
url: 'https://x.com/home',
|
|
9
|
+
})),
|
|
10
|
+
onRemoved: { addListener: vi.fn() },
|
|
11
|
+
onUpdated: { addListener: vi.fn() },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const debuggerApi = {
|
|
15
|
+
attach: vi.fn(async () => {}),
|
|
16
|
+
detach: vi.fn(async () => {}),
|
|
17
|
+
sendCommand: vi.fn(async (_target: unknown, method: string) => {
|
|
18
|
+
if (method === 'Runtime.evaluate') return { result: { value: 'ok' } };
|
|
19
|
+
return {};
|
|
20
|
+
}),
|
|
21
|
+
onDetach: { addListener: vi.fn() },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const scripting = {
|
|
25
|
+
executeScript: vi.fn(async () => [{ result: { removed: 1 } }]),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
chrome: {
|
|
30
|
+
tabs,
|
|
31
|
+
debugger: debuggerApi,
|
|
32
|
+
scripting,
|
|
33
|
+
runtime: { id: 'opencli-test' },
|
|
34
|
+
},
|
|
35
|
+
debuggerApi,
|
|
36
|
+
scripting,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('cdp attach recovery', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.resetModules();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.unstubAllGlobals();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does not mutate the DOM before a successful attach', async () => {
|
|
50
|
+
const { chrome, debuggerApi, scripting } = createChromeMock();
|
|
51
|
+
vi.stubGlobal('chrome', chrome);
|
|
52
|
+
|
|
53
|
+
const mod = await import('./cdp');
|
|
54
|
+
const result = await mod.evaluate(1, '1');
|
|
55
|
+
|
|
56
|
+
expect(result).toBe('ok');
|
|
57
|
+
expect(debuggerApi.attach).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(scripting.executeScript).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('retries after cleanup when attach fails with a foreign extension error', async () => {
|
|
62
|
+
const { chrome, debuggerApi, scripting } = createChromeMock();
|
|
63
|
+
debuggerApi.attach
|
|
64
|
+
.mockRejectedValueOnce(new Error('Cannot access a chrome-extension:// URL of different extension'))
|
|
65
|
+
.mockResolvedValueOnce(undefined);
|
|
66
|
+
vi.stubGlobal('chrome', chrome);
|
|
67
|
+
|
|
68
|
+
const mod = await import('./cdp');
|
|
69
|
+
const result = await mod.evaluate(1, '1');
|
|
70
|
+
|
|
71
|
+
expect(result).toBe('ok');
|
|
72
|
+
expect(scripting.executeScript).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(debuggerApi.attach).toHaveBeenCalledTimes(2);
|
|
74
|
+
});
|
|
75
|
+
});
|
package/extension/src/cdp.ts
CHANGED
|
@@ -10,6 +10,8 @@ const attached = new Set<number>();
|
|
|
10
10
|
|
|
11
11
|
/** Internal blank page used when no user URL is provided. */
|
|
12
12
|
const BLANK_PAGE = 'data:text/html,<html></html>';
|
|
13
|
+
const FOREIGN_EXTENSION_URL_PREFIX = 'chrome-extension://';
|
|
14
|
+
const ATTACH_RECOVERY_DELAY_MS = 120;
|
|
13
15
|
|
|
14
16
|
/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
|
|
15
17
|
function isDebuggableUrl(url?: string): boolean {
|
|
@@ -17,6 +19,67 @@ function isDebuggableUrl(url?: string): boolean {
|
|
|
17
19
|
return url.startsWith('http://') || url.startsWith('https://') || url === BLANK_PAGE;
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
type CleanupResult = { removed: number };
|
|
23
|
+
|
|
24
|
+
async function removeForeignExtensionEmbeds(tabId: number): Promise<CleanupResult> {
|
|
25
|
+
const tab = await chrome.tabs.get(tabId);
|
|
26
|
+
if (!tab.url || (!tab.url.startsWith('http://') && !tab.url.startsWith('https://'))) {
|
|
27
|
+
return { removed: 0 };
|
|
28
|
+
}
|
|
29
|
+
if (!chrome.scripting?.executeScript) return { removed: 0 };
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const [result] = await chrome.scripting.executeScript({
|
|
33
|
+
target: { tabId },
|
|
34
|
+
args: [`${FOREIGN_EXTENSION_URL_PREFIX}${chrome.runtime.id}/`],
|
|
35
|
+
func: (ownExtensionPrefix: string) => {
|
|
36
|
+
const extensionPrefix = 'chrome-extension://';
|
|
37
|
+
const selectors = ['iframe', 'frame', 'embed', 'object'];
|
|
38
|
+
const visitedRoots = new Set<Document | ShadowRoot>();
|
|
39
|
+
const roots: Array<Document | ShadowRoot> = [document];
|
|
40
|
+
let removed = 0;
|
|
41
|
+
|
|
42
|
+
while (roots.length > 0) {
|
|
43
|
+
const root = roots.pop();
|
|
44
|
+
if (!root || visitedRoots.has(root)) continue;
|
|
45
|
+
visitedRoots.add(root);
|
|
46
|
+
|
|
47
|
+
for (const selector of selectors) {
|
|
48
|
+
const nodes = root.querySelectorAll(selector);
|
|
49
|
+
for (const node of nodes) {
|
|
50
|
+
const src = node.getAttribute('src') || node.getAttribute('data') || '';
|
|
51
|
+
if (!src.startsWith(extensionPrefix) || src.startsWith(ownExtensionPrefix)) continue;
|
|
52
|
+
node.remove();
|
|
53
|
+
removed++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
|
58
|
+
let current = walker.nextNode();
|
|
59
|
+
while (current) {
|
|
60
|
+
const element = current as Element & { shadowRoot?: ShadowRoot | null };
|
|
61
|
+
if (element.shadowRoot) roots.push(element.shadowRoot);
|
|
62
|
+
current = walker.nextNode();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { removed };
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return result?.result ?? { removed: 0 };
|
|
70
|
+
} catch {
|
|
71
|
+
return { removed: 0 };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function delay(ms: number): Promise<void> {
|
|
76
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function tryAttach(tabId: number): Promise<void> {
|
|
80
|
+
await chrome.debugger.attach({ tabId }, '1.3');
|
|
81
|
+
}
|
|
82
|
+
|
|
20
83
|
async function ensureAttached(tabId: number): Promise<void> {
|
|
21
84
|
// Verify the tab URL is debuggable before attempting attach
|
|
22
85
|
try {
|
|
@@ -47,16 +110,27 @@ async function ensureAttached(tabId: number): Promise<void> {
|
|
|
47
110
|
}
|
|
48
111
|
|
|
49
112
|
try {
|
|
50
|
-
await
|
|
113
|
+
await tryAttach(tabId);
|
|
51
114
|
} catch (e: unknown) {
|
|
52
115
|
const msg = e instanceof Error ? e.message : String(e);
|
|
53
116
|
const hint = msg.includes('chrome-extension://')
|
|
54
117
|
? '. Tip: another Chrome extension may be interfering — try disabling other extensions'
|
|
55
118
|
: '';
|
|
56
|
-
if (msg.includes('
|
|
119
|
+
if (msg.includes('chrome-extension://')) {
|
|
120
|
+
const recoveryCleanup = await removeForeignExtensionEmbeds(tabId);
|
|
121
|
+
if (recoveryCleanup.removed > 0) {
|
|
122
|
+
console.warn(`[opencli] Removed ${recoveryCleanup.removed} foreign extension frame(s) after attach failure on tab ${tabId}`);
|
|
123
|
+
}
|
|
124
|
+
await delay(ATTACH_RECOVERY_DELAY_MS);
|
|
125
|
+
try {
|
|
126
|
+
await tryAttach(tabId);
|
|
127
|
+
} catch {
|
|
128
|
+
throw new Error(`attach failed: ${msg}${hint}`);
|
|
129
|
+
}
|
|
130
|
+
} else if (msg.includes('Another debugger is already attached')) {
|
|
57
131
|
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
|
58
132
|
try {
|
|
59
|
-
await
|
|
133
|
+
await tryAttach(tabId);
|
|
60
134
|
} catch {
|
|
61
135
|
throw new Error(`attach failed: ${msg}${hint}`);
|
|
62
136
|
}
|
|
@@ -71,6 +145,18 @@ async function ensureAttached(tabId: number): Promise<void> {
|
|
|
71
145
|
} catch {
|
|
72
146
|
// Some pages may not need explicit enable
|
|
73
147
|
}
|
|
148
|
+
|
|
149
|
+
// Disable breakpoints so that `debugger;` statements in page code don't
|
|
150
|
+
// pause execution. Anti-bot scripts use `debugger;` traps to detect CDP —
|
|
151
|
+
// they measure the time gap caused by the pause. Deactivating breakpoints
|
|
152
|
+
// makes the engine skip `debugger;` entirely, neutralising the timing
|
|
153
|
+
// side-channel without patching page JS.
|
|
154
|
+
try {
|
|
155
|
+
await chrome.debugger.sendCommand({ tabId }, 'Debugger.enable');
|
|
156
|
+
await chrome.debugger.sendCommand({ tabId }, 'Debugger.setBreakpointsActive', { active: false });
|
|
157
|
+
} catch {
|
|
158
|
+
// Non-fatal: best-effort hardening
|
|
159
|
+
}
|
|
74
160
|
}
|
|
75
161
|
|
|
76
162
|
export async function evaluate(tabId: number, expression: string): Promise<unknown> {
|