2025-03-21 10:58:58 -07:00
|
|
|
/**
|
|
|
|
* Copyright (c) Microsoft Corporation.
|
|
|
|
*
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
*
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
*
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
* limitations under the License.
|
|
|
|
*/
|
|
|
|
|
|
|
|
import * as playwright from 'playwright';
|
2025-04-01 23:47:53 +02:00
|
|
|
import yaml from 'yaml';
|
2025-03-21 10:58:58 -07:00
|
|
|
|
2025-04-02 11:42:39 -07:00
|
|
|
import { waitForCompletion } from './tools/utils';
|
|
|
|
import { ToolResult } from './tools/tool';
|
|
|
|
|
2025-03-30 09:05:58 -07:00
|
|
|
export type ContextOptions = {
|
2025-04-01 10:26:48 -07:00
|
|
|
browserName?: 'chromium' | 'firefox' | 'webkit';
|
2025-03-30 09:05:58 -07:00
|
|
|
userDataDir: string;
|
|
|
|
launchOptions?: playwright.LaunchOptions;
|
|
|
|
cdpEndpoint?: string;
|
|
|
|
remoteEndpoint?: string;
|
|
|
|
};
|
|
|
|
|
2025-04-02 11:42:39 -07:00
|
|
|
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
|
|
|
|
|
|
|
|
type RunOptions = {
|
|
|
|
captureSnapshot?: boolean;
|
|
|
|
waitForCompletion?: boolean;
|
|
|
|
status?: string;
|
|
|
|
noClearFileChooser?: boolean;
|
|
|
|
};
|
|
|
|
|
2025-03-21 10:58:58 -07:00
|
|
|
export class Context {
|
2025-04-04 15:22:00 -07:00
|
|
|
readonly options: ContextOptions;
|
2025-03-25 13:05:28 -07:00
|
|
|
private _browser: playwright.Browser | undefined;
|
2025-04-03 10:30:05 -07:00
|
|
|
private _browserContext: playwright.BrowserContext | undefined;
|
2025-04-03 19:24:17 -07:00
|
|
|
private _tabs: Tab[] = [];
|
|
|
|
private _currentTab: Tab | undefined;
|
2025-03-21 10:58:58 -07:00
|
|
|
|
2025-03-30 09:05:58 -07:00
|
|
|
constructor(options: ContextOptions) {
|
2025-04-04 15:22:00 -07:00
|
|
|
this.options = options;
|
2025-03-21 10:58:58 -07:00
|
|
|
}
|
|
|
|
|
2025-04-03 19:24:17 -07:00
|
|
|
tabs(): Tab[] {
|
|
|
|
return this._tabs;
|
|
|
|
}
|
|
|
|
|
|
|
|
currentTab(): Tab {
|
|
|
|
if (!this._currentTab)
|
|
|
|
throw new Error('Navigate to a location to create a tab');
|
|
|
|
return this._currentTab;
|
|
|
|
}
|
|
|
|
|
|
|
|
async newTab(): Promise<Tab> {
|
|
|
|
const browserContext = await this._ensureBrowserContext();
|
|
|
|
const page = await browserContext.newPage();
|
|
|
|
this._currentTab = this._tabs.find(t => t.page === page)!;
|
|
|
|
return this._currentTab;
|
|
|
|
}
|
|
|
|
|
|
|
|
async selectTab(index: number) {
|
|
|
|
this._currentTab = this._tabs[index - 1];
|
|
|
|
await this._currentTab.page.bringToFront();
|
|
|
|
}
|
|
|
|
|
|
|
|
async ensureTab(): Promise<Tab> {
|
|
|
|
const context = await this._ensureBrowserContext();
|
2025-04-03 22:39:55 -07:00
|
|
|
if (!this._currentTab)
|
|
|
|
await context.newPage();
|
2025-04-03 19:24:17 -07:00
|
|
|
return this._currentTab!;
|
|
|
|
}
|
|
|
|
|
|
|
|
async listTabs(): Promise<string> {
|
|
|
|
if (!this._tabs.length)
|
|
|
|
return 'No tabs open';
|
|
|
|
const lines: string[] = ['Open tabs:'];
|
|
|
|
for (let i = 0; i < this._tabs.length; i++) {
|
|
|
|
const tab = this._tabs[i];
|
|
|
|
const title = await tab.page.title();
|
|
|
|
const url = tab.page.url();
|
|
|
|
const current = tab === this._currentTab ? ' (current)' : '';
|
|
|
|
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
|
|
|
|
}
|
|
|
|
return lines.join('\n');
|
|
|
|
}
|
|
|
|
|
|
|
|
async closeTab(index: number | undefined) {
|
|
|
|
const tab = index === undefined ? this.currentTab() : this._tabs[index - 1];
|
|
|
|
await tab.page.close();
|
|
|
|
return await this.listTabs();
|
2025-03-21 10:58:58 -07:00
|
|
|
}
|
|
|
|
|
2025-04-03 10:30:05 -07:00
|
|
|
private _onPageCreated(page: playwright.Page) {
|
2025-04-03 19:24:17 -07:00
|
|
|
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
|
|
|
this._tabs.push(tab);
|
|
|
|
if (!this._currentTab)
|
|
|
|
this._currentTab = tab;
|
2025-04-03 10:30:05 -07:00
|
|
|
}
|
2025-03-26 15:02:45 -07:00
|
|
|
|
2025-04-03 19:24:17 -07:00
|
|
|
private _onPageClosed(tab: Tab) {
|
2025-04-03 22:39:55 -07:00
|
|
|
const index = this._tabs.indexOf(tab);
|
|
|
|
if (index === -1)
|
|
|
|
return;
|
|
|
|
this._tabs.splice(index, 1);
|
|
|
|
|
2025-04-03 19:24:17 -07:00
|
|
|
if (this._currentTab === tab)
|
2025-04-03 22:39:55 -07:00
|
|
|
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
2025-04-03 10:30:05 -07:00
|
|
|
const browser = this._browser;
|
2025-04-03 19:24:17 -07:00
|
|
|
if (this._browserContext && !this._tabs.length) {
|
2025-04-03 10:30:05 -07:00
|
|
|
void this._browserContext.close().then(() => browser?.close()).catch(() => {});
|
|
|
|
this._browser = undefined;
|
|
|
|
this._browserContext = undefined;
|
|
|
|
}
|
2025-03-25 13:05:28 -07:00
|
|
|
}
|
|
|
|
|
2025-04-03 10:30:05 -07:00
|
|
|
async close() {
|
|
|
|
if (!this._browserContext)
|
|
|
|
return;
|
|
|
|
await this._browserContext.close();
|
|
|
|
}
|
|
|
|
|
2025-04-03 19:24:17 -07:00
|
|
|
private async _ensureBrowserContext() {
|
|
|
|
if (!this._browserContext) {
|
|
|
|
const context = await this._createBrowserContext();
|
|
|
|
this._browser = context.browser;
|
|
|
|
this._browserContext = context.browserContext;
|
2025-04-03 22:39:55 -07:00
|
|
|
for (const page of this._browserContext.pages())
|
|
|
|
this._onPageCreated(page);
|
2025-04-03 19:24:17 -07:00
|
|
|
this._browserContext.on('page', page => this._onPageCreated(page));
|
|
|
|
}
|
|
|
|
return this._browserContext;
|
|
|
|
}
|
|
|
|
|
2025-04-03 10:30:05 -07:00
|
|
|
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
2025-04-04 15:22:00 -07:00
|
|
|
if (this.options.remoteEndpoint) {
|
|
|
|
const url = new URL(this.options.remoteEndpoint);
|
|
|
|
if (this.options.browserName)
|
|
|
|
url.searchParams.set('browser', this.options.browserName);
|
|
|
|
if (this.options.launchOptions)
|
|
|
|
url.searchParams.set('launch-options', JSON.stringify(this.options.launchOptions));
|
|
|
|
const browser = await playwright[this.options.browserName ?? 'chromium'].connect(String(url));
|
2025-04-03 10:30:05 -07:00
|
|
|
const browserContext = await browser.newContext();
|
|
|
|
return { browser, browserContext };
|
|
|
|
}
|
|
|
|
|
2025-04-04 15:22:00 -07:00
|
|
|
if (this.options.cdpEndpoint) {
|
|
|
|
const browser = await playwright.chromium.connectOverCDP(this.options.cdpEndpoint);
|
2025-04-03 10:30:05 -07:00
|
|
|
const browserContext = browser.contexts()[0];
|
|
|
|
return { browser, browserContext };
|
|
|
|
}
|
|
|
|
|
|
|
|
const browserContext = await this._launchPersistentContext();
|
|
|
|
return { browserContext };
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _launchPersistentContext(): Promise<playwright.BrowserContext> {
|
|
|
|
try {
|
2025-04-04 15:22:00 -07:00
|
|
|
const browserType = this.options.browserName ? playwright[this.options.browserName] : playwright.chromium;
|
|
|
|
return await browserType.launchPersistentContext(this.options.userDataDir, this.options.launchOptions);
|
2025-04-03 10:30:05 -07:00
|
|
|
} catch (error: any) {
|
|
|
|
if (error.message.includes('Executable doesn\'t exist'))
|
|
|
|
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-04-03 19:24:17 -07:00
|
|
|
class Tab {
|
|
|
|
readonly context: Context;
|
2025-04-03 10:30:05 -07:00
|
|
|
readonly page: playwright.Page;
|
|
|
|
private _console: playwright.ConsoleMessage[] = [];
|
|
|
|
private _fileChooser: playwright.FileChooser | undefined;
|
|
|
|
private _snapshot: PageSnapshot | undefined;
|
2025-04-03 19:24:17 -07:00
|
|
|
private _onPageClose: (tab: Tab) => void;
|
2025-04-03 10:30:05 -07:00
|
|
|
|
2025-04-03 19:24:17 -07:00
|
|
|
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
|
|
|
|
this.context = context;
|
2025-04-03 10:30:05 -07:00
|
|
|
this.page = page;
|
|
|
|
this._onPageClose = onPageClose;
|
|
|
|
page.on('console', event => this._console.push(event));
|
|
|
|
page.on('framenavigated', frame => {
|
|
|
|
if (!frame.parentFrame())
|
|
|
|
this._console.length = 0;
|
|
|
|
});
|
|
|
|
page.on('close', () => this._onClose());
|
|
|
|
page.on('filechooser', chooser => this._fileChooser = chooser);
|
|
|
|
page.setDefaultNavigationTimeout(60000);
|
|
|
|
page.setDefaultTimeout(5000);
|
|
|
|
}
|
|
|
|
|
|
|
|
private _onClose() {
|
|
|
|
this._fileChooser = undefined;
|
|
|
|
this._console.length = 0;
|
|
|
|
this._onPageClose(this);
|
2025-03-26 15:02:45 -07:00
|
|
|
}
|
|
|
|
|
2025-04-03 19:24:17 -07:00
|
|
|
async navigate(url: string) {
|
|
|
|
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
|
|
// Cap load event to 5 seconds, the page is operational at this point.
|
|
|
|
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
|
|
|
}
|
|
|
|
|
|
|
|
async run(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
2025-04-02 11:42:39 -07:00
|
|
|
try {
|
|
|
|
if (!options?.noClearFileChooser)
|
|
|
|
this._fileChooser = undefined;
|
|
|
|
if (options?.waitForCompletion)
|
2025-04-03 10:30:05 -07:00
|
|
|
await waitForCompletion(this.page, () => callback(this));
|
2025-04-02 11:42:39 -07:00
|
|
|
else
|
2025-04-03 10:30:05 -07:00
|
|
|
await callback(this);
|
2025-04-02 11:42:39 -07:00
|
|
|
} finally {
|
|
|
|
if (options?.captureSnapshot)
|
2025-04-03 10:30:05 -07:00
|
|
|
this._snapshot = await PageSnapshot.create(this.page);
|
2025-04-02 11:42:39 -07:00
|
|
|
}
|
2025-04-03 19:24:17 -07:00
|
|
|
const tabList = this.context.tabs().length > 1 ? await this.context.listTabs() + '\n\nCurrent tab:' + '\n' : '';
|
|
|
|
const snapshot = this._snapshot?.text({ status: options?.status, hasFileChooser: !!this._fileChooser }) ?? options?.status ?? '';
|
2025-04-02 11:42:39 -07:00
|
|
|
return {
|
|
|
|
content: [{
|
|
|
|
type: 'text',
|
2025-04-03 19:24:17 -07:00
|
|
|
text: tabList + snapshot,
|
2025-04-02 11:42:39 -07:00
|
|
|
}],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2025-04-03 19:24:17 -07:00
|
|
|
async runAndWait(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
2025-04-02 11:42:39 -07:00
|
|
|
return await this.run(callback, {
|
|
|
|
waitForCompletion: true,
|
|
|
|
...options,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2025-04-03 19:24:17 -07:00
|
|
|
async runAndWaitWithSnapshot(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
2025-04-02 11:42:39 -07:00
|
|
|
return await this.run(callback, {
|
|
|
|
captureSnapshot: true,
|
|
|
|
waitForCompletion: true,
|
|
|
|
...options,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
lastSnapshot(): PageSnapshot {
|
|
|
|
if (!this._snapshot)
|
|
|
|
throw new Error('No snapshot available');
|
|
|
|
return this._snapshot;
|
|
|
|
}
|
|
|
|
|
2025-03-26 15:02:45 -07:00
|
|
|
async console(): Promise<playwright.ConsoleMessage[]> {
|
|
|
|
return this._console;
|
|
|
|
}
|
|
|
|
|
2025-03-27 20:49:57 +01:00
|
|
|
async submitFileChooser(paths: string[]) {
|
|
|
|
if (!this._fileChooser)
|
|
|
|
throw new Error('No file chooser visible');
|
|
|
|
await this._fileChooser.setFiles(paths);
|
|
|
|
this._fileChooser = undefined;
|
|
|
|
}
|
2025-04-02 11:42:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
class PageSnapshot {
|
|
|
|
private _frameLocators: PageOrFrameLocator[] = [];
|
|
|
|
private _text!: string;
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
}
|
|
|
|
|
|
|
|
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
|
|
|
const snapshot = new PageSnapshot();
|
|
|
|
await snapshot._build(page);
|
|
|
|
return snapshot;
|
|
|
|
}
|
|
|
|
|
|
|
|
text(options?: { status?: string, hasFileChooser?: boolean }): string {
|
|
|
|
const results: string[] = [];
|
|
|
|
if (options?.status) {
|
|
|
|
results.push(options.status);
|
|
|
|
results.push('');
|
|
|
|
}
|
|
|
|
if (options?.hasFileChooser) {
|
2025-04-04 17:14:30 -07:00
|
|
|
results.push('- There is a file chooser visible that requires browser_file_upload to be called');
|
2025-04-02 11:42:39 -07:00
|
|
|
results.push('');
|
|
|
|
}
|
|
|
|
results.push(this._text);
|
|
|
|
return results.join('\n');
|
|
|
|
}
|
2025-03-31 15:30:08 -07:00
|
|
|
|
2025-04-02 11:42:39 -07:00
|
|
|
private async _build(page: playwright.Page) {
|
|
|
|
const yamlDocument = await this._snapshotFrame(page);
|
|
|
|
const lines = [];
|
|
|
|
lines.push(
|
|
|
|
`- Page URL: ${page.url()}`,
|
|
|
|
`- Page Title: ${await page.title()}`
|
|
|
|
);
|
|
|
|
lines.push(
|
|
|
|
`- Page Snapshot`,
|
|
|
|
'```yaml',
|
|
|
|
yamlDocument.toString().trim(),
|
|
|
|
'```',
|
|
|
|
''
|
|
|
|
);
|
|
|
|
this._text = lines.join('\n');
|
2025-04-01 23:47:53 +02:00
|
|
|
}
|
|
|
|
|
2025-04-02 11:42:39 -07:00
|
|
|
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
|
|
|
|
const frameIndex = this._frameLocators.push(frame) - 1;
|
2025-04-01 23:47:53 +02:00
|
|
|
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true });
|
|
|
|
const snapshot = yaml.parseDocument(snapshotString);
|
|
|
|
|
|
|
|
const visit = async (node: any): Promise<unknown> => {
|
|
|
|
if (yaml.isPair(node)) {
|
|
|
|
await Promise.all([
|
|
|
|
visit(node.key).then(k => node.key = k),
|
|
|
|
visit(node.value).then(v => node.value = v)
|
|
|
|
]);
|
|
|
|
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
|
|
|
|
node.items = await Promise.all(node.items.map(visit));
|
|
|
|
} else if (yaml.isScalar(node)) {
|
|
|
|
if (typeof node.value === 'string') {
|
|
|
|
const value = node.value;
|
|
|
|
if (frameIndex > 0)
|
|
|
|
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
|
|
|
|
if (value.startsWith('iframe ')) {
|
|
|
|
const ref = value.match(/\[ref=(.*)\]/)?.[1];
|
|
|
|
if (ref) {
|
2025-04-01 15:10:23 -07:00
|
|
|
try {
|
2025-04-02 11:42:39 -07:00
|
|
|
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
|
2025-04-01 15:10:23 -07:00
|
|
|
return snapshot.createPair(node.value, childSnapshot);
|
|
|
|
} catch (error) {
|
|
|
|
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
|
|
|
|
}
|
2025-04-01 23:47:53 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return node;
|
|
|
|
};
|
|
|
|
await visit(snapshot.contents);
|
|
|
|
return snapshot;
|
2025-03-27 20:22:44 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
refLocator(ref: string): playwright.Locator {
|
2025-04-02 11:42:39 -07:00
|
|
|
let frame = this._frameLocators[0];
|
2025-03-27 20:22:44 +01:00
|
|
|
const match = ref.match(/^f(\d+)(.*)/);
|
|
|
|
if (match) {
|
|
|
|
const frameIndex = parseInt(match[1], 10);
|
2025-04-02 11:42:39 -07:00
|
|
|
frame = this._frameLocators[frameIndex];
|
2025-03-27 20:22:44 +01:00
|
|
|
ref = match[2];
|
|
|
|
}
|
|
|
|
|
2025-04-01 23:47:53 +02:00
|
|
|
if (!frame)
|
|
|
|
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
|
|
|
|
|
2025-03-27 20:22:44 +01:00
|
|
|
return frame.locator(`aria-ref=${ref}`);
|
|
|
|
}
|
2025-03-21 10:58:58 -07:00
|
|
|
}
|