playwright-codegen-pro-core 1.0.4 → 1.0.5
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.
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
5
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
8
|
var __export = (target, all) => {
|
|
7
9
|
for (var name in all)
|
|
@@ -15,15 +17,25 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
15
17
|
}
|
|
16
18
|
return to;
|
|
17
19
|
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
18
28
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
29
|
var browserBackend_exports = {};
|
|
20
30
|
__export(browserBackend_exports, {
|
|
21
31
|
BrowserBackend: () => BrowserBackend
|
|
22
32
|
});
|
|
23
33
|
module.exports = __toCommonJS(browserBackend_exports);
|
|
34
|
+
var import_path = __toESM(require("path"));
|
|
24
35
|
var import_context = require("./context");
|
|
25
36
|
var import_response = require("./response");
|
|
26
37
|
var import_sessionLog = require("./sessionLog");
|
|
38
|
+
var import_mcpSessionRecorder = require("./mcpSessionRecorder");
|
|
27
39
|
var import_utilsBundle = require("../../utilsBundle");
|
|
28
40
|
class BrowserBackend {
|
|
29
41
|
constructor(config, browserContext, tools) {
|
|
@@ -38,8 +50,16 @@ class BrowserBackend {
|
|
|
38
50
|
sessionLog: this._sessionLog,
|
|
39
51
|
cwd: clientInfo.cwd
|
|
40
52
|
});
|
|
53
|
+
const cwd = clientInfo.cwd || process.cwd();
|
|
54
|
+
this._recorder = new import_mcpSessionRecorder.McpSessionRecorder(this.browserContext, {
|
|
55
|
+
cwd,
|
|
56
|
+
scenarioName: "Recorded via Playwright Codegen Pro MCP",
|
|
57
|
+
specFile: import_path.default.join(cwd, "tests", "mcp-session.spec.ts"),
|
|
58
|
+
secrets: this._config.secrets
|
|
59
|
+
});
|
|
41
60
|
}
|
|
42
61
|
async dispose() {
|
|
62
|
+
await this._recorder?.dispose().catch((e) => (0, import_utilsBundle.debug)("pw:tools:error")(e));
|
|
43
63
|
await this._context?.dispose().catch((e) => (0, import_utilsBundle.debug)("pw:tools:error")(e));
|
|
44
64
|
}
|
|
45
65
|
async callTool(name, rawArguments = {}) {
|
|
@@ -56,12 +76,15 @@ Tool "${name}" not found` }],
|
|
|
56
76
|
const context = this._context;
|
|
57
77
|
const response = new import_response.Response(context, name, parsedArguments, cwd);
|
|
58
78
|
context.setRunningTool(name);
|
|
79
|
+
this._recorder?.beginAction(name);
|
|
59
80
|
let responseObject;
|
|
60
81
|
try {
|
|
61
82
|
await tool.handle(context, parsedArguments, response);
|
|
62
83
|
responseObject = await response.serialize();
|
|
63
84
|
this._sessionLog?.logResponse(name, parsedArguments, responseObject);
|
|
85
|
+
this._recordAction(responseObject, cwd);
|
|
64
86
|
} catch (error) {
|
|
87
|
+
this._recorder?.completeAction(void 0);
|
|
65
88
|
return {
|
|
66
89
|
content: [{ type: "text", text: `### Error
|
|
67
90
|
${String(error)}` }],
|
|
@@ -72,6 +95,14 @@ ${String(error)}` }],
|
|
|
72
95
|
}
|
|
73
96
|
return responseObject;
|
|
74
97
|
}
|
|
98
|
+
_recordAction(responseObject, cwd) {
|
|
99
|
+
if (!this._recorder)
|
|
100
|
+
return;
|
|
101
|
+
const parsed = (0, import_response.parseResponse)(responseObject, cwd);
|
|
102
|
+
const pageUrl = parsed?.page?.match(/Page URL:\s*(\S+)/)?.[1];
|
|
103
|
+
const pageTitle = parsed?.page?.match(/Page Title:\s*(.+)/)?.[1]?.trim();
|
|
104
|
+
this._recorder.completeAction(parsed?.code, pageUrl, pageTitle);
|
|
105
|
+
}
|
|
75
106
|
}
|
|
76
107
|
// Annotate the CommonJS export names for ESM import in node:
|
|
77
108
|
0 && (module.exports = {
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var mcpNetworkCapture_exports = {};
|
|
20
|
+
__export(mcpNetworkCapture_exports, {
|
|
21
|
+
McpNetworkCapture: () => McpNetworkCapture
|
|
22
|
+
});
|
|
23
|
+
module.exports = __toCommonJS(mcpNetworkCapture_exports);
|
|
24
|
+
const debugNet = !!process.env.PW_DEBUG_NETWORK;
|
|
25
|
+
class McpNetworkCapture {
|
|
26
|
+
constructor(context, getCurrentAction, onUpdate) {
|
|
27
|
+
this._pending = /* @__PURE__ */ new Map();
|
|
28
|
+
this._pollTracker = /* @__PURE__ */ new Map();
|
|
29
|
+
this._listeners = [];
|
|
30
|
+
this._context = context;
|
|
31
|
+
this._getCurrentAction = getCurrentAction;
|
|
32
|
+
this._onUpdate = onUpdate;
|
|
33
|
+
}
|
|
34
|
+
start() {
|
|
35
|
+
const onRequest = (req) => this._onRequest(req);
|
|
36
|
+
const onResponse = (res) => void this._onResponse(res);
|
|
37
|
+
const onFailed = (req) => this._onFailed(req);
|
|
38
|
+
this._context.on("request", onRequest);
|
|
39
|
+
this._context.on("response", onResponse);
|
|
40
|
+
this._context.on("requestfailed", onFailed);
|
|
41
|
+
this._listeners.push(
|
|
42
|
+
() => this._context.off("request", onRequest),
|
|
43
|
+
() => this._context.off("response", onResponse),
|
|
44
|
+
() => this._context.off("requestfailed", onFailed)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
dispose() {
|
|
48
|
+
for (const remove of this._listeners)
|
|
49
|
+
remove();
|
|
50
|
+
this._listeners = [];
|
|
51
|
+
this._pending.clear();
|
|
52
|
+
}
|
|
53
|
+
// ─── Private ─────────────────────────────────────────────────────────────
|
|
54
|
+
_isNoiseUrl(url, method) {
|
|
55
|
+
if (method === "OPTIONS")
|
|
56
|
+
return true;
|
|
57
|
+
if (/\.(js|css|woff|woff2|ttf|eot|svg|png|jpg|jpeg|gif|ico|map)(\?|$)/i.test(url))
|
|
58
|
+
return true;
|
|
59
|
+
try {
|
|
60
|
+
const parsed = new URL(url);
|
|
61
|
+
if (/analytics|segment\.io|mixpanel|amplitude|hotjar|fullstory|heap\.io|intercom\.io|clarity\.ms|googletagmanager|google-analytics|facebook\.net|doubleclick|sentry\.io/.test(parsed.hostname))
|
|
62
|
+
return true;
|
|
63
|
+
if (/\/analytics\/|\/tracking\/|\/pixel|\/beacon|\/gen_204|\/log$|\/jserror/i.test(parsed.pathname))
|
|
64
|
+
return true;
|
|
65
|
+
if (/clients\d?\.google|play\.google\.com\/log|gstatic\.com|googleapis\.com\/.*\/log/i.test(url))
|
|
66
|
+
return true;
|
|
67
|
+
} catch {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
if (/refresh[_-]?token|token\/refresh|oauth\/token|auth\/refresh/i.test(url))
|
|
71
|
+
return true;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
_isPolling(key) {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const times = (this._pollTracker.get(key) ?? []).filter((t) => now - t < 3e4);
|
|
77
|
+
times.push(now);
|
|
78
|
+
this._pollTracker.set(key, times);
|
|
79
|
+
return times.length >= 3;
|
|
80
|
+
}
|
|
81
|
+
_graphqlOperationName(request) {
|
|
82
|
+
if (request.method() !== "POST")
|
|
83
|
+
return void 0;
|
|
84
|
+
try {
|
|
85
|
+
if (!/graphql/i.test(request.url()))
|
|
86
|
+
return void 0;
|
|
87
|
+
const buf = request.postDataBuffer();
|
|
88
|
+
if (!buf)
|
|
89
|
+
return void 0;
|
|
90
|
+
const body = JSON.parse(buf.toString("utf-8"));
|
|
91
|
+
return typeof body.operationName === "string" ? body.operationName : void 0;
|
|
92
|
+
} catch {
|
|
93
|
+
return void 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
_assignBucket(request, action, operationName) {
|
|
97
|
+
const url = request.url();
|
|
98
|
+
const method = request.method();
|
|
99
|
+
if (!["xhr", "fetch"].includes(request.resourceType()))
|
|
100
|
+
return "noise";
|
|
101
|
+
if (this._isNoiseUrl(url, method))
|
|
102
|
+
return "noise";
|
|
103
|
+
if (!action)
|
|
104
|
+
return "noise";
|
|
105
|
+
if (this._isPolling(`${operationName ?? url}|${method}`))
|
|
106
|
+
return "noise";
|
|
107
|
+
return /navigate/i.test(action.toolName) ? "pageLoad" : "direct";
|
|
108
|
+
}
|
|
109
|
+
_onRequest(request) {
|
|
110
|
+
const action = this._getCurrentAction();
|
|
111
|
+
const method = request.method();
|
|
112
|
+
const operationName = this._graphqlOperationName(request);
|
|
113
|
+
const bucket = this._assignBucket(request, action, operationName);
|
|
114
|
+
let requestBodySnippet;
|
|
115
|
+
if (bucket !== "noise" && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
|
|
116
|
+
const buf = request.postDataBuffer();
|
|
117
|
+
const text = buf?.toString("utf-8") ?? request.postData() ?? "";
|
|
118
|
+
if (text)
|
|
119
|
+
requestBodySnippet = text.slice(0, 500) + (text.length > 500 ? "..." : "");
|
|
120
|
+
}
|
|
121
|
+
const event = {
|
|
122
|
+
url: request.url(),
|
|
123
|
+
method,
|
|
124
|
+
bucket,
|
|
125
|
+
...operationName ? { operationName } : {},
|
|
126
|
+
...requestBodySnippet ? { requestBodySnippet } : {}
|
|
127
|
+
};
|
|
128
|
+
this._pending.set(request, event);
|
|
129
|
+
action?.events.push(event);
|
|
130
|
+
if (debugNet)
|
|
131
|
+
console.log(`[MCP-NC] ${method} ${event.url}${operationName ? ` (${operationName})` : ""} \u2192 ${bucket} (${action?.toolName ?? "none"})`);
|
|
132
|
+
}
|
|
133
|
+
async _onResponse(response) {
|
|
134
|
+
const request = response.request();
|
|
135
|
+
const event = this._pending.get(request);
|
|
136
|
+
if (!event)
|
|
137
|
+
return;
|
|
138
|
+
event.status = response.status();
|
|
139
|
+
if (event.bucket !== "noise") {
|
|
140
|
+
const ct = response.headers()["content-type"] ?? "";
|
|
141
|
+
if (/text|json|html/i.test(ct)) {
|
|
142
|
+
const buf = await response.body().catch(() => null);
|
|
143
|
+
if (buf)
|
|
144
|
+
event.bodySnippet = buf.toString("utf-8").slice(0, 500);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
this._pending.delete(request);
|
|
148
|
+
this._onUpdate();
|
|
149
|
+
}
|
|
150
|
+
_onFailed(request) {
|
|
151
|
+
const event = this._pending.get(request);
|
|
152
|
+
if (!event)
|
|
153
|
+
return;
|
|
154
|
+
event.aborted = true;
|
|
155
|
+
this._pending.delete(request);
|
|
156
|
+
this._onUpdate();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
160
|
+
0 && (module.exports = {
|
|
161
|
+
McpNetworkCapture
|
|
162
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
var mcpSessionRecorder_exports = {};
|
|
30
|
+
__export(mcpSessionRecorder_exports, {
|
|
31
|
+
McpSessionRecorder: () => McpSessionRecorder
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(mcpSessionRecorder_exports);
|
|
34
|
+
var import_fs = __toESM(require("fs"));
|
|
35
|
+
var import_path = __toESM(require("path"));
|
|
36
|
+
var import_mcpNetworkCapture = require("./mcpNetworkCapture");
|
|
37
|
+
const WRITE_THROTTLE_MS = 250;
|
|
38
|
+
const MAX_DIRECT = 20;
|
|
39
|
+
const MAX_PAGELOAD = 8;
|
|
40
|
+
const MAX_URL_LEN = 140;
|
|
41
|
+
function truncUrl(url) {
|
|
42
|
+
return url.length > MAX_URL_LEN ? url.slice(0, MAX_URL_LEN) + "\u2026" : url;
|
|
43
|
+
}
|
|
44
|
+
class McpSessionRecorder {
|
|
45
|
+
constructor(context, options) {
|
|
46
|
+
this._actions = [];
|
|
47
|
+
this._current = null;
|
|
48
|
+
this._sessionStart = Date.now();
|
|
49
|
+
this._disposed = false;
|
|
50
|
+
this._options = options;
|
|
51
|
+
this._sessionFile = import_path.default.join(options.cwd, ".playwright-session.md");
|
|
52
|
+
this._network = new import_mcpNetworkCapture.McpNetworkCapture(context, () => this._current, () => this._scheduleWrite());
|
|
53
|
+
this._network.start();
|
|
54
|
+
}
|
|
55
|
+
/** Called before a tool runs, so network events attribute to it. */
|
|
56
|
+
beginAction(toolName) {
|
|
57
|
+
this._current = { toolName, startTime: Date.now(), events: [] };
|
|
58
|
+
}
|
|
59
|
+
/** Called after a tool runs with the Playwright code it executed (if any). */
|
|
60
|
+
completeAction(code, pageUrl, pageTitle) {
|
|
61
|
+
const current = this._current;
|
|
62
|
+
if (!current)
|
|
63
|
+
return;
|
|
64
|
+
const trimmed = (code ?? "").trim();
|
|
65
|
+
if (trimmed) {
|
|
66
|
+
this._actions.push({
|
|
67
|
+
index: this._actions.length,
|
|
68
|
+
toolName: current.toolName,
|
|
69
|
+
code: trimmed,
|
|
70
|
+
pageUrl,
|
|
71
|
+
pageTitle,
|
|
72
|
+
startTime: current.startTime,
|
|
73
|
+
// Same array reference the network capture pushes into — late responses still land here.
|
|
74
|
+
networkEvents: current.events
|
|
75
|
+
});
|
|
76
|
+
} else if (current.events.length && this._actions.length) {
|
|
77
|
+
this._actions[this._actions.length - 1].networkEvents.push(...current.events);
|
|
78
|
+
}
|
|
79
|
+
this._scheduleWrite();
|
|
80
|
+
}
|
|
81
|
+
async dispose() {
|
|
82
|
+
if (this._disposed)
|
|
83
|
+
return;
|
|
84
|
+
this._disposed = true;
|
|
85
|
+
this._network.dispose();
|
|
86
|
+
if (this._writeTimer)
|
|
87
|
+
clearTimeout(this._writeTimer);
|
|
88
|
+
this._writeFiles();
|
|
89
|
+
}
|
|
90
|
+
// ─── Writing ─────────────────────────────────────────────────────────────
|
|
91
|
+
_scheduleWrite() {
|
|
92
|
+
if (this._disposed || this._writeTimer)
|
|
93
|
+
return;
|
|
94
|
+
this._writeTimer = setTimeout(() => {
|
|
95
|
+
this._writeTimer = void 0;
|
|
96
|
+
this._writeFiles();
|
|
97
|
+
}, WRITE_THROTTLE_MS);
|
|
98
|
+
}
|
|
99
|
+
_writeFiles() {
|
|
100
|
+
try {
|
|
101
|
+
import_fs.default.writeFileSync(this._sessionFile, this._redact(this._buildPrompt()), "utf-8");
|
|
102
|
+
const spec = this._redact(this._buildSpec());
|
|
103
|
+
import_fs.default.mkdirSync(import_path.default.dirname(this._options.specFile), { recursive: true });
|
|
104
|
+
import_fs.default.writeFileSync(this._options.specFile, spec, "utf-8");
|
|
105
|
+
} catch {
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
_redact(text) {
|
|
109
|
+
let out = text;
|
|
110
|
+
for (const value of Object.values(this._options.secrets ?? {})) {
|
|
111
|
+
if (value)
|
|
112
|
+
out = out.split(value).join("[redacted]");
|
|
113
|
+
}
|
|
114
|
+
out = out.replace(/("(?:password|token|secret|authorization|api[_-]?key|access[_-]?token|refresh[_-]?token)"\s*:\s*")[^"]*(")/gi, "$1[redacted]$2");
|
|
115
|
+
out = out.replace(/\bBearer\s+[A-Za-z0-9._\-]+/g, "Bearer [redacted]");
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
// ─── Prompt (.playwright-session.md) ─────────────────────────────────────
|
|
119
|
+
_buildPrompt() {
|
|
120
|
+
const relSpec = import_path.default.relative(this._options.cwd, this._options.specFile);
|
|
121
|
+
let session = "";
|
|
122
|
+
let lastPageUrl;
|
|
123
|
+
for (const action of this._actions) {
|
|
124
|
+
if (action.pageUrl && action.pageUrl !== lastPageUrl) {
|
|
125
|
+
session += `### Page: ${action.pageUrl}
|
|
126
|
+
`;
|
|
127
|
+
lastPageUrl = action.pageUrl;
|
|
128
|
+
}
|
|
129
|
+
const codeLine = action.code.split("\n")[0];
|
|
130
|
+
session += `- ${action.toolName}: ${codeLine} at t=${action.startTime - this._sessionStart}ms
|
|
131
|
+
`;
|
|
132
|
+
const direct = action.networkEvents.filter((e) => e.bucket === "direct");
|
|
133
|
+
const pageLoad = action.networkEvents.filter((e) => e.bucket === "pageLoad");
|
|
134
|
+
if (direct.length) {
|
|
135
|
+
session += ` API calls (direct):
|
|
136
|
+
`;
|
|
137
|
+
for (const e of direct.slice(0, MAX_DIRECT)) {
|
|
138
|
+
const op = e.operationName ? ` (${e.operationName})` : "";
|
|
139
|
+
const payload = e.requestBodySnippet ? ` (payload: ${e.requestBodySnippet})` : "";
|
|
140
|
+
const body = e.bodySnippet ? ` (body: ${e.bodySnippet})` : "";
|
|
141
|
+
session += ` - ${e.method} ${truncUrl(e.url)}${op}${payload} \u2192 ${e.status ?? (e.aborted ? "aborted" : "pending")}${body}
|
|
142
|
+
`;
|
|
143
|
+
}
|
|
144
|
+
if (direct.length > MAX_DIRECT)
|
|
145
|
+
session += ` - ...and ${direct.length - MAX_DIRECT} more
|
|
146
|
+
`;
|
|
147
|
+
}
|
|
148
|
+
if (pageLoad.length) {
|
|
149
|
+
session += ` API calls (page load context):
|
|
150
|
+
`;
|
|
151
|
+
for (const e of pageLoad.slice(0, MAX_PAGELOAD))
|
|
152
|
+
session += ` - ${e.method} ${truncUrl(e.url)} \u2192 ${e.status ?? "pending"}
|
|
153
|
+
`;
|
|
154
|
+
if (pageLoad.length > MAX_PAGELOAD)
|
|
155
|
+
session += ` - ...and ${pageLoad.length - MAX_PAGELOAD} more
|
|
156
|
+
`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const creates = this._actions.flatMap((a) => a.networkEvents.filter((e) => e.bucket === "direct" && e.method === "POST" && e.status === 201).map((e) => ({ a, e })));
|
|
160
|
+
let cleanup = "## Data Created During Session\n";
|
|
161
|
+
if (creates.length) {
|
|
162
|
+
for (const { a, e } of creates)
|
|
163
|
+
cleanup += `- Step ${a.index + 1} (${a.toolName}): POST ${e.url} \u2192 201 Created. Add afterEach cleanup using the response ID.
|
|
164
|
+
`;
|
|
165
|
+
} else {
|
|
166
|
+
cleanup += "No data was created. No cleanup needed.\n";
|
|
167
|
+
}
|
|
168
|
+
return `# Playwright Test Generation Request (live MCP session)
|
|
169
|
+
|
|
170
|
+
## Scenario
|
|
171
|
+
Name: ${this._options.scenarioName}
|
|
172
|
+
Target file: ${relSpec}
|
|
173
|
+
|
|
174
|
+
This session was recorded live while an AI agent drove the browser through the Playwright Codegen Pro MCP. A runnable draft test has already been written to the target file; use this context to produce a polished final test.
|
|
175
|
+
|
|
176
|
+
## Recorded Session
|
|
177
|
+
${session.trimEnd() || "(no actions recorded yet)"}
|
|
178
|
+
|
|
179
|
+
${cleanup}
|
|
180
|
+
## Best Practices (follow exactly)
|
|
181
|
+
- Do NOT use page.waitForTimeout(), page.waitForLoadState(), page.waitForNavigation()
|
|
182
|
+
- DO use await expect(locator).toBeVisible() and similar web-first assertions
|
|
183
|
+
- Prefer getByRole, getByLabel, getByPlaceholder, getByTestId over CSS selectors
|
|
184
|
+
- Add a meaningful assertion BEFORE and AFTER each significant action
|
|
185
|
+
- For actions with 'direct' API calls, wrap the click in Promise.all with waitForResponse
|
|
186
|
+
- Never assert on auto-generated IDs, UUIDs, or timestamps from response bodies
|
|
187
|
+
- Use process.env.TEST_* variables for any credentials (secrets are already redacted)
|
|
188
|
+
- For data-creating actions (201), add afterEach cleanup using the response ID
|
|
189
|
+
- Structure as test.describe with one clear test per scenario
|
|
190
|
+
|
|
191
|
+
## Output Format
|
|
192
|
+
Generate a complete Playwright test file in TypeScript.
|
|
193
|
+
The file should start with: import { test, expect } from '@playwright/test';
|
|
194
|
+
Save the result to: ${relSpec}`;
|
|
195
|
+
}
|
|
196
|
+
// ─── Draft spec (<scenario>.spec.ts) ─────────────────────────────────────
|
|
197
|
+
_buildSpec() {
|
|
198
|
+
const body = this._actions.map((a) => a.code.split("\n").map((l) => ` ${l}`).join("\n")).join("\n");
|
|
199
|
+
return `import { test, expect } from '@playwright/test';
|
|
200
|
+
|
|
201
|
+
// Draft test recorded live via the Playwright Codegen Pro MCP.
|
|
202
|
+
// Review and add assertions \u2014 see .playwright-session.md for captured API calls.
|
|
203
|
+
test('${this._options.scenarioName.replace(/'/g, "\\'")}', async ({ page }) => {
|
|
204
|
+
${body || " // No actions recorded yet."}
|
|
205
|
+
});
|
|
206
|
+
`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
210
|
+
0 && (module.exports = {
|
|
211
|
+
McpSessionRecorder
|
|
212
|
+
});
|
|
@@ -40,7 +40,7 @@ const recorderGetSession = (0, import_tool.defineTool)({
|
|
|
40
40
|
schema: {
|
|
41
41
|
name: "recorder_get_session",
|
|
42
42
|
title: "Get recorder session prompt",
|
|
43
|
-
description: 'Read the Playwright recorder session prompt. Returns recorded actions, network events, and instructions for generating a Playwright test.
|
|
43
|
+
description: 'Read the Playwright recorder session prompt. Returns recorded actions, network events (classified as direct/pageLoad/noise), redacted payloads, and instructions for generating a Playwright test. Two sources feed this: (1) browser tools you drive through THIS MCP are recorded live into .playwright-session.md, with a runnable draft test written to tests/mcp-session.spec.ts; (2) a separate `playwright-codegen-pro codegen <url>` recording (.playwright-session.md live, or .playwright-prompt.md after "Generate Test"). Call this after driving the browser to turn the session into a polished test.',
|
|
44
44
|
inputSchema: import_mcpBundle.z.object({
|
|
45
45
|
path: import_mcpBundle.z.string().optional().describe(
|
|
46
46
|
"Path to the prompt file. Defaults to checking .playwright-session.md (live) then .playwright-prompt.md in the current working directory."
|
|
@@ -49,7 +49,7 @@ const recorderGetSession = (0, import_tool.defineTool)({
|
|
|
49
49
|
type: "readOnly"
|
|
50
50
|
},
|
|
51
51
|
handle: async (context, params, response) => {
|
|
52
|
-
const cwd = context.options.cwd;
|
|
52
|
+
const cwd = context.options.cwd || process.cwd();
|
|
53
53
|
if (params.path) {
|
|
54
54
|
try {
|
|
55
55
|
const content = await import_fs.default.promises.readFile(params.path, "utf-8");
|
package/lib/tools/mcp/program.js
CHANGED
|
@@ -42,12 +42,16 @@ var import_log = require("./log");
|
|
|
42
42
|
const version = require("../../../package.json").version;
|
|
43
43
|
const codegenProInstructions = `This is the Playwright Codegen Pro MCP server \u2014 an AI-ready fork of the Playwright recorder.
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
3. Save the test where the prompt indicates (the target file is named in the session).
|
|
45
|
+
You can BUILD a test by driving the browser live: every browser tool you call (browser_navigate, browser_click, browser_type, ...) is recorded automatically. As you act, the server writes:
|
|
46
|
+
- .playwright-session.md \u2014 a structured prompt: each action, the network requests it triggered (classified as direct API calls / page-load context / noise), redacted request payloads and response bodies, and data-cleanup hints.
|
|
47
|
+
- tests/mcp-session.spec.ts \u2014 a runnable draft test that grows with each action.
|
|
49
48
|
|
|
50
|
-
|
|
49
|
+
Recommended workflow to "write a test for X":
|
|
50
|
+
1. Drive the site with the browser tools to perform the scenario, reading each tool's snapshot to decide the next step. No need to write or run a script \u2014 the recording captures what you do.
|
|
51
|
+
2. Call \`recorder_get_session\` to read back the accumulated session.
|
|
52
|
+
3. Produce a polished final test from it, following the "Best Practices" in the returned prompt (web-first assertions, role-based locators, waitForResponse around API-triggering clicks, afterEach cleanup for created data, no hard waits). Save it to the target file named in the session.
|
|
53
|
+
|
|
54
|
+
\`recorder_get_session\` also reads sessions from a separate \`playwright-codegen-pro codegen <url>\` run (\`.playwright-session.md\` live, or \`.playwright-prompt.md\` after the user clicks "Generate Test"). Secrets are redacted in all cases.`;
|
|
51
55
|
function decorateMCPCommand(command) {
|
|
52
56
|
command.option("--allowed-hosts <hosts...>", "comma-separated list of hosts this server is allowed to serve from. Defaults to the host the server is bound to. Pass '*' to disable the host check.", import_config.commaSeparatedList).option("--allowed-origins <origins>", "semicolon-separated list of TRUSTED origins to allow the browser to request. Default is to allow all.\nImportant: *does not* serve as a security boundary and *does not* affect redirects. ", import_config.semicolonSeparatedList).option("--allow-unrestricted-file-access", "allow access to files outside of the workspace roots. Also allows unrestricted access to file:// URLs. By default access to file system is restricted to workspace root directories (or cwd if no roots are configured) only, and navigation to file:// URLs is blocked.").option("--blocked-origins <origins>", "semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.\nImportant: *does not* serve as a security boundary and *does not* affect redirects.", import_config.semicolonSeparatedList).option("--block-service-workers", "block service workers").option("--browser <browser>", "browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.").option("--caps <caps>", "comma-separated list of additional capabilities to enable, possible values: vision, pdf, devtools.", import_config.commaSeparatedList).option("--cdp-endpoint <endpoint>", "CDP endpoint to connect to.").option("--cdp-header <headers...>", "CDP headers to send with the connect request, multiple can be specified.", import_config.headerParser).option("--cdp-timeout <timeout>", "timeout in milliseconds for connecting to CDP endpoint, defaults to 30000ms", import_config.numberParser).option("--codegen <lang>", 'specify the language to use for code generation, possible values: "typescript", "none". Default is "typescript".', import_config.enumParser.bind(null, "--codegen", ["none", "typescript"])).option("--config <path>", "path to the configuration file.").option("--console-level <level>", 'level of console messages to return: "error", "warning", "info", "debug". Each level includes the messages of more severe levels.', import_config.enumParser.bind(null, "--console-level", ["error", "warning", "info", "debug"])).option("--device <device>", 'device to emulate, for example: "iPhone 15"').option("--executable-path <path>", "path to the browser executable.").option("--extension", 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.').option("--grant-permissions <permissions...>", 'List of permissions to grant to the browser context, for example "geolocation", "clipboard-read", "clipboard-write".', import_config.commaSeparatedList).option("--headless", "run browser in headless mode, headed by default").option("--host <host>", "host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.").option("--ignore-https-errors", "ignore https errors").option("--init-page <path...>", "path to TypeScript file to evaluate on Playwright page object").option("--init-script <path...>", "path to JavaScript file to add as an initialization script. The script will be evaluated in every page before any of the page's scripts. Can be specified multiple times.").option("--isolated", "keep the browser profile in memory, do not save it to disk.").option("--image-responses <mode>", 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".', import_config.enumParser.bind(null, "--image-responses", ["allow", "omit"])).option("--no-sandbox", "disable the sandbox for all process types that are normally sandboxed.").option("--output-dir <path>", "path to the directory for output files.").option("--output-mode <mode>", 'whether to save snapshots, console messages, network logs to a file or to the standard output. Can be "file" or "stdout". Default is "stdout".', import_config.enumParser.bind(null, "--output-mode", ["file", "stdout"])).option("--port <port>", "port to listen on for SSE transport.").option("--proxy-bypass <bypass>", 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"').option("--proxy-server <proxy>", 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"').option("--sandbox", "enable the sandbox for all process types that are normally not sandboxed.").option("--save-session", "Whether to save the Playwright MCP session into the output directory.").option("--secrets <path>", "path to a file containing secrets in the dotenv format", import_config.dotenvFileLoader).option("--shared-browser-context", "reuse the same browser context between all connected HTTP clients.").option("--snapshot-mode <mode>", 'when taking snapshots for responses, specifies the mode to use. Can be "full" or "none". Default is "full".').option("--storage-state <path>", "path to the storage state file for isolated sessions.").option("--test-id-attribute <attribute>", 'specify the attribute to use for test ids, defaults to "data-testid"').option("--timeout-action <timeout>", "specify action timeout in milliseconds, defaults to 5000ms", import_config.numberParser).option("--timeout-navigation <timeout>", "specify navigation timeout in milliseconds, defaults to 60000ms", import_config.numberParser).option("--user-agent <ua string>", "specify user agent string").option("--user-data-dir <path>", "path to the user data directory. If not specified, a temporary directory will be created.").option("--viewport-size <size>", 'specify browser viewport size in pixels, for example "1280x720"', import_config.resolutionParser.bind(null, "--viewport-size")).addOption(new import_utilsBundle.ProgramOption("--vision", "Legacy option, use --caps=vision instead").hideHelp()).action(async (options) => {
|
|
53
57
|
options.sandbox = options.sandbox === true ? void 0 : false;
|
package/package.json
CHANGED