fix(browser): preserve chat focus during agent browsing
This commit is contained in:
72
tests/e2e/fixtures/native-agent-browser.ts
Normal file
72
tests/e2e/fixtures/native-agent-browser.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import path from 'node:path';
|
||||
import type { AgentBrowserBounds } from '../../../shared/agent-browser';
|
||||
import { AgentBrowserModule } from '../../../electron/agent-browser/module';
|
||||
import { ElectronAgentBrowserAdapter } from '../../../electron/agent-browser/electron-adapter';
|
||||
import { CodingAttachmentStore } from '../../../electron/coding-projects/attachment-store';
|
||||
import { PiAgentBrowserTool } from '../../../electron/coding-runtime/pi/extensions/agent-browser';
|
||||
|
||||
// Loaded in Electron Main by the E2E spec: use the production view, debugger and
|
||||
// Pi tool while the surrounding project/model APIs remain isolated fixtures.
|
||||
export class NativeAgentBrowserFixture {
|
||||
readonly browser: AgentBrowserModule;
|
||||
readonly tool: PiAgentBrowserTool;
|
||||
showRequests = 0;
|
||||
readonly context;
|
||||
|
||||
constructor(window: BrowserWindow, projectPath: string, readonly url: string) {
|
||||
this.context = {
|
||||
projectId: 'project-pi-first-chat', projectPath,
|
||||
conversationId: 'conversation-pi-first-chat', runId: 'browser-e2e',
|
||||
};
|
||||
this.browser = new AgentBrowserModule(new ElectronAgentBrowserAdapter(window));
|
||||
this.tool = new PiAgentBrowserTool(
|
||||
this.browser,
|
||||
new CodingAttachmentStore(path.join(projectPath, 'attachments')),
|
||||
(snapshot) => {
|
||||
this.showRequests += 1;
|
||||
window.webContents.send('agent-browser:show', snapshot);
|
||||
},
|
||||
(snapshot) => window.webContents.send('agent-browser:state', snapshot),
|
||||
);
|
||||
}
|
||||
|
||||
async execute(input: Record<string, unknown>) {
|
||||
const result = await this.tool.execute(this.context, input);
|
||||
return { value: JSON.parse(result.content[0].text), showRequests: this.showRequests };
|
||||
}
|
||||
|
||||
async route(route: string, body?: Record<string, unknown>) {
|
||||
const { projectId, projectPath } = this.context;
|
||||
if (route.startsWith('/api/agent-browser/state?')) {
|
||||
return { success: true, browser: await this.browser.getSnapshot(projectPath) };
|
||||
}
|
||||
if (route === '/api/agent-browser/ensure-work') {
|
||||
const snapshot = await this.browser.getSnapshot(projectPath);
|
||||
return {
|
||||
success: true, status: 'ready',
|
||||
browser: snapshot.state === 'closed'
|
||||
? await this.browser.open({ projectId, projectPath, url: this.url, visible: false })
|
||||
: snapshot,
|
||||
};
|
||||
}
|
||||
if (route === '/api/agent-browser/present') {
|
||||
return {
|
||||
success: true,
|
||||
browser: await this.browser.present({
|
||||
projectPath, visible: body?.visible === true,
|
||||
bounds: body?.bounds as AgentBrowserBounds | undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (route === '/api/agent-browser/close') {
|
||||
return { success: true, browser: await this.browser.close(projectPath) };
|
||||
}
|
||||
throw new Error('Unexpected native browser fixture route: ' + route);
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.tool.releaseRun(this.context.conversationId, this.context.runId);
|
||||
await this.browser.close(this.context.projectPath);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { ElectronApplication, Page } from 'playwright-core';
|
||||
import { appendFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import type { NativeAgentBrowserFixture } from './fixtures/native-agent-browser';
|
||||
import { expect, getStableWindow, test } from './fixtures/electron';
|
||||
|
||||
type CapturedRequest = {
|
||||
@@ -102,6 +106,7 @@ async function installCodingFirstChatHost(
|
||||
};
|
||||
const mainGlobal = globalThis as typeof globalThis & {
|
||||
__makelorePiFirstChatE2E?: MainState;
|
||||
__makeloreNativeBrowserE2E?: NativeAgentBrowserFixture;
|
||||
};
|
||||
const state: MainState = {
|
||||
captured: [],
|
||||
@@ -593,6 +598,9 @@ async function installCodingFirstChatHost(
|
||||
: []).map((item) => ({ ...item, ...metadata.get(item.id) })),
|
||||
});
|
||||
}
|
||||
if (path.startsWith('/api/agent-browser/') && mainGlobal.__makeloreNativeBrowserE2E) {
|
||||
return respond(await mainGlobal.__makeloreNativeBrowserE2E.route(path, body));
|
||||
}
|
||||
if (path.startsWith('/api/agent-browser/state?')) {
|
||||
return respond({ success: true, browser: browserSnapshot() });
|
||||
}
|
||||
@@ -2045,6 +2053,93 @@ test('work tab prepares the page automatically and keeps the student draft throu
|
||||
expect(requests.filter((request) => request.path.endsWith('/prompt'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('agent browser continues in the chat tab without repeatedly selecting Work', async ({ launchElectronApp }) => {
|
||||
const require = createRequire(resolve('package.json'));
|
||||
const { build } = createRequire(require.resolve('vite/package.json'))('esbuild') as typeof import('esbuild');
|
||||
const bundlePath = test.info().outputPath('native-agent-browser.cjs');
|
||||
await build({
|
||||
entryPoints: [resolve('tests/e2e/fixtures/native-agent-browser.ts')],
|
||||
outfile: bundlePath, bundle: true, platform: 'node', format: 'cjs', external: ['electron'],
|
||||
});
|
||||
let pageLoads = 0;
|
||||
const server = createServer((_request, response) => {
|
||||
pageLoads += 1;
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
response.end('<html><body><h1>Browser background test</h1><script>console.log("page-ready")</script></body></html>');
|
||||
});
|
||||
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Missing loopback port');
|
||||
const url = 'http://127.0.0.1:' + address.port + '/';
|
||||
const electronApp = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
let page = await getStableWindow(electronApp);
|
||||
const connection = await page.evaluate(async () => ({ token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string }));
|
||||
await installCodingFirstChatHost(electronApp, connection, true);
|
||||
await settleSnapshot(electronApp);
|
||||
await disableCodingEventSource(page);
|
||||
await page.reload(); page = await getStableWindow(electronApp);
|
||||
await page.getByTestId('ai-module-option-programming').click();
|
||||
await page.evaluate(() => { window.location.hash = '/chat'; });
|
||||
const composer = page.getByTestId('coding-message-composer').getByRole('textbox');
|
||||
await composer.fill('继续对话,不要抢走标签');
|
||||
await electronApp.evaluate(({ app, BrowserWindow }, input) => {
|
||||
const { NativeAgentBrowserFixture } = process.mainModule!.require(input.bundlePath) as typeof import('./fixtures/native-agent-browser');
|
||||
(globalThis as typeof globalThis & { __makeloreNativeBrowserE2E?: NativeAgentBrowserFixture }).__makeloreNativeBrowserE2E =
|
||||
new NativeAgentBrowserFixture(BrowserWindow.getAllWindows()[0], app.getPath('userData'), input.url);
|
||||
}, { bundlePath, url });
|
||||
const execute = async (input: Record<string, unknown>) => await electronApp.evaluate(async (_electron, command) => {
|
||||
return await (globalThis as typeof globalThis & { __makeloreNativeBrowserE2E: NativeAgentBrowserFixture }).__makeloreNativeBrowserE2E.execute(command);
|
||||
}, input);
|
||||
const opened = await execute({ action: 'open', url });
|
||||
expect(opened.value.visible).toBe(true);
|
||||
expect(opened.showRequests).toBe(1);
|
||||
await expect(page.getByRole('tab', { name: '作品', exact: true })).toHaveAttribute('aria-selected', 'true');
|
||||
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
|
||||
await expect.poll(async () => (await execute({ action: 'status' })).value.visible).toBe(false);
|
||||
await composer.focus();
|
||||
const generation = opened.value.generation;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
// Include a new URL: open may navigate, but must not reveal the native view.
|
||||
const reused = await execute({ action: 'open', url: url + '?attempt=' + attempt });
|
||||
expect(reused.value).toMatchObject({ browserId: opened.value.browserId, generation, visible: false });
|
||||
expect(reused.showRequests).toBe(1);
|
||||
const evaluated = await execute({
|
||||
action: 'send_cdp', method: 'Runtime.evaluate',
|
||||
params: { expression: 'document.querySelector("h1").textContent', returnByValue: true },
|
||||
});
|
||||
expect(evaluated.value).toMatchObject({ kind: 'inline', value: { result: { value: 'Browser background test' } } });
|
||||
await expect(page.getByRole('tab', { name: '操作对话', exact: true })).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(composer).toHaveValue('继续对话,不要抢走标签');
|
||||
}
|
||||
const loadsBeforeReload = pageLoads;
|
||||
await execute({ action: 'navigate', navigation: 'reload' });
|
||||
await expect.poll(() => pageLoads).toBeGreaterThan(loadsBeforeReload);
|
||||
const events = await execute({ action: 'read_events', after: 0 });
|
||||
expect(events.value.events.some((event: { method: string }) => event.method === 'Runtime.consoleAPICalled')).toBe(true);
|
||||
const screenshot = await execute({ action: 'send_cdp', method: 'Page.captureScreenshot' });
|
||||
expect(screenshot.value).toMatchObject({ attachmentId: expect.any(String), mime: 'image/png' });
|
||||
expect(screenshot.showRequests).toBe(1);
|
||||
expect((await execute({ action: 'status' })).value.visible).toBe(false);
|
||||
await expect(composer).toHaveValue('继续对话,不要抢走标签');
|
||||
await expect(composer).toBeFocused();
|
||||
await page.getByRole('tab', { name: '作品', exact: true }).click();
|
||||
await expect.poll(async () => (await execute({ action: 'status' })).value.visible).toBe(true);
|
||||
expect((await execute({ action: 'status' })).value).toMatchObject({ browserId: opened.value.browserId, generation });
|
||||
await page.getByRole('tab', { name: '操作对话', exact: true }).click();
|
||||
await expect.poll(async () => (await execute({ action: 'status' })).value.visible).toBe(false);
|
||||
await execute({ action: 'close' });
|
||||
expect((await execute({ action: 'status' })).value.state).toBe('closed');
|
||||
} finally {
|
||||
await electronApp.evaluate(async () => {
|
||||
const mainGlobal = globalThis as typeof globalThis & { __makeloreNativeBrowserE2E?: NativeAgentBrowserFixture };
|
||||
await mainGlobal.__makeloreNativeBrowserE2E?.dispose();
|
||||
delete mainGlobal.__makeloreNativeBrowserE2E;
|
||||
});
|
||||
await new Promise<void>((done, reject) => server.close((error) => error ? reject(error) : done()));
|
||||
}
|
||||
});
|
||||
|
||||
test('work failure offers a useful recovery and retains the student draft after opening', async ({ launchElectronApp }) => {
|
||||
const electronApp = await launchElectronApp({ skipSetup: true });
|
||||
let page = await getStableWindow(electronApp);
|
||||
|
||||
@@ -1280,7 +1280,9 @@ describe('AgentBrowserModule', () => {
|
||||
await expect(module.sendCdp({
|
||||
projectPath,
|
||||
method: 'Runtime.evaluate',
|
||||
})).rejects.toMatchObject({ code: 'VIEWPORT_NOT_READY' });
|
||||
})).resolves.toMatchObject({ kind: 'inline' });
|
||||
await expect(module.readEvents({ projectPath, after: 0 })).resolves.toBeDefined();
|
||||
await module.navigate({ projectPath, action: 'reload' });
|
||||
const reopened = await module.open({
|
||||
projectId: 'clock',
|
||||
projectPath,
|
||||
@@ -1288,10 +1290,18 @@ describe('AgentBrowserModule', () => {
|
||||
visible: true,
|
||||
});
|
||||
expect(reopened).toMatchObject({
|
||||
browserId: first.browserId,
|
||||
generation: first.generation,
|
||||
visible: false,
|
||||
bounds: { x: 1, y: 2, width: 640, height: 480 },
|
||||
});
|
||||
expect(adapter.views[0].visible).toBe(false);
|
||||
expect(adapter.views).toHaveLength(1);
|
||||
await module.close(projectPath);
|
||||
await expect(module.sendCdp({
|
||||
projectPath,
|
||||
method: 'Runtime.evaluate',
|
||||
})).rejects.toMatchObject({ code: 'BROWSER_NOT_OPEN' });
|
||||
});
|
||||
|
||||
it('waits for the matching browser generation to be presented without polling', async () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ const electronMocks = vi.hoisted(() => {
|
||||
webContents: unknown;
|
||||
setBounds: ReturnType<typeof vi.fn>;
|
||||
setVisible: ReturnType<typeof vi.fn>;
|
||||
getVisible: ReturnType<typeof vi.fn>;
|
||||
}> = [];
|
||||
const permissionHandler = vi.fn();
|
||||
const permissionCheckHandler = vi.fn();
|
||||
@@ -66,11 +67,13 @@ const electronMocks = vi.hoisted(() => {
|
||||
isDevToolsOpened: vi.fn().mockReturnValue(false),
|
||||
reload: vi.fn(),
|
||||
executeJavaScript: vi.fn().mockResolvedValue({ ok: true }),
|
||||
capturePage: vi.fn().mockResolvedValue({}),
|
||||
close: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
});
|
||||
readonly setBounds = vi.fn();
|
||||
readonly setVisible = vi.fn();
|
||||
readonly getVisible = vi.fn().mockReturnValue(true);
|
||||
|
||||
constructor(readonly options: unknown) {
|
||||
nativeViews.push(this);
|
||||
@@ -102,6 +105,29 @@ describe('ElectronAgentBrowserAdapter', () => {
|
||||
electronMocks.browserSession.removeAllListeners();
|
||||
});
|
||||
|
||||
it.each([false, true])('captures a page without presenting a hidden native view (visible=%s)', async (visible) => {
|
||||
const adapter = new ElectronAgentBrowserAdapter({} as never);
|
||||
const view = adapter.createView('browser-screenshot');
|
||||
const native = electronMocks.nativeViews[0];
|
||||
native.getVisible.mockReturnValue(visible);
|
||||
const contents = native.webContents as InstanceType<typeof electronMocks.MockWebContentsView>['webContents'];
|
||||
const params = { format: 'jpeg', quality: 80, clip: { x: 0, y: 0, width: 320, height: 200, scale: 1 } };
|
||||
contents.debugger.sendCommand.mockResolvedValue({ data: 'screenshot' });
|
||||
await expect(view.webContents.debugger.sendCommand('Page.captureScreenshot', params, 'child-session'))
|
||||
.resolves.toEqual({ data: 'screenshot' });
|
||||
if (visible) {
|
||||
expect(contents.capturePage).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(contents.capturePage).toHaveBeenCalledExactlyOnceWith(undefined, { stayHidden: true, stayAwake: false });
|
||||
expect(contents.capturePage.mock.invocationCallOrder[0]).toBeLessThan(contents.debugger.sendCommand.mock.invocationCallOrder[0]);
|
||||
}
|
||||
expect(contents.debugger.sendCommand).toHaveBeenCalledWith('Page.captureScreenshot', params, 'child-session');
|
||||
expect(native.setVisible).not.toHaveBeenCalled();
|
||||
contents.capturePage.mockClear();
|
||||
await view.webContents.debugger.sendCommand('Runtime.evaluate', { expression: '1' });
|
||||
expect(contents.capturePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates an isolated sandboxed WebContentsView and mounts it in Main', async () => {
|
||||
const addChildView = vi.fn();
|
||||
const removeChildView = vi.fn();
|
||||
|
||||
@@ -458,6 +458,45 @@ describe('PI-090 product tools', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])('preserves the selected tab when reopening an initialized browser (visible=%s)', async (visible) => {
|
||||
const root = await temporaryRoot('makelore-pi-browser-reuse-');
|
||||
const initialized = {
|
||||
browserId: 'browser-a', projectId: 'project-a', projectPath: root,
|
||||
state: 'attached' as const, generation: 3, url: 'http://127.0.0.1:4173/', title: 'App',
|
||||
visible, bounds: { x: 10, y: 20, width: 800, height: 600 },
|
||||
canGoBack: false, canGoForward: false, eventCursor: 0,
|
||||
};
|
||||
const browser = {
|
||||
open: vi.fn(async () => initialized),
|
||||
waitForPresentation: vi.fn(async () => ({ ...initialized, visible: true })),
|
||||
setDiagnostics: vi.fn(async () => initialized),
|
||||
} as unknown as AgentBrowserModule;
|
||||
const requestAgentBrowserPresentation = vi.fn();
|
||||
const publishAgentBrowserState = vi.fn();
|
||||
const tools = new PiProductTools({
|
||||
browser,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
requestAgentBrowserPresentation,
|
||||
publishAgentBrowserState,
|
||||
});
|
||||
const context = {
|
||||
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'browser-a',
|
||||
projectId: 'project-a', projectPath: root, skillIds: [],
|
||||
};
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const result = await tools.execute('agent_browser', context, { action: 'open', url: initialized.url });
|
||||
expect(JSON.parse(result.content[0].text)).toMatchObject({ visible, generation: 3 });
|
||||
}
|
||||
expect(requestAgentBrowserPresentation).not.toHaveBeenCalled();
|
||||
expect(browser.waitForPresentation).not.toHaveBeenCalled();
|
||||
expect(publishAgentBrowserState).toHaveBeenLastCalledWith(initialized);
|
||||
await tools.settleRun('conversation-a', 'run-a');
|
||||
expect(browser.setDiagnostics).toHaveBeenCalledWith({
|
||||
projectPath: root, enabled: false, owner: 'agent:conversation-a:run-a',
|
||||
});
|
||||
});
|
||||
|
||||
it('closes an agent-opened browser when presentation times out', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-browser-timeout-');
|
||||
const hidden = {
|
||||
|
||||
Reference in New Issue
Block a user