playwright-codegen-pro-core 1.0.4 → 1.0.6
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/lib/tools/backend/browserBackend.js +32 -0
- package/lib/tools/backend/mcpNetworkCapture.js +162 -0
- package/lib/tools/backend/mcpSessionRecorder.js +243 -0
- package/lib/tools/backend/recorder.js +24 -3
- package/lib/tools/mcp/program.js +11 -5
- package/lib/vite/traceViewer/index.hhnlQ6BY.js +2 -0
- package/lib/vite/traceViewer/index.html +1 -1
- package/package.json +1 -1
|
@@ -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,17 @@ 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
|
+
});
|
|
60
|
+
this._context.mcpRecorder = this._recorder;
|
|
41
61
|
}
|
|
42
62
|
async dispose() {
|
|
63
|
+
await this._recorder?.dispose().catch((e) => (0, import_utilsBundle.debug)("pw:tools:error")(e));
|
|
43
64
|
await this._context?.dispose().catch((e) => (0, import_utilsBundle.debug)("pw:tools:error")(e));
|
|
44
65
|
}
|
|
45
66
|
async callTool(name, rawArguments = {}) {
|
|
@@ -56,12 +77,15 @@ Tool "${name}" not found` }],
|
|
|
56
77
|
const context = this._context;
|
|
57
78
|
const response = new import_response.Response(context, name, parsedArguments, cwd);
|
|
58
79
|
context.setRunningTool(name);
|
|
80
|
+
this._recorder?.beginAction(name);
|
|
59
81
|
let responseObject;
|
|
60
82
|
try {
|
|
61
83
|
await tool.handle(context, parsedArguments, response);
|
|
62
84
|
responseObject = await response.serialize();
|
|
63
85
|
this._sessionLog?.logResponse(name, parsedArguments, responseObject);
|
|
86
|
+
this._recordAction(responseObject, cwd);
|
|
64
87
|
} catch (error) {
|
|
88
|
+
this._recorder?.completeAction(void 0);
|
|
65
89
|
return {
|
|
66
90
|
content: [{ type: "text", text: `### Error
|
|
67
91
|
${String(error)}` }],
|
|
@@ -72,6 +96,14 @@ ${String(error)}` }],
|
|
|
72
96
|
}
|
|
73
97
|
return responseObject;
|
|
74
98
|
}
|
|
99
|
+
_recordAction(responseObject, cwd) {
|
|
100
|
+
if (!this._recorder)
|
|
101
|
+
return;
|
|
102
|
+
const parsed = (0, import_response.parseResponse)(responseObject, cwd);
|
|
103
|
+
const pageUrl = parsed?.page?.match(/Page URL:\s*(\S+)/)?.[1];
|
|
104
|
+
const pageTitle = parsed?.page?.match(/Page Title:\s*(.+)/)?.[1]?.trim();
|
|
105
|
+
this._recorder.completeAction(parsed?.code, pageUrl, pageTitle);
|
|
106
|
+
}
|
|
75
107
|
}
|
|
76
108
|
// Annotate the CommonJS export names for ESM import in node:
|
|
77
109
|
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,243 @@
|
|
|
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
|
+
slugify: () => slugify
|
|
33
|
+
});
|
|
34
|
+
module.exports = __toCommonJS(mcpSessionRecorder_exports);
|
|
35
|
+
var import_fs = __toESM(require("fs"));
|
|
36
|
+
var import_path = __toESM(require("path"));
|
|
37
|
+
var import_mcpNetworkCapture = require("./mcpNetworkCapture");
|
|
38
|
+
const WRITE_THROTTLE_MS = 250;
|
|
39
|
+
const MAX_DIRECT = 20;
|
|
40
|
+
const MAX_PAGELOAD = 8;
|
|
41
|
+
const MAX_URL_LEN = 140;
|
|
42
|
+
function truncUrl(url) {
|
|
43
|
+
return url.length > MAX_URL_LEN ? url.slice(0, MAX_URL_LEN) + "\u2026" : url;
|
|
44
|
+
}
|
|
45
|
+
function slugify(name) {
|
|
46
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "mcp-session";
|
|
47
|
+
}
|
|
48
|
+
class McpSessionRecorder {
|
|
49
|
+
constructor(context, options) {
|
|
50
|
+
this._actions = [];
|
|
51
|
+
this._current = null;
|
|
52
|
+
this._sessionStart = Date.now();
|
|
53
|
+
this._disposed = false;
|
|
54
|
+
this._options = options;
|
|
55
|
+
this._defaultScenario = options.scenarioName;
|
|
56
|
+
this._defaultSpecFile = options.specFile;
|
|
57
|
+
this._sessionFile = import_path.default.join(options.cwd, ".playwright-session.md");
|
|
58
|
+
this._network = new import_mcpNetworkCapture.McpNetworkCapture(context, () => this._current, () => this._scheduleWrite());
|
|
59
|
+
this._network.start();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Begin a fresh recording. Clears accumulated actions so the next flow becomes its own
|
|
63
|
+
* test. With a name, the draft is written to tests/<slug>.spec.ts (so back-to-back flows
|
|
64
|
+
* each get their own file); without a name, resets to the default mcp-session.spec.ts.
|
|
65
|
+
* Returns the relative path of the spec the new flow will be written to.
|
|
66
|
+
*/
|
|
67
|
+
reset(name) {
|
|
68
|
+
this._actions = [];
|
|
69
|
+
this._current = null;
|
|
70
|
+
this._sessionStart = Date.now();
|
|
71
|
+
if (name) {
|
|
72
|
+
this._options.scenarioName = name;
|
|
73
|
+
this._options.specFile = import_path.default.join(this._options.cwd, "tests", `${slugify(name)}.spec.ts`);
|
|
74
|
+
} else {
|
|
75
|
+
this._options.scenarioName = this._defaultScenario;
|
|
76
|
+
this._options.specFile = this._defaultSpecFile;
|
|
77
|
+
}
|
|
78
|
+
if (this._writeTimer) {
|
|
79
|
+
clearTimeout(this._writeTimer);
|
|
80
|
+
this._writeTimer = void 0;
|
|
81
|
+
}
|
|
82
|
+
this._writeFiles();
|
|
83
|
+
return import_path.default.relative(this._options.cwd, this._options.specFile);
|
|
84
|
+
}
|
|
85
|
+
/** Called before a tool runs, so network events attribute to it. */
|
|
86
|
+
beginAction(toolName) {
|
|
87
|
+
this._current = { toolName, startTime: Date.now(), events: [] };
|
|
88
|
+
}
|
|
89
|
+
/** Called after a tool runs with the Playwright code it executed (if any). */
|
|
90
|
+
completeAction(code, pageUrl, pageTitle) {
|
|
91
|
+
const current = this._current;
|
|
92
|
+
if (!current)
|
|
93
|
+
return;
|
|
94
|
+
const trimmed = (code ?? "").trim();
|
|
95
|
+
if (trimmed) {
|
|
96
|
+
this._actions.push({
|
|
97
|
+
index: this._actions.length,
|
|
98
|
+
toolName: current.toolName,
|
|
99
|
+
code: trimmed,
|
|
100
|
+
pageUrl,
|
|
101
|
+
pageTitle,
|
|
102
|
+
startTime: current.startTime,
|
|
103
|
+
// Same array reference the network capture pushes into — late responses still land here.
|
|
104
|
+
networkEvents: current.events
|
|
105
|
+
});
|
|
106
|
+
} else if (current.events.length && this._actions.length) {
|
|
107
|
+
this._actions[this._actions.length - 1].networkEvents.push(...current.events);
|
|
108
|
+
}
|
|
109
|
+
this._scheduleWrite();
|
|
110
|
+
}
|
|
111
|
+
async dispose() {
|
|
112
|
+
if (this._disposed)
|
|
113
|
+
return;
|
|
114
|
+
this._disposed = true;
|
|
115
|
+
this._network.dispose();
|
|
116
|
+
if (this._writeTimer)
|
|
117
|
+
clearTimeout(this._writeTimer);
|
|
118
|
+
this._writeFiles();
|
|
119
|
+
}
|
|
120
|
+
// ─── Writing ─────────────────────────────────────────────────────────────
|
|
121
|
+
_scheduleWrite() {
|
|
122
|
+
if (this._disposed || this._writeTimer)
|
|
123
|
+
return;
|
|
124
|
+
this._writeTimer = setTimeout(() => {
|
|
125
|
+
this._writeTimer = void 0;
|
|
126
|
+
this._writeFiles();
|
|
127
|
+
}, WRITE_THROTTLE_MS);
|
|
128
|
+
}
|
|
129
|
+
_writeFiles() {
|
|
130
|
+
try {
|
|
131
|
+
import_fs.default.writeFileSync(this._sessionFile, this._redact(this._buildPrompt()), "utf-8");
|
|
132
|
+
const spec = this._redact(this._buildSpec());
|
|
133
|
+
import_fs.default.mkdirSync(import_path.default.dirname(this._options.specFile), { recursive: true });
|
|
134
|
+
import_fs.default.writeFileSync(this._options.specFile, spec, "utf-8");
|
|
135
|
+
} catch {
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
_redact(text) {
|
|
139
|
+
let out = text;
|
|
140
|
+
for (const value of Object.values(this._options.secrets ?? {})) {
|
|
141
|
+
if (value)
|
|
142
|
+
out = out.split(value).join("[redacted]");
|
|
143
|
+
}
|
|
144
|
+
out = out.replace(/("(?:password|token|secret|authorization|api[_-]?key|access[_-]?token|refresh[_-]?token)"\s*:\s*")[^"]*(")/gi, "$1[redacted]$2");
|
|
145
|
+
out = out.replace(/\bBearer\s+[A-Za-z0-9._\-]+/g, "Bearer [redacted]");
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
// ─── Prompt (.playwright-session.md) ─────────────────────────────────────
|
|
149
|
+
_buildPrompt() {
|
|
150
|
+
const relSpec = import_path.default.relative(this._options.cwd, this._options.specFile);
|
|
151
|
+
let session = "";
|
|
152
|
+
let lastPageUrl;
|
|
153
|
+
for (const action of this._actions) {
|
|
154
|
+
if (action.pageUrl && action.pageUrl !== lastPageUrl) {
|
|
155
|
+
session += `### Page: ${action.pageUrl}
|
|
156
|
+
`;
|
|
157
|
+
lastPageUrl = action.pageUrl;
|
|
158
|
+
}
|
|
159
|
+
const codeLine = action.code.split("\n")[0];
|
|
160
|
+
session += `- ${action.toolName}: ${codeLine} at t=${action.startTime - this._sessionStart}ms
|
|
161
|
+
`;
|
|
162
|
+
const direct = action.networkEvents.filter((e) => e.bucket === "direct");
|
|
163
|
+
const pageLoad = action.networkEvents.filter((e) => e.bucket === "pageLoad");
|
|
164
|
+
if (direct.length) {
|
|
165
|
+
session += ` API calls (direct):
|
|
166
|
+
`;
|
|
167
|
+
for (const e of direct.slice(0, MAX_DIRECT)) {
|
|
168
|
+
const op = e.operationName ? ` (${e.operationName})` : "";
|
|
169
|
+
const payload = e.requestBodySnippet ? ` (payload: ${e.requestBodySnippet})` : "";
|
|
170
|
+
const body = e.bodySnippet ? ` (body: ${e.bodySnippet})` : "";
|
|
171
|
+
session += ` - ${e.method} ${truncUrl(e.url)}${op}${payload} \u2192 ${e.status ?? (e.aborted ? "aborted" : "pending")}${body}
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
if (direct.length > MAX_DIRECT)
|
|
175
|
+
session += ` - ...and ${direct.length - MAX_DIRECT} more
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
if (pageLoad.length) {
|
|
179
|
+
session += ` API calls (page load context):
|
|
180
|
+
`;
|
|
181
|
+
for (const e of pageLoad.slice(0, MAX_PAGELOAD))
|
|
182
|
+
session += ` - ${e.method} ${truncUrl(e.url)} \u2192 ${e.status ?? "pending"}
|
|
183
|
+
`;
|
|
184
|
+
if (pageLoad.length > MAX_PAGELOAD)
|
|
185
|
+
session += ` - ...and ${pageLoad.length - MAX_PAGELOAD} more
|
|
186
|
+
`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const creates = this._actions.flatMap((a) => a.networkEvents.filter((e) => e.bucket === "direct" && e.method === "POST" && e.status === 201).map((e) => ({ a, e })));
|
|
190
|
+
let cleanup = "## Data Created During Session\n";
|
|
191
|
+
if (creates.length) {
|
|
192
|
+
for (const { a, e } of creates)
|
|
193
|
+
cleanup += `- Step ${a.index + 1} (${a.toolName}): POST ${e.url} \u2192 201 Created. Add afterEach cleanup using the response ID.
|
|
194
|
+
`;
|
|
195
|
+
} else {
|
|
196
|
+
cleanup += "No data was created. No cleanup needed.\n";
|
|
197
|
+
}
|
|
198
|
+
return `# Playwright Test Generation Request (live MCP session)
|
|
199
|
+
|
|
200
|
+
## Scenario
|
|
201
|
+
Name: ${this._options.scenarioName}
|
|
202
|
+
Target file: ${relSpec}
|
|
203
|
+
|
|
204
|
+
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.
|
|
205
|
+
|
|
206
|
+
## Recorded Session
|
|
207
|
+
${session.trimEnd() || "(no actions recorded yet)"}
|
|
208
|
+
|
|
209
|
+
${cleanup}
|
|
210
|
+
## Best Practices (follow exactly)
|
|
211
|
+
- Do NOT use page.waitForTimeout(), page.waitForLoadState(), page.waitForNavigation()
|
|
212
|
+
- DO use await expect(locator).toBeVisible() and similar web-first assertions
|
|
213
|
+
- Prefer getByRole, getByLabel, getByPlaceholder, getByTestId over CSS selectors
|
|
214
|
+
- Add a meaningful assertion BEFORE and AFTER each significant action
|
|
215
|
+
- For actions with 'direct' API calls, wrap the click in Promise.all with waitForResponse
|
|
216
|
+
- Never assert on auto-generated IDs, UUIDs, or timestamps from response bodies
|
|
217
|
+
- Use process.env.TEST_* variables for any credentials (secrets are already redacted)
|
|
218
|
+
- For data-creating actions (201), add afterEach cleanup using the response ID
|
|
219
|
+
- Structure as test.describe with one clear test per scenario
|
|
220
|
+
|
|
221
|
+
## Output Format
|
|
222
|
+
Generate a complete Playwright test file in TypeScript.
|
|
223
|
+
The file should start with: import { test, expect } from '@playwright/test';
|
|
224
|
+
Save the result to: ${relSpec}`;
|
|
225
|
+
}
|
|
226
|
+
// ─── Draft spec (<scenario>.spec.ts) ─────────────────────────────────────
|
|
227
|
+
_buildSpec() {
|
|
228
|
+
const body = this._actions.map((a) => a.code.split("\n").map((l) => ` ${l}`).join("\n")).join("\n");
|
|
229
|
+
return `import { test, expect } from '@playwright/test';
|
|
230
|
+
|
|
231
|
+
// Draft test recorded live via the Playwright Codegen Pro MCP.
|
|
232
|
+
// Review and add assertions \u2014 see .playwright-session.md for captured API calls.
|
|
233
|
+
test('${this._options.scenarioName.replace(/'/g, "\\'")}', async ({ page }) => {
|
|
234
|
+
${body || " // No actions recorded yet."}
|
|
235
|
+
});
|
|
236
|
+
`;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
240
|
+
0 && (module.exports = {
|
|
241
|
+
McpSessionRecorder,
|
|
242
|
+
slugify
|
|
243
|
+
});
|
|
@@ -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");
|
|
@@ -71,4 +71,25 @@ const recorderGetSession = (0, import_tool.defineTool)({
|
|
|
71
71
|
response.addTextResult("No recorder session found. Run `npx playwright codegen --ai-codegen`, record actions, and the session will be available here automatically.");
|
|
72
72
|
}
|
|
73
73
|
});
|
|
74
|
-
|
|
74
|
+
const recorderReset = (0, import_tool.defineTool)({
|
|
75
|
+
capability: "core",
|
|
76
|
+
schema: {
|
|
77
|
+
name: "recorder_reset",
|
|
78
|
+
title: "Start a new recording",
|
|
79
|
+
description: "Start a fresh recording, clearing the accumulated actions so the next flow becomes its own clean test. Call this between independent flows when testing or documenting a site back-to-back. With a `name`, the draft test is written to tests/<name>.spec.ts and the test is named accordingly (each flow gets its own file); without a name, it resets to the default tests/mcp-session.spec.ts. The previous flow's spec file is left in place.",
|
|
80
|
+
inputSchema: import_mcpBundle.z.object({
|
|
81
|
+
name: import_mcpBundle.z.string().optional().describe('Name for the new flow, e.g. "login" or "checkout". Becomes the test name and spec filename tests/<name>.spec.ts.')
|
|
82
|
+
}),
|
|
83
|
+
type: "readOnly"
|
|
84
|
+
},
|
|
85
|
+
handle: async (context, params, response) => {
|
|
86
|
+
const recorder = context.mcpRecorder;
|
|
87
|
+
if (!recorder) {
|
|
88
|
+
response.addTextResult("Live recording is not active for this server.");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const specFile = recorder.reset(params.name);
|
|
92
|
+
response.addTextResult(params.name ? `Started new recording "${params.name}". Subsequent actions are recorded into ${specFile} (and .playwright-session.md).` : `Recording reset. Subsequent actions are recorded into ${specFile} (and .playwright-session.md).`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
var recorder_default = [recorderGetSession, recorderReset];
|
package/lib/tools/mcp/program.js
CHANGED
|
@@ -42,12 +42,18 @@ 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
|
+
Testing or documenting multiple flows back-to-back: call \`recorder_reset\` between flows so each becomes its own clean test rather than piling into one. Pass a name (e.g. \`recorder_reset({ name: "login" })\`) to write that flow's draft to \`tests/login.spec.ts\`. Typical loop: drive flow A \u2192 recorder_get_session \u2192 recorder_reset({ name: "checkout" }) \u2192 drive flow B \u2192 recorder_get_session \u2192 ...
|
|
55
|
+
|
|
56
|
+
\`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
57
|
function decorateMCPCommand(command) {
|
|
52
58
|
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
59
|
options.sandbox = options.sandbox === true ? void 0 : false;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{T as x,r,a as C,W as F,j as e,D as z,b as I,c as P,d as A,e as O,f as V}from"./assets/defaultSettingsView-ConrJv9G.js";const $=()=>{const[o,c]=r.useState(!1),[n,l]=r.useState(),[h,u]=r.useState(),[p,b]=r.useState(N),[f,j]=r.useState({done:0,total:0}),[k,v]=r.useState(!1),[y,m]=r.useState(null),[T,R]=r.useState(null),[U,E]=r.useState(!1),g=r.useCallback(t=>{const s=new URL(window.location.href);if(!t.length)return;const i=t.item(0),a=URL.createObjectURL(i);s.searchParams.append("trace",a);const d=s.toString();window.history.pushState({},"",d),l(a),u(i.name),v(!1),m(null)},[]);r.useEffect(()=>{const t=async s=>{var a;if(!((a=s.clipboardData)!=null&&a.files.length))return;const i=["application/zip","application/x-zip-compressed"];for(const d of s.clipboardData.files)if(!i.includes(d.type))return;s.preventDefault(),g(s.clipboardData.files)};return document.addEventListener("paste",t),()=>document.removeEventListener("paste",t)}),r.useEffect(()=>{const t=s=>{const{method:i,params:a}=s.data;if(i!=="load"||!((a==null?void 0:a.trace)instanceof Blob))return;const d=new File([a.trace],"trace.zip",{type:"application/zip"}),w=new DataTransfer;w.items.add(d),g(w.files)};return window.addEventListener("message",t),()=>window.removeEventListener("message",t)});const W=r.useCallback(t=>{t.preventDefault(),g(t.dataTransfer.files)},[g]),M=r.useCallback(t=>{t.preventDefault(),t.target.files&&g(t.target.files)},[g]);r.useEffect(()=>{const t=new URL(window.location.href).searchParams,s=t.get("trace");if(c(t.has("isServer")),s!=null&&s.startsWith("file:")){R(s||null);return}if(t.has("isServer")){const i=new URLSearchParams(window.location.search).get("ws"),a=new URL(`../${i}`,window.location.toString());a.protocol=window.location.protocol==="https:"?"wss:":"ws:";const d=new C(new F(a));d.onLoadTraceRequested(async w=>{l(w.traceUrl),v(!1),m(null)}),d.initialize({}).catch(()=>{})}else s&&!s.startsWith("blob:")&&l(s)},[]);const S=r.useCallback(async t=>{const s=new URLSearchParams;s.set("trace",t);const i=await fetch(`contexts?${s.toString()}`);if(!i.ok){const{error:w}=await i.json();return m(w),w}const a=await i.json(),d=new x(t,a);j({done:0,total:0}),m(null),b(d)},[]);r.useEffect(()=>{(async()=>{if(!n){b(N);return}const t=s=>{s.data.method==="progress"&&j(s.data.params)};try{navigator.serviceWorker.addEventListener("message",t),j({done:0,total:1});let s=await S(n);s!=null&&s.includes("please grant permission for Local Network Access")&&(await fetch(n,{method:"HEAD",headers:{"x-pw-serviceworker":"skip"}}),s=await S(n)),s&&(o||l(void 0))}finally{navigator.serviceWorker.removeEventListener("message",t)}})()},[o,n,h,S]);const D=f.done!==f.total&&f.total!==0&&!y;r.useEffect(()=>{if(D){const t=setTimeout(()=>{E(!0)},200);return()=>clearTimeout(t)}else E(!1)},[D]);const L=!!(!o&&!k&&!T&&(!n||y));return e.jsxs("div",{className:"vbox workbench-loader",onDragOver:t=>{t.preventDefault(),t.dataTransfer.types.includes("Files")&&v(!0)},children:[e.jsxs("div",{className:"hbox workbench-loader-header",...L?{inert:!0}:{},children:[e.jsx("div",{className:"logo",children:e.jsx("img",{src:"playwright-logo.svg",alt:"Playwright logo"})}),e.jsx("div",{className:"product",children:"Playwright"}),p.title&&e.jsx("div",{className:"title",children:p.title}),e.jsx("div",{className:"spacer"}),e.jsx(z,{icon:"settings-gear",title:"Settings",dialogDataTestId:"settings-toolbar-dialog",children:e.jsx(I,{location:"trace-viewer"})})]}),e.jsx(P,{model:p,inert:L}),T&&e.jsxs("div",{className:"drop-target",children:[e.jsx("div",{children:"Trace Viewer uses Service Workers to show traces. To view trace:"}),e.jsxs("div",{style:{paddingTop:20},children:[e.jsxs("div",{children:["1. Click ",e.jsx("a",{href:T,children:"here"})," to put your trace into the download shelf"]}),e.jsxs("div",{children:["2. Go to ",e.jsx("a",{href:"https://trace.playwright.dev",children:"trace.playwright.dev"})]}),e.jsx("div",{children:"3. Drop the trace from the download shelf into the page"})]})]}),e.jsx(A,{open:U,isModal:!0,className:"progress-dialog",children:e.jsxs("div",{className:"progress-content",children:[e.jsx("div",{className:"title",role:"heading","aria-level":1,children:"Loading Playwright Trace..."}),e.jsx("div",{className:"progress-wrapper",children:e.jsx("div",{className:"inner-progress",style:{width:f.total?100*f.done/f.total+"%":0}})})]})}),L&&e.jsxs("div",{className:"drop-target",children:[e.jsx("div",{className:"processing-error",role:"alert",children:y}),e.jsx("div",{className:"title",role:"heading","aria-level":1,children:"Drop Playwright Trace to load"}),e.jsx("div",{children:"or"}),e.jsx("button",{onClick:()=>{const t=document.createElement("input");t.type="file",t.click(),t.addEventListener("change",s=>M(s))},type:"button",children:"Select file"}),e.jsx("div",{className:"info",children:"Playwright Trace Viewer is a Progressive Web App, it does not send your trace anywhere, it opens it locally."}),e.jsxs("div",{className:"version",children:["Playwright v","1.0.5"]})]}),o&&!n&&e.jsx("div",{className:"drop-target",children:e.jsx("div",{className:"title",children:"Select test to see the trace"})}),k&&e.jsx("div",{className:"drop-target",onDragLeave:()=>{v(!1)},onDrop:t=>W(t),children:e.jsx("div",{className:"title",children:"Release to analyse the Playwright Trace"})})]})},N=new x("",[]),q=({traceJson:o})=>{const[c,n]=r.useState(void 0),[l,h]=r.useState(0),u=r.useRef(null);return r.useEffect(()=>(u.current&&clearTimeout(u.current),u.current=setTimeout(async()=>{try{const p=await B(o);n(p)}catch{const p=new x("",[]);n(p)}finally{h(l+1)}},500),()=>{u.current&&clearTimeout(u.current)}),[o,l]),e.jsx(P,{isLive:!0,model:c})};async function B(o){const c=new URLSearchParams;c.set("trace",o);const l=await(await fetch(`contexts?${c.toString()}`)).json();return new x(o,l)}(async()=>{const o=new URLSearchParams(window.location.search);if(O(),window.location.protocol!=="file:"){if(o.get("isUnderTest")==="true"&&await new Promise(h=>setTimeout(h,1e3)),!navigator.serviceWorker)throw new Error(`Service workers are not supported.
|
|
2
|
+
Make sure to serve the Trace Viewer (${window.location}) via HTTPS or localhost.`);navigator.serviceWorker.register("sw.bundle.js"),navigator.serviceWorker.controller||await new Promise(h=>{navigator.serviceWorker.oncontrollerchange=()=>h()}),setInterval(function(){fetch("ping")},1e4)}const c=o.get("trace"),l=(c==null?void 0:c.endsWith(".json"))?e.jsx(q,{traceJson:c}):e.jsx($,{});V.createRoot(document.querySelector("#root")).render(l)})();
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<link rel="icon" href="./playwright-logo.svg" type="image/svg+xml">
|
|
8
8
|
<link rel="manifest" href="./manifest.webmanifest">
|
|
9
9
|
<title>Playwright Trace Viewer</title>
|
|
10
|
-
<script type="module" crossorigin src="./index.
|
|
10
|
+
<script type="module" crossorigin src="./index.hhnlQ6BY.js"></script>
|
|
11
11
|
<link rel="modulepreload" crossorigin href="./assets/defaultSettingsView-ConrJv9G.js">
|
|
12
12
|
<link rel="stylesheet" crossorigin href="./defaultSettingsView.B4dS75f0.css">
|
|
13
13
|
<link rel="stylesheet" crossorigin href="./index.CzXZzn5A.css">
|
package/package.json
CHANGED