@playwright/mcp 0.0.32 → 0.0.34
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/README.md +33 -3
- package/lib/browserContextFactory.js +78 -60
- package/lib/browserServerBackend.js +47 -21
- package/lib/config.js +8 -8
- package/lib/context.js +76 -28
- package/lib/extension/cdpRelay.js +29 -50
- package/lib/extension/extensionContextFactory.js +56 -0
- package/lib/index.js +3 -1
- package/lib/loopTools/context.js +1 -1
- package/lib/loopTools/main.js +7 -5
- package/lib/mcp/proxyBackend.js +106 -0
- package/lib/mcp/server.js +32 -24
- package/lib/mcp/tool.js +29 -0
- package/lib/mcp/transport.js +1 -1
- package/lib/program.js +30 -21
- package/lib/response.js +81 -14
- package/lib/sessionLog.js +85 -34
- package/lib/tab.js +55 -52
- package/lib/tools/common.js +0 -1
- package/lib/tools/evaluate.js +1 -1
- package/lib/tools/files.js +0 -1
- package/lib/tools/keyboard.js +1 -3
- package/lib/tools/navigate.js +0 -3
- package/lib/tools/pdf.js +2 -4
- package/lib/tools/screenshot.js +13 -10
- package/lib/tools/snapshot.js +3 -8
- package/lib/tools/tool.js +5 -4
- package/lib/tools/utils.js +0 -7
- package/lib/tools/wait.js +3 -4
- package/lib/{javascript.js → utils/codegen.js} +1 -1
- package/lib/{fileUtils.js → utils/fileUtils.js} +6 -2
- package/lib/utils/guid.js +22 -0
- package/lib/{package.js → utils/package.js} +1 -1
- package/package.json +9 -9
- package/lib/extension/main.js +0 -33
- /package/lib/{httpServer.js → utils/httpServer.js} +0 -0
- /package/lib/{log.js → utils/log.js} +0 -0
- /package/lib/{manualPromise.js → utils/manualPromise.js} +0 -0
package/lib/response.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
+
import { renderModalStates } from './tab.js';
|
|
16
17
|
export class Response {
|
|
17
18
|
_result = [];
|
|
18
19
|
_code = [];
|
|
@@ -20,9 +21,10 @@ export class Response {
|
|
|
20
21
|
_context;
|
|
21
22
|
_includeSnapshot = false;
|
|
22
23
|
_includeTabs = false;
|
|
23
|
-
|
|
24
|
+
_tabSnapshot;
|
|
24
25
|
toolName;
|
|
25
26
|
toolArgs;
|
|
27
|
+
_isError;
|
|
26
28
|
constructor(context, toolName, toolArgs) {
|
|
27
29
|
this._context = context;
|
|
28
30
|
this.toolName = toolName;
|
|
@@ -31,6 +33,13 @@ export class Response {
|
|
|
31
33
|
addResult(result) {
|
|
32
34
|
this._result.push(result);
|
|
33
35
|
}
|
|
36
|
+
addError(error) {
|
|
37
|
+
this._result.push(error);
|
|
38
|
+
this._isError = true;
|
|
39
|
+
}
|
|
40
|
+
isError() {
|
|
41
|
+
return this._isError;
|
|
42
|
+
}
|
|
34
43
|
result() {
|
|
35
44
|
return this._result.join('\n');
|
|
36
45
|
}
|
|
@@ -52,16 +61,18 @@ export class Response {
|
|
|
52
61
|
setIncludeTabs() {
|
|
53
62
|
this._includeTabs = true;
|
|
54
63
|
}
|
|
55
|
-
async
|
|
56
|
-
|
|
57
|
-
|
|
64
|
+
async finish() {
|
|
65
|
+
// All the async snapshotting post-action is happening here.
|
|
66
|
+
// Everything below should race against modal states.
|
|
58
67
|
if (this._includeSnapshot && this._context.currentTab())
|
|
59
|
-
this.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return this._snapshot;
|
|
68
|
+
this._tabSnapshot = await this._context.currentTabOrDie().captureSnapshot();
|
|
69
|
+
for (const tab of this._context.tabs())
|
|
70
|
+
await tab.updateTitle();
|
|
63
71
|
}
|
|
64
|
-
|
|
72
|
+
tabSnapshot() {
|
|
73
|
+
return this._tabSnapshot;
|
|
74
|
+
}
|
|
75
|
+
serialize() {
|
|
65
76
|
const response = [];
|
|
66
77
|
// Start with command result.
|
|
67
78
|
if (this._result.length) {
|
|
@@ -79,11 +90,16 @@ ${this._code.join('\n')}
|
|
|
79
90
|
}
|
|
80
91
|
// List browser tabs.
|
|
81
92
|
if (this._includeSnapshot || this._includeTabs)
|
|
82
|
-
response.push(...(
|
|
93
|
+
response.push(...renderTabsMarkdown(this._context.tabs(), this._includeTabs));
|
|
83
94
|
// Add snapshot if provided.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
response.push(
|
|
95
|
+
if (this._tabSnapshot?.modalStates.length) {
|
|
96
|
+
response.push(...renderModalStates(this._context, this._tabSnapshot.modalStates));
|
|
97
|
+
response.push('');
|
|
98
|
+
}
|
|
99
|
+
else if (this._tabSnapshot) {
|
|
100
|
+
response.push(renderTabSnapshot(this._tabSnapshot));
|
|
101
|
+
response.push('');
|
|
102
|
+
}
|
|
87
103
|
// Main response part
|
|
88
104
|
const content = [
|
|
89
105
|
{ type: 'text', text: response.join('\n') },
|
|
@@ -93,6 +109,57 @@ ${this._code.join('\n')}
|
|
|
93
109
|
for (const image of this._images)
|
|
94
110
|
content.push({ type: 'image', data: image.data.toString('base64'), mimeType: image.contentType });
|
|
95
111
|
}
|
|
96
|
-
return { content };
|
|
112
|
+
return { content, isError: this._isError };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function renderTabSnapshot(tabSnapshot) {
|
|
116
|
+
const lines = [];
|
|
117
|
+
if (tabSnapshot.consoleMessages.length) {
|
|
118
|
+
lines.push(`### New console messages`);
|
|
119
|
+
for (const message of tabSnapshot.consoleMessages)
|
|
120
|
+
lines.push(`- ${trim(message.toString(), 100)}`);
|
|
121
|
+
lines.push('');
|
|
122
|
+
}
|
|
123
|
+
if (tabSnapshot.downloads.length) {
|
|
124
|
+
lines.push(`### Downloads`);
|
|
125
|
+
for (const entry of tabSnapshot.downloads) {
|
|
126
|
+
if (entry.finished)
|
|
127
|
+
lines.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
|
128
|
+
else
|
|
129
|
+
lines.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
|
130
|
+
}
|
|
131
|
+
lines.push('');
|
|
132
|
+
}
|
|
133
|
+
lines.push(`### Page state`);
|
|
134
|
+
lines.push(`- Page URL: ${tabSnapshot.url}`);
|
|
135
|
+
lines.push(`- Page Title: ${tabSnapshot.title}`);
|
|
136
|
+
lines.push(`- Page Snapshot:`);
|
|
137
|
+
lines.push('```yaml');
|
|
138
|
+
lines.push(tabSnapshot.ariaSnapshot);
|
|
139
|
+
lines.push('```');
|
|
140
|
+
return lines.join('\n');
|
|
141
|
+
}
|
|
142
|
+
function renderTabsMarkdown(tabs, force = false) {
|
|
143
|
+
if (tabs.length === 1 && !force)
|
|
144
|
+
return [];
|
|
145
|
+
if (!tabs.length) {
|
|
146
|
+
return [
|
|
147
|
+
'### Open tabs',
|
|
148
|
+
'No open tabs. Use the "browser_navigate" tool to navigate to a page first.',
|
|
149
|
+
'',
|
|
150
|
+
];
|
|
97
151
|
}
|
|
152
|
+
const lines = ['### Open tabs'];
|
|
153
|
+
for (let i = 0; i < tabs.length; i++) {
|
|
154
|
+
const tab = tabs[i];
|
|
155
|
+
const current = tab.isCurrentTab() ? ' (current)' : '';
|
|
156
|
+
lines.push(`- ${i}:${current} [${tab.lastTitle()}] (${tab.page.url()})`);
|
|
157
|
+
}
|
|
158
|
+
lines.push('');
|
|
159
|
+
return lines;
|
|
160
|
+
}
|
|
161
|
+
function trim(text, maxLength) {
|
|
162
|
+
if (text.length <= maxLength)
|
|
163
|
+
return text;
|
|
164
|
+
return text.slice(0, maxLength) + '...';
|
|
98
165
|
}
|
package/lib/sessionLog.js
CHANGED
|
@@ -15,56 +15,107 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import fs from 'fs';
|
|
17
17
|
import path from 'path';
|
|
18
|
+
import { logUnhandledError } from './utils/log.js';
|
|
18
19
|
import { outputFile } from './config.js';
|
|
19
|
-
let sessionOrdinal = 0;
|
|
20
20
|
export class SessionLog {
|
|
21
21
|
_folder;
|
|
22
22
|
_file;
|
|
23
23
|
_ordinal = 0;
|
|
24
|
+
_pendingEntries = [];
|
|
25
|
+
_sessionFileQueue = Promise.resolve();
|
|
26
|
+
_flushEntriesTimeout;
|
|
24
27
|
constructor(sessionFolder) {
|
|
25
28
|
this._folder = sessionFolder;
|
|
26
29
|
this._file = path.join(this._folder, 'session.md');
|
|
27
30
|
}
|
|
28
|
-
static async create(config) {
|
|
29
|
-
const sessionFolder = await outputFile(config, `session-${
|
|
31
|
+
static async create(config, rootPath) {
|
|
32
|
+
const sessionFolder = await outputFile(config, rootPath, `session-${Date.now()}`);
|
|
30
33
|
await fs.promises.mkdir(sessionFolder, { recursive: true });
|
|
31
34
|
// eslint-disable-next-line no-console
|
|
32
35
|
console.error(`Session: ${sessionFolder}`);
|
|
33
36
|
return new SessionLog(sessionFolder);
|
|
34
37
|
}
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
38
|
+
logResponse(response) {
|
|
39
|
+
const entry = {
|
|
40
|
+
timestamp: performance.now(),
|
|
41
|
+
toolCall: {
|
|
42
|
+
toolName: response.toolName,
|
|
43
|
+
toolArgs: response.toolArgs,
|
|
44
|
+
result: response.result(),
|
|
45
|
+
isError: response.isError(),
|
|
46
|
+
},
|
|
47
|
+
code: response.code(),
|
|
48
|
+
tabSnapshot: response.tabSnapshot(),
|
|
49
|
+
};
|
|
50
|
+
this._appendEntry(entry);
|
|
51
|
+
}
|
|
52
|
+
logUserAction(action, tab, code, isUpdate) {
|
|
53
|
+
code = code.trim();
|
|
54
|
+
if (isUpdate) {
|
|
55
|
+
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
|
|
56
|
+
if (lastEntry.userAction?.name === action.name) {
|
|
57
|
+
lastEntry.userAction = action;
|
|
58
|
+
lastEntry.code = code;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
50
61
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
if (action.name === 'navigate') {
|
|
63
|
+
// Already logged at this location.
|
|
64
|
+
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
|
|
65
|
+
if (lastEntry?.tabSnapshot?.url === action.url)
|
|
66
|
+
return;
|
|
56
67
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
const entry = {
|
|
69
|
+
timestamp: performance.now(),
|
|
70
|
+
userAction: action,
|
|
71
|
+
code,
|
|
72
|
+
tabSnapshot: {
|
|
73
|
+
url: tab.page.url(),
|
|
74
|
+
title: '',
|
|
75
|
+
ariaSnapshot: action.ariaSnapshot || '',
|
|
76
|
+
modalStates: [],
|
|
77
|
+
consoleMessages: [],
|
|
78
|
+
downloads: [],
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
this._appendEntry(entry);
|
|
82
|
+
}
|
|
83
|
+
_appendEntry(entry) {
|
|
84
|
+
this._pendingEntries.push(entry);
|
|
85
|
+
if (this._flushEntriesTimeout)
|
|
86
|
+
clearTimeout(this._flushEntriesTimeout);
|
|
87
|
+
this._flushEntriesTimeout = setTimeout(() => this._flushEntries(), 1000);
|
|
88
|
+
}
|
|
89
|
+
async _flushEntries() {
|
|
90
|
+
clearTimeout(this._flushEntriesTimeout);
|
|
91
|
+
const entries = this._pendingEntries;
|
|
92
|
+
this._pendingEntries = [];
|
|
93
|
+
const lines = [''];
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
const ordinal = (++this._ordinal).toString().padStart(3, '0');
|
|
96
|
+
if (entry.toolCall) {
|
|
97
|
+
lines.push(`### Tool call: ${entry.toolCall.toolName}`, `- Args`, '```json', JSON.stringify(entry.toolCall.toolArgs, null, 2), '```');
|
|
98
|
+
if (entry.toolCall.result) {
|
|
99
|
+
lines.push(entry.toolCall.isError ? `- Error` : `- Result`, '```', entry.toolCall.result, '```');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (entry.userAction) {
|
|
103
|
+
const actionData = { ...entry.userAction };
|
|
104
|
+
delete actionData.ariaSnapshot;
|
|
105
|
+
delete actionData.selector;
|
|
106
|
+
delete actionData.signals;
|
|
107
|
+
lines.push(`### User action: ${entry.userAction.name}`, `- Args`, '```json', JSON.stringify(actionData, null, 2), '```');
|
|
108
|
+
}
|
|
109
|
+
if (entry.code) {
|
|
110
|
+
lines.push(`- Code`, '```js', entry.code, '```');
|
|
111
|
+
}
|
|
112
|
+
if (entry.tabSnapshot) {
|
|
113
|
+
const fileName = `${ordinal}.snapshot.yml`;
|
|
114
|
+
fs.promises.writeFile(path.join(this._folder, fileName), entry.tabSnapshot.ariaSnapshot).catch(logUnhandledError);
|
|
115
|
+
lines.push(`- Snapshot: ${fileName}`);
|
|
116
|
+
}
|
|
117
|
+
lines.push('', '');
|
|
61
118
|
}
|
|
62
|
-
|
|
63
|
-
await fs.promises.appendFile(this._file, lines.join('\n'));
|
|
119
|
+
this._sessionFileQueue = this._sessionFileQueue.then(() => fs.promises.appendFile(this._file, lines.join('\n')));
|
|
64
120
|
}
|
|
65
121
|
}
|
|
66
|
-
function extension(contentType) {
|
|
67
|
-
if (contentType === 'image/jpeg')
|
|
68
|
-
return 'jpg';
|
|
69
|
-
return 'png';
|
|
70
|
-
}
|
package/lib/tab.js
CHANGED
|
@@ -15,15 +15,15 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { EventEmitter } from 'events';
|
|
17
17
|
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
|
18
|
-
import { logUnhandledError } from './log.js';
|
|
19
|
-
import { ManualPromise } from './manualPromise.js';
|
|
20
|
-
import { outputFile } from './config.js';
|
|
18
|
+
import { logUnhandledError } from './utils/log.js';
|
|
19
|
+
import { ManualPromise } from './utils/manualPromise.js';
|
|
21
20
|
export const TabEvents = {
|
|
22
21
|
modalState: 'modalState'
|
|
23
22
|
};
|
|
24
23
|
export class Tab extends EventEmitter {
|
|
25
24
|
context;
|
|
26
25
|
page;
|
|
26
|
+
_lastTitle = 'about:blank';
|
|
27
27
|
_consoleMessages = [];
|
|
28
28
|
_recentConsoleMessages = [];
|
|
29
29
|
_requests = new Map();
|
|
@@ -53,6 +53,10 @@ export class Tab extends EventEmitter {
|
|
|
53
53
|
});
|
|
54
54
|
page.setDefaultNavigationTimeout(60000);
|
|
55
55
|
page.setDefaultTimeout(5000);
|
|
56
|
+
page[tabSymbol] = this;
|
|
57
|
+
}
|
|
58
|
+
static forPage(page) {
|
|
59
|
+
return page[tabSymbol];
|
|
56
60
|
}
|
|
57
61
|
modalStates() {
|
|
58
62
|
return this._modalStates;
|
|
@@ -65,14 +69,7 @@ export class Tab extends EventEmitter {
|
|
|
65
69
|
this._modalStates = this._modalStates.filter(state => state !== modalState);
|
|
66
70
|
}
|
|
67
71
|
modalStatesMarkdown() {
|
|
68
|
-
|
|
69
|
-
if (this._modalStates.length === 0)
|
|
70
|
-
result.push('- There is no modal state present');
|
|
71
|
-
for (const state of this._modalStates) {
|
|
72
|
-
const tool = this.context.tools.filter(tool => 'clearsModalState' in tool).find(tool => tool.clearsModalState === state.type);
|
|
73
|
-
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
|
74
|
-
}
|
|
75
|
-
return result;
|
|
72
|
+
return renderModalStates(this.context, this.modalStates());
|
|
76
73
|
}
|
|
77
74
|
_dialogShown(dialog) {
|
|
78
75
|
this.setModalState({
|
|
@@ -85,7 +82,7 @@ export class Tab extends EventEmitter {
|
|
|
85
82
|
const entry = {
|
|
86
83
|
download,
|
|
87
84
|
finished: false,
|
|
88
|
-
outputFile: await
|
|
85
|
+
outputFile: await this.context.outputFile(download.suggestedFilename())
|
|
89
86
|
};
|
|
90
87
|
this._downloads.push(entry);
|
|
91
88
|
await download.saveAs(entry.outputFile);
|
|
@@ -104,8 +101,16 @@ export class Tab extends EventEmitter {
|
|
|
104
101
|
this._clearCollectedArtifacts();
|
|
105
102
|
this._onPageClose(this);
|
|
106
103
|
}
|
|
107
|
-
async
|
|
108
|
-
|
|
104
|
+
async updateTitle() {
|
|
105
|
+
await this._raceAgainstModalStates(async () => {
|
|
106
|
+
this._lastTitle = await callOnPageNoTrace(this.page, page => page.title());
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
lastTitle() {
|
|
110
|
+
return this._lastTitle;
|
|
111
|
+
}
|
|
112
|
+
isCurrentTab() {
|
|
113
|
+
return this === this.context.currentTab();
|
|
109
114
|
}
|
|
110
115
|
async waitForLoadState(state, options) {
|
|
111
116
|
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(logUnhandledError));
|
|
@@ -142,54 +147,46 @@ export class Tab extends EventEmitter {
|
|
|
142
147
|
requests() {
|
|
143
148
|
return this._requests;
|
|
144
149
|
}
|
|
145
|
-
_takeRecentConsoleMarkdown() {
|
|
146
|
-
if (!this._recentConsoleMessages.length)
|
|
147
|
-
return [];
|
|
148
|
-
const result = this._recentConsoleMessages.map(message => {
|
|
149
|
-
return `- ${trim(message.toString(), 100)}`;
|
|
150
|
-
});
|
|
151
|
-
return [`### New console messages`, ...result, ''];
|
|
152
|
-
}
|
|
153
|
-
_listDownloadsMarkdown() {
|
|
154
|
-
if (!this._downloads.length)
|
|
155
|
-
return [];
|
|
156
|
-
const result = ['### Downloads'];
|
|
157
|
-
for (const entry of this._downloads) {
|
|
158
|
-
if (entry.finished)
|
|
159
|
-
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
|
160
|
-
else
|
|
161
|
-
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
|
162
|
-
}
|
|
163
|
-
result.push('');
|
|
164
|
-
return result;
|
|
165
|
-
}
|
|
166
150
|
async captureSnapshot() {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
result.push(...this.modalStatesMarkdown());
|
|
170
|
-
return result.join('\n');
|
|
171
|
-
}
|
|
172
|
-
result.push(...this._takeRecentConsoleMarkdown());
|
|
173
|
-
result.push(...this._listDownloadsMarkdown());
|
|
174
|
-
await this._raceAgainstModalStates(async () => {
|
|
151
|
+
let tabSnapshot;
|
|
152
|
+
const modalStates = await this._raceAgainstModalStates(async () => {
|
|
175
153
|
const snapshot = await this.page._snapshotForAI();
|
|
176
|
-
|
|
154
|
+
tabSnapshot = {
|
|
155
|
+
url: this.page.url(),
|
|
156
|
+
title: await this.page.title(),
|
|
157
|
+
ariaSnapshot: snapshot,
|
|
158
|
+
modalStates: [],
|
|
159
|
+
consoleMessages: [],
|
|
160
|
+
downloads: this._downloads,
|
|
161
|
+
};
|
|
177
162
|
});
|
|
178
|
-
|
|
163
|
+
if (tabSnapshot) {
|
|
164
|
+
// Assign console message late so that we did not lose any to modal state.
|
|
165
|
+
tabSnapshot.consoleMessages = this._recentConsoleMessages;
|
|
166
|
+
this._recentConsoleMessages = [];
|
|
167
|
+
}
|
|
168
|
+
return tabSnapshot ?? {
|
|
169
|
+
url: this.page.url(),
|
|
170
|
+
title: '',
|
|
171
|
+
ariaSnapshot: '',
|
|
172
|
+
modalStates,
|
|
173
|
+
consoleMessages: [],
|
|
174
|
+
downloads: [],
|
|
175
|
+
};
|
|
179
176
|
}
|
|
180
177
|
_javaScriptBlocked() {
|
|
181
178
|
return this._modalStates.some(state => state.type === 'dialog');
|
|
182
179
|
}
|
|
183
180
|
async _raceAgainstModalStates(action) {
|
|
184
181
|
if (this.modalStates().length)
|
|
185
|
-
return this.modalStates()
|
|
182
|
+
return this.modalStates();
|
|
186
183
|
const promise = new ManualPromise();
|
|
187
|
-
const listener = (modalState) => promise.resolve(modalState);
|
|
184
|
+
const listener = (modalState) => promise.resolve([modalState]);
|
|
188
185
|
this.once(TabEvents.modalState, listener);
|
|
189
186
|
return await Promise.race([
|
|
190
187
|
action().then(() => {
|
|
191
188
|
this.off(TabEvents.modalState, listener);
|
|
192
|
-
return
|
|
189
|
+
return [];
|
|
193
190
|
}),
|
|
194
191
|
promise,
|
|
195
192
|
]);
|
|
@@ -239,8 +236,14 @@ function pageErrorToConsoleMessage(errorOrValue) {
|
|
|
239
236
|
toString: () => String(errorOrValue),
|
|
240
237
|
};
|
|
241
238
|
}
|
|
242
|
-
function
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
239
|
+
export function renderModalStates(context, modalStates) {
|
|
240
|
+
const result = ['### Modal state'];
|
|
241
|
+
if (modalStates.length === 0)
|
|
242
|
+
result.push('- There is no modal state present');
|
|
243
|
+
for (const state of modalStates) {
|
|
244
|
+
const tool = context.tools.filter(tool => 'clearsModalState' in tool).find(tool => tool.clearsModalState === state.type);
|
|
245
|
+
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
|
246
|
+
}
|
|
247
|
+
return result;
|
|
246
248
|
}
|
|
249
|
+
const tabSymbol = Symbol('tabSymbol');
|
package/lib/tools/common.js
CHANGED
|
@@ -43,7 +43,6 @@ const resize = defineTabTool({
|
|
|
43
43
|
type: 'readOnly',
|
|
44
44
|
},
|
|
45
45
|
handle: async (tab, params, response) => {
|
|
46
|
-
response.addCode(`// Resize browser window to ${params.width}x${params.height}`);
|
|
47
46
|
response.addCode(`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`);
|
|
48
47
|
await tab.waitForCompletion(async () => {
|
|
49
48
|
await tab.page.setViewportSize({ width: params.width, height: params.height });
|
package/lib/tools/evaluate.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { z } from 'zod';
|
|
17
17
|
import { defineTabTool } from './tool.js';
|
|
18
|
-
import * as javascript from '../
|
|
18
|
+
import * as javascript from '../utils/codegen.js';
|
|
19
19
|
import { generateLocator } from './utils.js';
|
|
20
20
|
const evaluateSchema = z.object({
|
|
21
21
|
function: z.string().describe('() => { /* code */ } or (element) => { /* code */ } when element is provided'),
|
package/lib/tools/files.js
CHANGED
|
@@ -31,7 +31,6 @@ const uploadFile = defineTabTool({
|
|
|
31
31
|
const modalState = tab.modalStates().find(state => state.type === 'fileChooser');
|
|
32
32
|
if (!modalState)
|
|
33
33
|
throw new Error('No file chooser visible');
|
|
34
|
-
response.addCode(`// Select files for upload`);
|
|
35
34
|
response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`);
|
|
36
35
|
tab.clearModalState(modalState);
|
|
37
36
|
await tab.waitForCompletion(async () => {
|
package/lib/tools/keyboard.js
CHANGED
|
@@ -17,7 +17,7 @@ import { z } from 'zod';
|
|
|
17
17
|
import { defineTabTool } from './tool.js';
|
|
18
18
|
import { elementSchema } from './snapshot.js';
|
|
19
19
|
import { generateLocator } from './utils.js';
|
|
20
|
-
import * as javascript from '../
|
|
20
|
+
import * as javascript from '../utils/codegen.js';
|
|
21
21
|
const pressKey = defineTabTool({
|
|
22
22
|
capability: 'core',
|
|
23
23
|
schema: {
|
|
@@ -57,12 +57,10 @@ const type = defineTabTool({
|
|
|
57
57
|
await tab.waitForCompletion(async () => {
|
|
58
58
|
if (params.slowly) {
|
|
59
59
|
response.setIncludeSnapshot();
|
|
60
|
-
response.addCode(`// Press "${params.text}" sequentially into "${params.element}"`);
|
|
61
60
|
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
|
|
62
61
|
await locator.pressSequentially(params.text);
|
|
63
62
|
}
|
|
64
63
|
else {
|
|
65
|
-
response.addCode(`// Fill "${params.text}" into "${params.element}"`);
|
|
66
64
|
response.addCode(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
|
|
67
65
|
await locator.fill(params.text);
|
|
68
66
|
}
|
package/lib/tools/navigate.js
CHANGED
|
@@ -30,7 +30,6 @@ const navigate = defineTool({
|
|
|
30
30
|
const tab = await context.ensureTab();
|
|
31
31
|
await tab.navigate(params.url);
|
|
32
32
|
response.setIncludeSnapshot();
|
|
33
|
-
response.addCode(`// Navigate to ${params.url}`);
|
|
34
33
|
response.addCode(`await page.goto('${params.url}');`);
|
|
35
34
|
},
|
|
36
35
|
});
|
|
@@ -46,7 +45,6 @@ const goBack = defineTabTool({
|
|
|
46
45
|
handle: async (tab, params, response) => {
|
|
47
46
|
await tab.page.goBack();
|
|
48
47
|
response.setIncludeSnapshot();
|
|
49
|
-
response.addCode(`// Navigate back`);
|
|
50
48
|
response.addCode(`await page.goBack();`);
|
|
51
49
|
},
|
|
52
50
|
});
|
|
@@ -62,7 +60,6 @@ const goForward = defineTabTool({
|
|
|
62
60
|
handle: async (tab, params, response) => {
|
|
63
61
|
await tab.page.goForward();
|
|
64
62
|
response.setIncludeSnapshot();
|
|
65
|
-
response.addCode(`// Navigate forward`);
|
|
66
63
|
response.addCode(`await page.goForward();`);
|
|
67
64
|
},
|
|
68
65
|
});
|
package/lib/tools/pdf.js
CHANGED
|
@@ -15,8 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { z } from 'zod';
|
|
17
17
|
import { defineTabTool } from './tool.js';
|
|
18
|
-
import * as javascript from '../
|
|
19
|
-
import { outputFile } from '../config.js';
|
|
18
|
+
import * as javascript from '../utils/codegen.js';
|
|
20
19
|
const pdfSchema = z.object({
|
|
21
20
|
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
|
|
22
21
|
});
|
|
@@ -30,8 +29,7 @@ const pdf = defineTabTool({
|
|
|
30
29
|
type: 'readOnly',
|
|
31
30
|
},
|
|
32
31
|
handle: async (tab, params, response) => {
|
|
33
|
-
const fileName = await
|
|
34
|
-
response.addCode(`// Save page as ${fileName}`);
|
|
32
|
+
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.pdf`);
|
|
35
33
|
response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`);
|
|
36
34
|
response.addResult(`Saved page as ${fileName}`);
|
|
37
35
|
await tab.page.pdf({ path: fileName });
|
package/lib/tools/screenshot.js
CHANGED
|
@@ -15,11 +15,10 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { z } from 'zod';
|
|
17
17
|
import { defineTabTool } from './tool.js';
|
|
18
|
-
import * as javascript from '../
|
|
19
|
-
import { outputFile } from '../config.js';
|
|
18
|
+
import * as javascript from '../utils/codegen.js';
|
|
20
19
|
import { generateLocator } from './utils.js';
|
|
21
20
|
const screenshotSchema = z.object({
|
|
22
|
-
|
|
21
|
+
type: z.enum(['png', 'jpeg']).default('png').describe('Image format for the screenshot. Default is png.'),
|
|
23
22
|
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
|
|
24
23
|
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
|
|
25
24
|
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
|
|
@@ -45,11 +44,11 @@ const screenshot = defineTabTool({
|
|
|
45
44
|
type: 'readOnly',
|
|
46
45
|
},
|
|
47
46
|
handle: async (tab, params, response) => {
|
|
48
|
-
const fileType = params.
|
|
49
|
-
const fileName = await
|
|
47
|
+
const fileType = params.type || 'png';
|
|
48
|
+
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
|
|
50
49
|
const options = {
|
|
51
50
|
type: fileType,
|
|
52
|
-
quality: fileType === 'png' ? undefined :
|
|
51
|
+
quality: fileType === 'png' ? undefined : 90,
|
|
53
52
|
scale: 'css',
|
|
54
53
|
path: fileName,
|
|
55
54
|
...(params.fullPage !== undefined && { fullPage: params.fullPage })
|
|
@@ -65,10 +64,14 @@ const screenshot = defineTabTool({
|
|
|
65
64
|
response.addCode(`await page.screenshot(${javascript.formatObject(options)});`);
|
|
66
65
|
const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
|
67
66
|
response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
// https://github.com/microsoft/playwright-mcp/issues/817
|
|
68
|
+
// Never return large images to LLM, saving them to the file system is enough.
|
|
69
|
+
if (!params.fullPage) {
|
|
70
|
+
response.addImage({
|
|
71
|
+
contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
|
72
|
+
data: buffer
|
|
73
|
+
});
|
|
74
|
+
}
|
|
72
75
|
}
|
|
73
76
|
});
|
|
74
77
|
export default [
|
package/lib/tools/snapshot.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { z } from 'zod';
|
|
17
17
|
import { defineTabTool, defineTool } from './tool.js';
|
|
18
|
-
import * as javascript from '../
|
|
18
|
+
import * as javascript from '../utils/codegen.js';
|
|
19
19
|
import { generateLocator } from './utils.js';
|
|
20
20
|
const snapshot = defineTool({
|
|
21
21
|
capability: 'core',
|
|
@@ -53,14 +53,10 @@ const click = defineTabTool({
|
|
|
53
53
|
const locator = await tab.refLocator(params);
|
|
54
54
|
const button = params.button;
|
|
55
55
|
const buttonAttr = button ? `{ button: '${button}' }` : '';
|
|
56
|
-
if (params.doubleClick)
|
|
57
|
-
response.addCode(`// Double click ${params.element}`);
|
|
56
|
+
if (params.doubleClick)
|
|
58
57
|
response.addCode(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
|
|
59
|
-
|
|
60
|
-
else {
|
|
61
|
-
response.addCode(`// Click ${params.element}`);
|
|
58
|
+
else
|
|
62
59
|
response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
|
|
63
|
-
}
|
|
64
60
|
await tab.waitForCompletion(async () => {
|
|
65
61
|
if (params.doubleClick)
|
|
66
62
|
await locator.dblclick({ button });
|
|
@@ -128,7 +124,6 @@ const selectOption = defineTabTool({
|
|
|
128
124
|
handle: async (tab, params, response) => {
|
|
129
125
|
response.setIncludeSnapshot();
|
|
130
126
|
const locator = await tab.refLocator(params);
|
|
131
|
-
response.addCode(`// Select options [${params.values.join(', ')}] in ${params.element}`);
|
|
132
127
|
response.addCode(`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`);
|
|
133
128
|
await tab.waitForCompletion(async () => {
|
|
134
129
|
await locator.selectOption(params.values);
|
package/lib/tools/tool.js
CHANGED
|
@@ -23,10 +23,11 @@ export function defineTabTool(tool) {
|
|
|
23
23
|
const tab = context.currentTabOrDie();
|
|
24
24
|
const modalStates = tab.modalStates().map(state => state.type);
|
|
25
25
|
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
|
|
26
|
-
|
|
27
|
-
if (!tool.clearsModalState && modalStates.length)
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
response.addError(`Error: The tool "${tool.schema.name}" can only be used when there is related modal state present.\n` + tab.modalStatesMarkdown().join('\n'));
|
|
27
|
+
else if (!tool.clearsModalState && modalStates.length)
|
|
28
|
+
response.addError(`Error: Tool "${tool.schema.name}" does not handle the modal state.\n` + tab.modalStatesMarkdown().join('\n'));
|
|
29
|
+
else
|
|
30
|
+
return tool.handle(tab, params, response);
|
|
30
31
|
},
|
|
31
32
|
};
|
|
32
33
|
}
|
package/lib/tools/utils.js
CHANGED
|
@@ -60,13 +60,6 @@ export async function waitForCompletion(tab, callback) {
|
|
|
60
60
|
dispose();
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
-
export function sanitizeForFilePath(s) {
|
|
64
|
-
const sanitize = (s) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
|
65
|
-
const separator = s.lastIndexOf('.');
|
|
66
|
-
if (separator === -1)
|
|
67
|
-
return sanitize(s);
|
|
68
|
-
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
|
|
69
|
-
}
|
|
70
63
|
export async function generateLocator(locator) {
|
|
71
64
|
try {
|
|
72
65
|
const { resolvedSelector } = await locator._resolveSelector();
|