豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit e5973fd

Browse files
authored
chore: implement trigger extension action (#1049)
This PR implements the ability to trigger an extension action by passing it an extension id. It uses puppeteer internals and the canary chrome version in tests since the TriggerAction CDP command is still not available in the current stable release.
1 parent 9d173cd commit e5973fd

File tree

9 files changed

+109
-21
lines changed

9 files changed

+109
-21
lines changed

.github/workflows/run-tests.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ jobs:
4141
shell: bash
4242
run: npm ci
4343

44+
- name: Install Chrome Canary
45+
shell: bash
46+
run: |
47+
CANARY_PATH=$(npx @puppeteer/browsers install chrome@canary --format "{{path}}")
48+
echo "CANARY_EXECUTABLE_PATH=$CANARY_PATH" >> $GITHUB_ENV
49+
4450
- name: Build
4551
run: npm run bundle
4652
env:

src/McpContext.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,24 @@ export class McpContext implements Context {
919919
this.#extensionRegistry.remove(id);
920920
}
921921

922+
async triggerExtensionAction(id: string): Promise<void> {
923+
const page = this.getSelectedPptrPage();
924+
// @ts-expect-error internal puppeteer api is needed since we don't have a way to get
925+
// a tab id at the moment
926+
const theTarget = page._tabId;
927+
const session = await this.browser.target().createCDPSession();
928+
929+
try {
930+
// @ts-expect-error triggerAction is not yet available
931+
await session.send('Extensions.triggerAction', {
932+
id,
933+
targetId: theTarget,
934+
});
935+
} finally {
936+
await session.detach();
937+
}
938+
}
939+
922940
listExtensions(): InstalledExtension[] {
923941
return this.#extensionRegistry.list();
924942
}

src/McpResponse.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,7 @@ Call ${handleDialog.name} to handle it before continuing.`);
595595
}
596596

597597
if (this.#includeExtensionServiceWorkers) {
598-
if (!context.getExtensionServiceWorkers().length) {
598+
if (context.getExtensionServiceWorkers().length) {
599599
response.push(`## Extension Service Workers`);
600600
}
601601

src/tools/ToolDefinition.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ export type Context = Readonly<{
190190
): void;
191191
installExtension(path: string): Promise<string>;
192192
uninstallExtension(id: string): Promise<void>;
193+
triggerExtensionAction(id: string): Promise<void>;
193194
listExtensions(): InstalledExtension[];
194195
getExtension(id: string): InstalledExtension | undefined;
195196
getSelectedMcpPage(): McpPage;

src/tools/extensions.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,21 @@ export const reloadExtension = defineTool({
8585
response.appendResponseLine('Extension reloaded.');
8686
},
8787
});
88+
89+
export const triggerExtensionAction = defineTool({
90+
name: 'trigger_extension_action',
91+
description: 'Triggers an action in a Chrome extension.',
92+
annotations: {
93+
category: ToolCategory.EXTENSIONS,
94+
readOnlyHint: false,
95+
conditions: [EXTENSIONS_CONDITION],
96+
},
97+
schema: {
98+
id: zod.string().describe('ID of the extension.'),
99+
},
100+
handler: async (request, response, context) => {
101+
const {id} = request.params;
102+
await context.triggerExtensionAction(id);
103+
response.appendResponseLine(`Extension action triggered. Id: ${id}`);
104+
},
105+
});

tests/tools/extensions.test.ts

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,25 @@ import {afterEach, describe, it} from 'node:test';
1010

1111
import sinon from 'sinon';
1212

13-
import type {McpResponse} from '../../src/McpResponse.js';
13+
import type {ParsedArguments} from '../../src/cli.js';
1414
import {
1515
installExtension,
1616
uninstallExtension,
1717
listExtensions,
1818
reloadExtension,
19+
triggerExtensionAction,
1920
} from '../../src/tools/extensions.js';
20-
import {withMcpContext} from '../utils.js';
21+
import {extractExtensionId, withMcpContext} from '../utils.js';
2122

23+
const EXTENSION_WITH_SW_PATH = path.join(
24+
import.meta.dirname,
25+
'../../../tests/tools/fixtures/extension-sw',
26+
);
2227
const EXTENSION_PATH = path.join(
2328
import.meta.dirname,
2429
'../../../tests/tools/fixtures/extension',
2530
);
2631

27-
export function extractId(response: McpResponse) {
28-
const responseLine = response.responseLines[0];
29-
assert.ok(responseLine, 'Response should not be empty');
30-
const match = responseLine.match(/Extension installed\. Id: (.+)/);
31-
const extensionId = match ? match[1] : null;
32-
assert.ok(extensionId, 'Response should contain a valid key');
33-
return extensionId;
34-
}
35-
3632
describe('extension', () => {
3733
afterEach(() => {
3834
sinon.restore();
@@ -47,7 +43,7 @@ describe('extension', () => {
4743
context,
4844
);
4945

50-
const extensionId = extractId(response);
46+
const extensionId = extractExtensionId(response);
5147
const page = context.getSelectedPptrPage();
5248
await page.goto('chrome://extensions');
5349

@@ -102,7 +98,7 @@ describe('extension', () => {
10298
context,
10399
);
104100

105-
const extensionId = extractId(response);
101+
const extensionId = extractExtensionId(response);
106102
const installSpy = sinon.spy(context, 'installExtension');
107103
response.resetResponseLineForTesting();
108104

@@ -128,4 +124,36 @@ describe('extension', () => {
128124
assert.ok(reinstalled, 'Extension should be present after reload');
129125
});
130126
});
127+
it('triggers an extension action', async () => {
128+
await withMcpContext(
129+
async (response, context) => {
130+
const extensionId = await context.installExtension(
131+
EXTENSION_WITH_SW_PATH,
132+
);
133+
134+
const targetsBefore = context.browser.targets();
135+
const pageTargetBefore = targetsBefore.find(
136+
t => t.type() === 'page' && t.url().includes(extensionId),
137+
);
138+
assert.ok(!pageTargetBefore, 'Page should not exist before action');
139+
140+
await triggerExtensionAction.handler(
141+
{params: {id: extensionId}},
142+
response,
143+
context,
144+
);
145+
146+
const pageTargetAfter = await context.browser.waitForTarget(
147+
t => t.type() === 'page' && t.url().includes(extensionId),
148+
);
149+
assert.ok(pageTargetAfter, 'Page should exist after action');
150+
},
151+
{
152+
executablePath: process.env.CANARY_EXECUTABLE_PATH,
153+
},
154+
{
155+
categoryExtensions: true,
156+
} as ParsedArguments,
157+
);
158+
});
131159
});
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
console.log('Service worker loaded');
1+
chrome.action.onClicked.addListener(tab => {
2+
console.log('Action clicked');
3+
});

tests/tools/script.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ import type {ParsedArguments} from '../../src/cli.js';
1212
import {installExtension} from '../../src/tools/extensions.js';
1313
import {evaluateScript} from '../../src/tools/script.js';
1414
import {serverHooks} from '../server.js';
15-
import {html, withMcpContext} from '../utils.js';
16-
17-
import {extractId} from './extensions.test.js';
15+
import {extractExtensionId, html, withMcpContext} from '../utils.js';
1816

1917
const EXTENSION_PATH = path.join(
2018
import.meta.dirname,
@@ -209,7 +207,7 @@ describe('script', () => {
209207
context,
210208
);
211209

212-
const extensionId = extractId(response);
210+
const extensionId = extractExtensionId(response);
213211
const swTarget = await context.browser.waitForTarget(
214212
t => t.type() === 'service_worker' && t.url().includes(extensionId),
215213
);

tests/utils.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import assert from 'node:assert';
8+
79
import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js';
810
import logger from 'debug';
911
import type {Browser} from 'puppeteer';
@@ -42,15 +44,29 @@ export function getImageContent(content: CallToolResult['content'][number]): {
4244
throw new Error(`Expected image content but got ${content.type}`);
4345
}
4446

47+
export function extractExtensionId(response: McpResponse) {
48+
const responseLine = response.responseLines[0];
49+
assert.ok(responseLine, 'Response should not be empty');
50+
const match = responseLine.match(/Extension installed\. Id: (.+)/);
51+
const extensionId = match ? match[1] : null;
52+
assert.ok(extensionId, 'Response should contain a valid key');
53+
return extensionId;
54+
}
55+
4556
const browsers = new Map<string, Browser>();
4657
let context: McpContext | undefined;
4758

4859
export async function withBrowser(
4960
cb: (browser: Browser, page: Page) => Promise<void>,
50-
options: {debug?: boolean; autoOpenDevTools?: boolean} = {},
61+
options: {
62+
debug?: boolean;
63+
autoOpenDevTools?: boolean;
64+
executablePath?: string;
65+
} = {},
5166
) {
5267
const launchOptions: LaunchOptions = {
53-
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
68+
executablePath:
69+
options.executablePath ?? process.env.PUPPETEER_EXECUTABLE_PATH,
5470
headless: !options.debug,
5571
defaultViewport: null,
5672
devtools: options.autoOpenDevTools ?? false,
@@ -85,6 +101,7 @@ export async function withMcpContext(
85101
debug?: boolean;
86102
autoOpenDevTools?: boolean;
87103
performanceCrux?: boolean;
104+
executablePath?: string;
88105
} = {},
89106
args: ParsedArguments = {} as ParsedArguments,
90107
) {

0 commit comments

Comments
 (0)