@wordbricks/playwright-mcp 0.1.20 → 0.1.23
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/cli-wrapper.js +15 -14
- package/cli.js +1 -1
- package/config.d.ts +11 -6
- package/index.d.ts +7 -5
- package/index.js +1 -1
- package/package.json +34 -57
- package/LICENSE +0 -202
- package/lib/browserContextFactory.js +0 -326
- package/lib/browserServerBackend.js +0 -84
- package/lib/config.js +0 -286
- package/lib/context.js +0 -309
- package/lib/extension/cdpRelay.js +0 -346
- package/lib/extension/extensionContextFactory.js +0 -56
- package/lib/frameworkPatterns.js +0 -35
- package/lib/hooks/antiBotDetectionHook.js +0 -171
- package/lib/hooks/core.js +0 -144
- package/lib/hooks/eventConsumer.js +0 -52
- package/lib/hooks/events.js +0 -42
- package/lib/hooks/formatToolCallEvent.js +0 -16
- package/lib/hooks/frameworkStateHook.js +0 -182
- package/lib/hooks/grouping.js +0 -72
- package/lib/hooks/jsonLdDetectionHook.js +0 -175
- package/lib/hooks/networkFilters.js +0 -82
- package/lib/hooks/networkSetup.js +0 -59
- package/lib/hooks/networkTrackingHook.js +0 -67
- package/lib/hooks/pageHeightHook.js +0 -75
- package/lib/hooks/registry.js +0 -42
- package/lib/hooks/requireTabHook.js +0 -26
- package/lib/hooks/schema.js +0 -89
- package/lib/hooks/waitHook.js +0 -33
- package/lib/index.js +0 -39
- package/lib/mcp/inProcessTransport.js +0 -72
- package/lib/mcp/proxyBackend.js +0 -115
- package/lib/mcp/server.js +0 -86
- package/lib/mcp/tool.js +0 -38
- package/lib/mcp/transport.js +0 -181
- package/lib/playwrightTransformer.js +0 -497
- package/lib/program.js +0 -110
- package/lib/response.js +0 -186
- package/lib/sessionLog.js +0 -121
- package/lib/tab.js +0 -249
- package/lib/tools/common.js +0 -55
- package/lib/tools/console.js +0 -33
- package/lib/tools/dialogs.js +0 -47
- package/lib/tools/evaluate.js +0 -53
- package/lib/tools/extractFrameworkState.js +0 -214
- package/lib/tools/files.js +0 -45
- package/lib/tools/form.js +0 -57
- package/lib/tools/getSnapshot.js +0 -37
- package/lib/tools/getVisibleHtml.js +0 -52
- package/lib/tools/install.js +0 -51
- package/lib/tools/keyboard.js +0 -78
- package/lib/tools/mouse.js +0 -99
- package/lib/tools/navigate.js +0 -70
- package/lib/tools/network.js +0 -123
- package/lib/tools/networkDetail.js +0 -229
- package/lib/tools/networkSearch/bodySearch.js +0 -147
- package/lib/tools/networkSearch/grouping.js +0 -28
- package/lib/tools/networkSearch/helpers.js +0 -32
- package/lib/tools/networkSearch/searchHtml.js +0 -67
- package/lib/tools/networkSearch/types.js +0 -1
- package/lib/tools/networkSearch/urlSearch.js +0 -82
- package/lib/tools/networkSearch.js +0 -268
- package/lib/tools/pdf.js +0 -40
- package/lib/tools/repl.js +0 -402
- package/lib/tools/screenshot.js +0 -79
- package/lib/tools/scroll.js +0 -126
- package/lib/tools/snapshot.js +0 -144
- package/lib/tools/tabs.js +0 -59
- package/lib/tools/tool.js +0 -33
- package/lib/tools/utils.js +0 -74
- package/lib/tools/wait.js +0 -55
- package/lib/tools.js +0 -67
- package/lib/utils/adBlockFilter.js +0 -87
- package/lib/utils/codegen.js +0 -51
- package/lib/utils/extensionPath.js +0 -10
- package/lib/utils/fileUtils.js +0 -36
- package/lib/utils/graphql.js +0 -258
- package/lib/utils/guid.js +0 -22
- package/lib/utils/httpServer.js +0 -39
- package/lib/utils/log.js +0 -21
- package/lib/utils/manualPromise.js +0 -111
- package/lib/utils/networkFormat.js +0 -12
- package/lib/utils/package.js +0 -20
- package/lib/utils/result.js +0 -2
- package/lib/utils/sanitizeHtml.js +0 -98
- package/lib/utils/truncate.js +0 -103
- package/lib/utils/withTimeout.js +0 -7
- package/src/index.ts +0 -50
package/lib/mcp/transport.js
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Copyright (c) Microsoft Corporation.
|
|
3
|
-
*
|
|
4
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
-
* you may not use this file except in compliance with the License.
|
|
6
|
-
* You may obtain a copy of the License at
|
|
7
|
-
*
|
|
8
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
*
|
|
10
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
* See the License for the specific language governing permissions and
|
|
14
|
-
* limitations under the License.
|
|
15
|
-
*/
|
|
16
|
-
import crypto from 'crypto';
|
|
17
|
-
import debug from 'debug';
|
|
18
|
-
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
19
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
20
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
21
|
-
import { SUPPORTED_PROTOCOL_VERSIONS, LATEST_PROTOCOL_VERSION, isInitializeRequest, isJSONRPCRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
22
|
-
import contentType from 'content-type';
|
|
23
|
-
import getRawBody from 'raw-body';
|
|
24
|
-
import { pipe } from '@fxts/core';
|
|
25
|
-
import { httpAddressToString, startHttpServer } from '../utils/httpServer.js';
|
|
26
|
-
import * as mcpServer from './server.js';
|
|
27
|
-
// @see node_modules/@modelcontextprotocol/sdk/dist/esm/server/streamableHttp.js
|
|
28
|
-
const MAXIMUM_MESSAGE_SIZE = '4mb';
|
|
29
|
-
export async function start(serverBackendFactory, options) {
|
|
30
|
-
if (options.port !== undefined) {
|
|
31
|
-
const httpServer = await startHttpServer(options);
|
|
32
|
-
startHttpTransport(httpServer, serverBackendFactory);
|
|
33
|
-
}
|
|
34
|
-
else {
|
|
35
|
-
await startStdioTransport(serverBackendFactory);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
async function startStdioTransport(serverBackendFactory) {
|
|
39
|
-
await mcpServer.connect(serverBackendFactory, new StdioServerTransport(), false);
|
|
40
|
-
}
|
|
41
|
-
const testDebug = debug('pw:mcp:test');
|
|
42
|
-
async function handleStreamableReinitiate(req, res, transport, serverBackendFactory, sessionId, serverInfos) {
|
|
43
|
-
const ct = req.headers['content-type'];
|
|
44
|
-
if (!ct || !ct.includes('application/json')) {
|
|
45
|
-
res.writeHead(415).end(JSON.stringify({
|
|
46
|
-
jsonrpc: '2.0',
|
|
47
|
-
error: {
|
|
48
|
-
code: -32000,
|
|
49
|
-
message: 'Unsupported Media Type: Content-Type must be application/json'
|
|
50
|
-
},
|
|
51
|
-
id: null
|
|
52
|
-
}));
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
const parsedCt = contentType.parse(ct);
|
|
56
|
-
const encoding = parsedCt.parameters.charset;
|
|
57
|
-
const body = await pipe(getRawBody(req, {
|
|
58
|
-
limit: MAXIMUM_MESSAGE_SIZE,
|
|
59
|
-
encoding,
|
|
60
|
-
}), raw => raw.toString(), JSON.parse);
|
|
61
|
-
const msg = Array.isArray(body) ? (body.length === 1 ? body[0] : undefined) : body;
|
|
62
|
-
if (body && isJSONRPCRequest(msg) && isInitializeRequest(msg)) {
|
|
63
|
-
const requestedVersion = msg.params.protocolVersion;
|
|
64
|
-
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) ? requestedVersion : LATEST_PROTOCOL_VERSION;
|
|
65
|
-
const headers = {
|
|
66
|
-
'Content-Type': 'text/event-stream',
|
|
67
|
-
'Mcp-Session-Id': transport.sessionId
|
|
68
|
-
};
|
|
69
|
-
res.writeHead(200, headers);
|
|
70
|
-
const info = serverInfos.get(sessionId) || (() => {
|
|
71
|
-
const backend = serverBackendFactory();
|
|
72
|
-
return { name: backend.name, version: backend.version };
|
|
73
|
-
})();
|
|
74
|
-
const response = {
|
|
75
|
-
jsonrpc: '2.0',
|
|
76
|
-
id: msg.id,
|
|
77
|
-
result: {
|
|
78
|
-
protocolVersion,
|
|
79
|
-
capabilities: { tools: {} },
|
|
80
|
-
serverInfo: info,
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
res.write(`event: message\n`);
|
|
84
|
-
res.write(`data: ${JSON.stringify(response)}\n\n`);
|
|
85
|
-
res.end();
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
return await transport.handleRequest(req, res, body);
|
|
89
|
-
}
|
|
90
|
-
async function handleSSE(serverBackendFactory, req, res, url, sessions) {
|
|
91
|
-
if (req.method === 'POST') {
|
|
92
|
-
const sessionId = url.searchParams.get('sessionId');
|
|
93
|
-
if (!sessionId) {
|
|
94
|
-
res.statusCode = 400;
|
|
95
|
-
return res.end('Missing sessionId');
|
|
96
|
-
}
|
|
97
|
-
const transport = sessions.get(sessionId);
|
|
98
|
-
if (!transport) {
|
|
99
|
-
res.statusCode = 404;
|
|
100
|
-
return res.end('Session not found');
|
|
101
|
-
}
|
|
102
|
-
return await transport.handlePostMessage(req, res);
|
|
103
|
-
}
|
|
104
|
-
else if (req.method === 'GET') {
|
|
105
|
-
const transport = new SSEServerTransport('/sse', res);
|
|
106
|
-
sessions.set(transport.sessionId, transport);
|
|
107
|
-
testDebug(`create SSE session: ${transport.sessionId}`);
|
|
108
|
-
await mcpServer.connect(serverBackendFactory, transport, false);
|
|
109
|
-
res.on('close', () => {
|
|
110
|
-
testDebug(`delete SSE session: ${transport.sessionId}`);
|
|
111
|
-
sessions.delete(transport.sessionId);
|
|
112
|
-
});
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
res.statusCode = 405;
|
|
116
|
-
res.end('Method not allowed');
|
|
117
|
-
}
|
|
118
|
-
// Streamable transport: 'initialize' handling per MCP Lifecycle (Initialization) https://modelcontextprotocol.io/specification/draft/basic/lifecycle#initialization
|
|
119
|
-
async function handleStreamable(serverBackendFactory, req, res, sessions, serverInfos) {
|
|
120
|
-
const sessionId = req.headers['mcp-session-id'];
|
|
121
|
-
if (sessionId) {
|
|
122
|
-
const transport = sessions.get(sessionId);
|
|
123
|
-
if (!transport) {
|
|
124
|
-
res.statusCode = 404;
|
|
125
|
-
res.end('Session not found');
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (req.method === 'POST')
|
|
129
|
-
return await handleStreamableReinitiate(req, res, transport, serverBackendFactory, sessionId, serverInfos);
|
|
130
|
-
return await transport.handleRequest(req, res);
|
|
131
|
-
}
|
|
132
|
-
if (req.method === 'POST') {
|
|
133
|
-
const transport = new StreamableHTTPServerTransport({
|
|
134
|
-
sessionIdGenerator: () => crypto.randomUUID(),
|
|
135
|
-
onsessioninitialized: async (sessionId) => {
|
|
136
|
-
testDebug(`create http session: ${transport.sessionId}`);
|
|
137
|
-
const serverInfo = await mcpServer.connect(serverBackendFactory, transport, false);
|
|
138
|
-
sessions.set(sessionId, transport);
|
|
139
|
-
serverInfos.set(sessionId, serverInfo);
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
transport.onclose = () => {
|
|
143
|
-
if (!transport.sessionId)
|
|
144
|
-
return;
|
|
145
|
-
sessions.delete(transport.sessionId);
|
|
146
|
-
serverInfos.delete(transport.sessionId);
|
|
147
|
-
testDebug(`delete http session: ${transport.sessionId}`);
|
|
148
|
-
};
|
|
149
|
-
await transport.handleRequest(req, res);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
res.statusCode = 400;
|
|
153
|
-
res.end('Invalid request');
|
|
154
|
-
}
|
|
155
|
-
function startHttpTransport(httpServer, serverBackendFactory) {
|
|
156
|
-
const sseSessions = new Map();
|
|
157
|
-
const streamableSessions = new Map();
|
|
158
|
-
const streamableServerInfos = new Map();
|
|
159
|
-
httpServer.on('request', async (req, res) => {
|
|
160
|
-
const url = new URL(`http://localhost${req.url}`);
|
|
161
|
-
if (url.pathname.startsWith('/sse'))
|
|
162
|
-
await handleSSE(serverBackendFactory, req, res, url, sseSessions);
|
|
163
|
-
else
|
|
164
|
-
await handleStreamable(serverBackendFactory, req, res, streamableSessions, streamableServerInfos);
|
|
165
|
-
});
|
|
166
|
-
const url = httpAddressToString(httpServer.address());
|
|
167
|
-
const message = [
|
|
168
|
-
`Listening on ${url}`,
|
|
169
|
-
'Put this in your client config:',
|
|
170
|
-
JSON.stringify({
|
|
171
|
-
'mcpServers': {
|
|
172
|
-
'playwright': {
|
|
173
|
-
'url': `${url}/mcp`
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}, undefined, 2),
|
|
177
|
-
'For legacy SSE transport support, you can use the /sse endpoint instead.',
|
|
178
|
-
].join('\n');
|
|
179
|
-
// eslint-disable-next-line no-console
|
|
180
|
-
console.error(message);
|
|
181
|
-
}
|
|
@@ -1,497 +0,0 @@
|
|
|
1
|
-
import { pipe, memoize } from '@fxts/core';
|
|
2
|
-
import { buildLexer, apply, seq, alt_sc, rep_sc, tok, kmid, extractByTokenRange, makeParserModule } from 'typescript-parsec';
|
|
3
|
-
// Pure utility functions
|
|
4
|
-
export const escapeString = (str) => str.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
5
|
-
const isBlank = (str) => str.trim() === '';
|
|
6
|
-
export const createHelperCall = (helper, ...args) => `__pwHelpers.${helper}(${args.map(a => `'${escapeString(a)}'`).join(', ')})`;
|
|
7
|
-
var SelTokKind;
|
|
8
|
-
(function (SelTokKind) {
|
|
9
|
-
SelTokKind[SelTokKind["HasText"] = 0] = "HasText";
|
|
10
|
-
SelTokKind[SelTokKind["Contains"] = 1] = "Contains";
|
|
11
|
-
SelTokKind[SelTokKind["Text"] = 2] = "Text";
|
|
12
|
-
SelTokKind[SelTokKind["Visible"] = 3] = "Visible";
|
|
13
|
-
SelTokKind[SelTokKind["Has"] = 4] = "Has";
|
|
14
|
-
SelTokKind[SelTokKind["NthMatch"] = 5] = "NthMatch";
|
|
15
|
-
SelTokKind[SelTokKind["LParen"] = 6] = "LParen";
|
|
16
|
-
SelTokKind[SelTokKind["RParen"] = 7] = "RParen";
|
|
17
|
-
SelTokKind[SelTokKind["Comma"] = 8] = "Comma";
|
|
18
|
-
SelTokKind[SelTokKind["WS"] = 9] = "WS";
|
|
19
|
-
SelTokKind[SelTokKind["DQString"] = 10] = "DQString";
|
|
20
|
-
SelTokKind[SelTokKind["SQString"] = 11] = "SQString";
|
|
21
|
-
SelTokKind[SelTokKind["BQString"] = 12] = "BQString";
|
|
22
|
-
SelTokKind[SelTokKind["Regex"] = 13] = "Regex";
|
|
23
|
-
SelTokKind[SelTokKind["Other"] = 14] = "Other";
|
|
24
|
-
})(SelTokKind || (SelTokKind = {}));
|
|
25
|
-
const selectorLexer = buildLexer([
|
|
26
|
-
[true, /^:has-text/g, SelTokKind.HasText],
|
|
27
|
-
[true, /^:contains/g, SelTokKind.Contains],
|
|
28
|
-
[true, /^:text/g, SelTokKind.Text],
|
|
29
|
-
[true, /^:visible\b/g, SelTokKind.Visible],
|
|
30
|
-
[true, /^:has\b/g, SelTokKind.Has],
|
|
31
|
-
[true, /^:nth-match\b/g, SelTokKind.NthMatch],
|
|
32
|
-
[true, /^\(/g, SelTokKind.LParen],
|
|
33
|
-
[true, /^\)/g, SelTokKind.RParen],
|
|
34
|
-
[true, /^,/g, SelTokKind.Comma],
|
|
35
|
-
[false, /^\s+/g, SelTokKind.WS],
|
|
36
|
-
[true, /^"(?:[^"\\]|\\.)*"/g, SelTokKind.DQString],
|
|
37
|
-
[true, /^'(?:[^'\\]|\\.)*'/g, SelTokKind.SQString],
|
|
38
|
-
[true, /^`(?:[^`\\]|\\.)*`/g, SelTokKind.BQString],
|
|
39
|
-
[true, /^\/(?:[^\/\\]|\\.)+\/[a-z]*/gi, SelTokKind.Regex],
|
|
40
|
-
[true, /^[^]/g, SelTokKind.Other],
|
|
41
|
-
]);
|
|
42
|
-
// text args can be "..." or '...' or `...` or /.../flags
|
|
43
|
-
const TEXT_ARG = alt_sc(apply(tok(SelTokKind.DQString), t => t.text.slice(1, t.text.length - 1)), apply(tok(SelTokKind.SQString), t => t.text.slice(1, t.text.length - 1)), apply(tok(SelTokKind.BQString), t => t.text.slice(1, t.text.length - 1)), apply(tok(SelTokKind.Regex), t => t.text));
|
|
44
|
-
// Keep for generic quoted (used where we want the raw quoted text, not regex)
|
|
45
|
-
const QUOTED = alt_sc(tok(SelTokKind.DQString), tok(SelTokKind.SQString), tok(SelTokKind.BQString));
|
|
46
|
-
// Recursive string parser for :has(...) argument
|
|
47
|
-
const HasArgModule = makeParserModule({
|
|
48
|
-
ITEM: m => alt_sc(apply(QUOTED, t => t.text), apply(tok(SelTokKind.Regex), t => t.text), // allow regex inside :has inner
|
|
49
|
-
apply(tok(SelTokKind.Has), t => t.text), apply(tok(SelTokKind.HasText), t => t.text), apply(tok(SelTokKind.Contains), t => t.text), apply(tok(SelTokKind.Text), t => t.text), apply(tok(SelTokKind.Visible), t => t.text), apply(tok(SelTokKind.NthMatch), t => t.text), apply(tok(SelTokKind.Comma), t => t.text), // allow comma in inner (selector lists)
|
|
50
|
-
apply(tok(SelTokKind.Other), t => t.text), apply(seq(tok(SelTokKind.LParen), m.ARG, tok(SelTokKind.RParen)), v => '(' + v[1] + ')')),
|
|
51
|
-
ARG: m => apply(rep_sc(m.ITEM), arr => arr.join('')),
|
|
52
|
-
});
|
|
53
|
-
// first arg of :nth-match — same as ARG but *without* comma tokens
|
|
54
|
-
const HasArgNoCommaModule = makeParserModule({
|
|
55
|
-
ITEM: m => alt_sc(apply(QUOTED, t => t.text), apply(tok(SelTokKind.Regex), t => t.text), apply(tok(SelTokKind.Has), t => t.text), apply(tok(SelTokKind.HasText), t => t.text), apply(tok(SelTokKind.Contains), t => t.text), apply(tok(SelTokKind.Text), t => t.text), apply(tok(SelTokKind.Visible), t => t.text), apply(tok(SelTokKind.NthMatch), t => t.text), apply(tok(SelTokKind.Other), t => t.text), apply(seq(tok(SelTokKind.LParen), m.ARG, tok(SelTokKind.RParen)), v => '(' + v[1] + ')')),
|
|
56
|
-
ARG: m => apply(rep_sc(m.ITEM), arr => arr.join('')),
|
|
57
|
-
});
|
|
58
|
-
const HAS_ARG = HasArgModule.ARG;
|
|
59
|
-
const NTH_ARG_NO_COMMA = HasArgNoCommaModule.ARG;
|
|
60
|
-
const PseudoModule = makeParserModule({
|
|
61
|
-
HAS: () => apply(kmid(seq(tok(SelTokKind.Has), tok(SelTokKind.LParen)), HAS_ARG, tok(SelTokKind.RParen)), inner => ({ kind: 'has', inner })),
|
|
62
|
-
HAS_TEXT: () => apply(kmid(seq(tok(SelTokKind.HasText), tok(SelTokKind.LParen)), TEXT_ARG, tok(SelTokKind.RParen)), text => ({ kind: 'hasText', text })),
|
|
63
|
-
CONTAINS: () => apply(kmid(seq(tok(SelTokKind.Contains), tok(SelTokKind.LParen)), TEXT_ARG, tok(SelTokKind.RParen)), text => ({ kind: 'contains', text })),
|
|
64
|
-
TEXT: () => apply(kmid(seq(tok(SelTokKind.Text), tok(SelTokKind.LParen)), TEXT_ARG, tok(SelTokKind.RParen)), text => ({ kind: 'text', text })),
|
|
65
|
-
VISIBLE: () => apply(tok(SelTokKind.Visible), () => ({ kind: 'visible' })),
|
|
66
|
-
// :nth-match(<selector>, <index>)
|
|
67
|
-
NTH_MATCH: () => apply(kmid(seq(tok(SelTokKind.NthMatch), tok(SelTokKind.LParen)), seq(NTH_ARG_NO_COMMA, // first arg string (no commas)
|
|
68
|
-
apply(rep_sc(tok(SelTokKind.WS)), () => ''), // optional spaces
|
|
69
|
-
tok(SelTokKind.Comma), apply(rep_sc(tok(SelTokKind.WS)), () => ''), // optional spaces
|
|
70
|
-
TEXT_ARG), tok(SelTokKind.RParen)), v => {
|
|
71
|
-
const inner = v[0];
|
|
72
|
-
const idxRaw = v[4];
|
|
73
|
-
const index = parseInt(idxRaw, 10);
|
|
74
|
-
return { kind: 'nthMatch', inner, index: isFinite(index) ? index : 1 };
|
|
75
|
-
}),
|
|
76
|
-
ANY: m => alt_sc(m.HAS, m.HAS_TEXT, m.CONTAINS, m.TEXT, m.VISIBLE, m.NTH_MATCH),
|
|
77
|
-
});
|
|
78
|
-
const parseFirstPseudo = (selector) => {
|
|
79
|
-
const start = selectorLexer.parse(selector);
|
|
80
|
-
if (!start)
|
|
81
|
-
return { kind: 'none' };
|
|
82
|
-
let t = start;
|
|
83
|
-
while (t) {
|
|
84
|
-
const parsed = PseudoModule.ANY.parse(t);
|
|
85
|
-
if (parsed.successful && parsed.candidates.length > 0) {
|
|
86
|
-
const cand = parsed.candidates[0];
|
|
87
|
-
const consumed = extractByTokenRange(selector, cand.firstToken, cand.nextToken).length;
|
|
88
|
-
const prefix = selector.slice(0, t.pos.index);
|
|
89
|
-
const base = prefix + selector.slice(t.pos.index + consumed);
|
|
90
|
-
const res = cand.result;
|
|
91
|
-
if (res.kind === 'has')
|
|
92
|
-
return { kind: 'has', base, inner: res.inner };
|
|
93
|
-
if (res.kind === 'visible')
|
|
94
|
-
return { kind: 'visible', base };
|
|
95
|
-
if (res.kind === 'hasText')
|
|
96
|
-
return { kind: 'hasText', base, text: res.text };
|
|
97
|
-
if (res.kind === 'contains')
|
|
98
|
-
return { kind: 'contains', base, text: res.text };
|
|
99
|
-
if (res.kind === 'nthMatch')
|
|
100
|
-
return { kind: 'nthMatch', base, inner: res.inner, index: res.index };
|
|
101
|
-
return { kind: 'text', base, text: res.text };
|
|
102
|
-
}
|
|
103
|
-
t = t.next;
|
|
104
|
-
}
|
|
105
|
-
return { kind: 'none' };
|
|
106
|
-
};
|
|
107
|
-
var SkipTokKind;
|
|
108
|
-
(function (SkipTokKind) {
|
|
109
|
-
SkipTokKind[SkipTokKind["LineComment"] = 0] = "LineComment";
|
|
110
|
-
SkipTokKind[SkipTokKind["BlockComment"] = 1] = "BlockComment";
|
|
111
|
-
SkipTokKind[SkipTokKind["DQString"] = 2] = "DQString";
|
|
112
|
-
SkipTokKind[SkipTokKind["SQString"] = 3] = "SQString";
|
|
113
|
-
SkipTokKind[SkipTokKind["BQString"] = 4] = "BQString";
|
|
114
|
-
SkipTokKind[SkipTokKind["WS"] = 5] = "WS";
|
|
115
|
-
SkipTokKind[SkipTokKind["LParen"] = 6] = "LParen";
|
|
116
|
-
SkipTokKind[SkipTokKind["RParen"] = 7] = "RParen";
|
|
117
|
-
SkipTokKind[SkipTokKind["Dot"] = 8] = "Dot";
|
|
118
|
-
SkipTokKind[SkipTokKind["QuerySelector"] = 9] = "QuerySelector";
|
|
119
|
-
SkipTokKind[SkipTokKind["QuerySelectorAll"] = 10] = "QuerySelectorAll";
|
|
120
|
-
SkipTokKind[SkipTokKind["Identifier"] = 11] = "Identifier";
|
|
121
|
-
SkipTokKind[SkipTokKind["Other"] = 12] = "Other";
|
|
122
|
-
})(SkipTokKind || (SkipTokKind = {}));
|
|
123
|
-
const skipLexer = buildLexer([
|
|
124
|
-
[true, /^\/\/[^\n]*/g, SkipTokKind.LineComment],
|
|
125
|
-
[true, /^\/\*[^]*?\*\//g, SkipTokKind.BlockComment],
|
|
126
|
-
[true, /^"(?:[^"\\]|\\.)*"/g, SkipTokKind.DQString],
|
|
127
|
-
[true, /^'(?:[^'\\]|\\.)*'/g, SkipTokKind.SQString],
|
|
128
|
-
[true, /^`(?:[^`\\]|\\.)*`/g, SkipTokKind.BQString],
|
|
129
|
-
[true, /^\s+/g, SkipTokKind.WS],
|
|
130
|
-
[true, /^\(/g, SkipTokKind.LParen],
|
|
131
|
-
[true, /^\)/g, SkipTokKind.RParen],
|
|
132
|
-
[true, /^\./g, SkipTokKind.Dot],
|
|
133
|
-
[true, /^querySelectorAll\b/g, SkipTokKind.QuerySelectorAll],
|
|
134
|
-
[true, /^querySelector\b/g, SkipTokKind.QuerySelector],
|
|
135
|
-
[true, /^[A-Za-z_$][A-Za-z0-9_$]*/g, SkipTokKind.Identifier],
|
|
136
|
-
[true, /^[^]/g, SkipTokKind.Other],
|
|
137
|
-
]);
|
|
138
|
-
const SKIP_CALL_QUOTED = alt_sc(tok(SkipTokKind.DQString), tok(SkipTokKind.SQString), tok(SkipTokKind.BQString));
|
|
139
|
-
// Zero-or-more space/comments -> string
|
|
140
|
-
const WS_OR_COMMENT = alt_sc(tok(SkipTokKind.WS), tok(SkipTokKind.LineComment), tok(SkipTokKind.BlockComment));
|
|
141
|
-
const SP0 = apply(rep_sc(WS_OR_COMMENT), ts => ts.map(t => t.text).join(''));
|
|
142
|
-
// receiver := Identifier ('.' Identifier)*
|
|
143
|
-
const RECEIVER = apply(seq(tok(SkipTokKind.Identifier), rep_sc(seq(tok(SkipTokKind.Dot), tok(SkipTokKind.Identifier)))), (v) => {
|
|
144
|
-
const head = v[0].text;
|
|
145
|
-
const tail = v[1].map((p) => p[0].text + p[1].text).join('');
|
|
146
|
-
return head + tail;
|
|
147
|
-
});
|
|
148
|
-
const SegmentModule = makeParserModule({
|
|
149
|
-
RAW: () => apply(alt_sc(tok(SkipTokKind.LParen), tok(SkipTokKind.RParen), tok(SkipTokKind.WS), tok(SkipTokKind.LineComment), tok(SkipTokKind.BlockComment), tok(SkipTokKind.DQString), tok(SkipTokKind.SQString), tok(SkipTokKind.BQString), tok(SkipTokKind.QuerySelectorAll), tok(SkipTokKind.QuerySelector), tok(SkipTokKind.Identifier), tok(SkipTokKind.Dot), tok(SkipTokKind.Other)), t => ({ kind: 'raw', text: t.text })),
|
|
150
|
-
CALL_ONE: () => apply(seq(RECEIVER, tok(SkipTokKind.Dot), tok(SkipTokKind.QuerySelector), SP0, tok(SkipTokKind.LParen), SP0, SKIP_CALL_QUOTED, SP0, tok(SkipTokKind.RParen)), v => {
|
|
151
|
-
const lit = v[6];
|
|
152
|
-
return {
|
|
153
|
-
kind: 'call',
|
|
154
|
-
method: 'one',
|
|
155
|
-
selector: lit.text.slice(1, lit.text.length - 1),
|
|
156
|
-
original: v[0] + v[1].text + v[2].text + v[3] + v[4].text + v[5] + v[6].text + v[7] + v[8].text,
|
|
157
|
-
};
|
|
158
|
-
}),
|
|
159
|
-
CALL_ALL: () => apply(seq(RECEIVER, tok(SkipTokKind.Dot), tok(SkipTokKind.QuerySelectorAll), SP0, tok(SkipTokKind.LParen), SP0, SKIP_CALL_QUOTED, SP0, tok(SkipTokKind.RParen)), v => {
|
|
160
|
-
const lit = v[6];
|
|
161
|
-
return {
|
|
162
|
-
kind: 'call',
|
|
163
|
-
method: 'all',
|
|
164
|
-
selector: lit.text.slice(1, lit.text.length - 1),
|
|
165
|
-
original: v[0] + v[1].text + v[2].text + v[3] + v[4].text + v[5] + v[6].text + v[7] + v[8].text,
|
|
166
|
-
};
|
|
167
|
-
}),
|
|
168
|
-
SEGMENT: m => alt_sc(m.CALL_ALL, m.CALL_ONE, m.RAW),
|
|
169
|
-
});
|
|
170
|
-
const PROGRAM = rep_sc(SegmentModule.SEGMENT);
|
|
171
|
-
// Helper functions template
|
|
172
|
-
export const helperFunctions = `
|
|
173
|
-
;(() => {
|
|
174
|
-
if (window.__pwHelpers)
|
|
175
|
-
return;
|
|
176
|
-
window.__pwHelpers = (() => {
|
|
177
|
-
const isVisible = (el) => {
|
|
178
|
-
const s = window.getComputedStyle(el);
|
|
179
|
-
return s.display !== 'none' && s.visibility !== 'hidden' && el.offsetParent !== null;
|
|
180
|
-
};
|
|
181
|
-
// string or /regex/flags -> predicate
|
|
182
|
-
const toMatcher = (needle) => {
|
|
183
|
-
if (typeof needle === 'string' && /^\\/(?:[^\\\\/]|\\\\.)+\\/[a-z]*$/i.test(needle)) {
|
|
184
|
-
const m = needle.match(/^\\/(.*)\\/([a-z]*)$/i);
|
|
185
|
-
try { const re = new RegExp(m[1], m[2]); return (s) => typeof s === 'string' && re.test(s); }
|
|
186
|
-
catch { /* fall through */ }
|
|
187
|
-
}
|
|
188
|
-
return (s) => typeof s === 'string' && s.includes(needle);
|
|
189
|
-
};
|
|
190
|
-
const hasText = (el, text) => {
|
|
191
|
-
const match = toMatcher(text);
|
|
192
|
-
return el.textContent && match(el.textContent);
|
|
193
|
-
};
|
|
194
|
-
const processSelector = (sel) => sel.replace(/>>/g, ' ');
|
|
195
|
-
const normalizeScoped = (sel) => {
|
|
196
|
-
const trimmed = sel.trim();
|
|
197
|
-
if (trimmed.startsWith('>') || trimmed.startsWith('+') || trimmed.startsWith('~'))
|
|
198
|
-
return ':scope ' + trimmed;
|
|
199
|
-
if (!/^:scope\\b/.test(trimmed))
|
|
200
|
-
return ':scope ' + trimmed;
|
|
201
|
-
return trimmed;
|
|
202
|
-
};
|
|
203
|
-
// multi-pseudo support for inner selectors (used by :has())
|
|
204
|
-
const matchSelectorWithPseudos = (root, sel) => {
|
|
205
|
-
// peel off known pseudos and turn them into filters
|
|
206
|
-
const filters = [];
|
|
207
|
-
// collect text/contains/has-text
|
|
208
|
-
sel = sel.replace(/:(?:contains|has-text|text)\\((\\"(?:[^"\\\\]|\\\\.)*\\"|'(?:[^'\\\\]|\\\\.)*'|\\\`(?:[^\\\`\\\\]|\\\\.)*\\\`|\\/(?:[^\\\\/]|\\\\.)+\\/[a-z]*)\\)/gi, (m, arg) => {
|
|
209
|
-
// strip quotes if present; regex stays as /.../flags
|
|
210
|
-
let v = arg;
|
|
211
|
-
const qc = v[0];
|
|
212
|
-
if (qc === '"' || qc === "'" || qc === '\`') v = v.slice(1, -1);
|
|
213
|
-
const pred = (el) => hasText(el, v);
|
|
214
|
-
filters.push(pred);
|
|
215
|
-
return '';
|
|
216
|
-
});
|
|
217
|
-
// collect :visible
|
|
218
|
-
sel = sel.replace(/:visible\\b/gi, () => {
|
|
219
|
-
filters.push((el) => isVisible(el));
|
|
220
|
-
return '';
|
|
221
|
-
});
|
|
222
|
-
const without = (sel.trim() || '*');
|
|
223
|
-
const scoped = normalizeScoped(processSelector(without));
|
|
224
|
-
const nodes = Array.from(root.querySelectorAll(scoped));
|
|
225
|
-
if (!filters.length) return nodes;
|
|
226
|
-
return nodes.filter(n => filters.every(f => f(n)));
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
return {
|
|
230
|
-
hasText: (selector, text) => Array.from(document.querySelectorAll(selector)).find(el => hasText(el, text)),
|
|
231
|
-
hasTextAll: (selector, text) => Array.from(document.querySelectorAll(selector)).filter(el => hasText(el, text)),
|
|
232
|
-
visible: (selector) => Array.from(document.querySelectorAll(selector)).find(el => isVisible(el)),
|
|
233
|
-
visibleAll: (selector) => Array.from(document.querySelectorAll(selector)).filter(el => isVisible(el)),
|
|
234
|
-
has: (baseSelector, innerSelector) => Array.from(document.querySelectorAll(baseSelector)).find(el => matchSelectorWithPseudos(el, innerSelector).length > 0),
|
|
235
|
-
hasAll: (baseSelector, innerSelector) => Array.from(document.querySelectorAll(baseSelector)).filter(el => matchSelectorWithPseudos(el, innerSelector).length > 0),
|
|
236
|
-
text: (text) => {
|
|
237
|
-
const match = toMatcher(text); // regex-aware
|
|
238
|
-
const walker = document.createTreeWalker(
|
|
239
|
-
document.body,
|
|
240
|
-
NodeFilter.SHOW_TEXT,
|
|
241
|
-
null
|
|
242
|
-
);
|
|
243
|
-
let node;
|
|
244
|
-
while (node = walker.nextNode()) {
|
|
245
|
-
if (node.textContent && match(node.textContent)) {
|
|
246
|
-
return node.parentElement;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
return null;
|
|
250
|
-
},
|
|
251
|
-
// plural version (used by querySelectorAll('text=...'))
|
|
252
|
-
textAll: (text) => {
|
|
253
|
-
const match = toMatcher(text);
|
|
254
|
-
const out = [];
|
|
255
|
-
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
|
|
256
|
-
let node;
|
|
257
|
-
while (node = walker.nextNode()) {
|
|
258
|
-
if (node.textContent && match(node.textContent) && node.parentElement) {
|
|
259
|
-
out.push(node.parentElement);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
return out;
|
|
263
|
-
},
|
|
264
|
-
// nth-match helpers
|
|
265
|
-
nthMatch: (baseSelector, innerSelector, indexStr) => {
|
|
266
|
-
const idx = Math.max(1, parseInt(indexStr, 10) || 1) - 1;
|
|
267
|
-
const bases = (baseSelector && baseSelector.trim())
|
|
268
|
-
? Array.from(document.querySelectorAll(baseSelector))
|
|
269
|
-
: [document];
|
|
270
|
-
const all = [];
|
|
271
|
-
for (const b of bases) {
|
|
272
|
-
const nodes = matchSelectorWithPseudos(b, innerSelector);
|
|
273
|
-
for (const n of nodes) all.push(n);
|
|
274
|
-
}
|
|
275
|
-
return all[idx] || null;
|
|
276
|
-
},
|
|
277
|
-
nthMatchAll: (baseSelector, innerSelector, indexStr) => {
|
|
278
|
-
const el = window.__pwHelpers.nthMatch(baseSelector, innerSelector, indexStr);
|
|
279
|
-
return el ? [el] : [];
|
|
280
|
-
},
|
|
281
|
-
querySelector: (selector) => document.querySelector(processSelector(selector)),
|
|
282
|
-
querySelectorAll: (selector) => document.querySelectorAll(processSelector(selector))
|
|
283
|
-
};
|
|
284
|
-
})();
|
|
285
|
-
})();
|
|
286
|
-
`;
|
|
287
|
-
// ----- Transformers -----
|
|
288
|
-
export const transformHasText = ({ script, needsHelpers }) => {
|
|
289
|
-
if (!script.includes(':has-text('))
|
|
290
|
-
return { script, needsHelpers };
|
|
291
|
-
const result = transformSelectorCalls(script, (selector, method) => {
|
|
292
|
-
const parsed = parseFirstPseudo(selector);
|
|
293
|
-
if (parsed.kind !== 'hasText')
|
|
294
|
-
return null;
|
|
295
|
-
// if base is empty, behave like :text() — search globally
|
|
296
|
-
if (isBlank(parsed.base)) {
|
|
297
|
-
return method === 'all'
|
|
298
|
-
? createHelperCall('hasTextAll', '*', parsed.text)
|
|
299
|
-
: createHelperCall('text', parsed.text);
|
|
300
|
-
}
|
|
301
|
-
return method === 'all'
|
|
302
|
-
? createHelperCall('hasTextAll', parsed.base, parsed.text)
|
|
303
|
-
: createHelperCall('hasText', parsed.base, parsed.text);
|
|
304
|
-
});
|
|
305
|
-
return {
|
|
306
|
-
script: result.output,
|
|
307
|
-
needsHelpers: needsHelpers || result.changed,
|
|
308
|
-
};
|
|
309
|
-
};
|
|
310
|
-
export const transformVisible = ({ script, needsHelpers }) => {
|
|
311
|
-
if (!script.includes(':visible'))
|
|
312
|
-
return { script, needsHelpers };
|
|
313
|
-
const result = transformSelectorCalls(script, (selector, method) => {
|
|
314
|
-
const parsed = parseFirstPseudo(selector);
|
|
315
|
-
if (parsed.kind !== 'visible')
|
|
316
|
-
return null;
|
|
317
|
-
return method === 'all'
|
|
318
|
-
? createHelperCall('visibleAll', parsed.base)
|
|
319
|
-
: createHelperCall('visible', parsed.base);
|
|
320
|
-
});
|
|
321
|
-
return {
|
|
322
|
-
script: result.output,
|
|
323
|
-
needsHelpers: needsHelpers || result.changed,
|
|
324
|
-
};
|
|
325
|
-
};
|
|
326
|
-
export const transformText = ({ script, needsHelpers }) => {
|
|
327
|
-
if (!script.includes(':text('))
|
|
328
|
-
return { script, needsHelpers };
|
|
329
|
-
const result = transformSelectorCalls(script, (selector, method) => {
|
|
330
|
-
const parsed = parseFirstPseudo(selector);
|
|
331
|
-
if (parsed.kind !== 'text')
|
|
332
|
-
return null;
|
|
333
|
-
if (isBlank(parsed.base)) {
|
|
334
|
-
return method === 'all'
|
|
335
|
-
? createHelperCall('hasTextAll', '*', parsed.text)
|
|
336
|
-
: createHelperCall('text', parsed.text);
|
|
337
|
-
}
|
|
338
|
-
return method === 'all'
|
|
339
|
-
? createHelperCall('hasTextAll', parsed.base, parsed.text)
|
|
340
|
-
: createHelperCall('hasText', parsed.base, parsed.text);
|
|
341
|
-
});
|
|
342
|
-
return {
|
|
343
|
-
script: result.output,
|
|
344
|
-
needsHelpers: needsHelpers || result.changed,
|
|
345
|
-
};
|
|
346
|
-
};
|
|
347
|
-
// Support playwright "text=..." engine (and "X >> text=...")
|
|
348
|
-
export const transformTextEngine = ({ script, needsHelpers }) => {
|
|
349
|
-
const result = transformSelectorCalls(script, (selector, method) => {
|
|
350
|
-
const raw = selector.trim();
|
|
351
|
-
// Case 1: entire selector is text=...
|
|
352
|
-
const m1 = raw.match(/^\s*text\s*=\s*(.+)\s*$/);
|
|
353
|
-
if (m1) {
|
|
354
|
-
const arg = m1[1].trim();
|
|
355
|
-
const val = unwrapPossibleQuotesOrKeepRegex(arg);
|
|
356
|
-
return method === 'all'
|
|
357
|
-
? createHelperCall('textAll', val)
|
|
358
|
-
: createHelperCall('text', val);
|
|
359
|
-
}
|
|
360
|
-
// Case 2: X >> text=...
|
|
361
|
-
const m2 = raw.match(/^(.*?)>>\s*text\s*=\s*(.+)\s*$/);
|
|
362
|
-
if (m2) {
|
|
363
|
-
const base = m2[1].trim() || '*';
|
|
364
|
-
const arg = m2[2].trim();
|
|
365
|
-
const val = unwrapPossibleQuotesOrKeepRegex(arg);
|
|
366
|
-
return method === 'all'
|
|
367
|
-
? createHelperCall('hasTextAll', base, val)
|
|
368
|
-
: createHelperCall('hasText', base, val);
|
|
369
|
-
}
|
|
370
|
-
return null;
|
|
371
|
-
});
|
|
372
|
-
return { script: result.output, needsHelpers: needsHelpers || result.changed };
|
|
373
|
-
};
|
|
374
|
-
// Utility used only by transformTextEngine
|
|
375
|
-
const unwrapPossibleQuotesOrKeepRegex = (s) => {
|
|
376
|
-
const q = s[0];
|
|
377
|
-
if ((q === '"' || q === "'" || q === '`') && s.length >= 2 && s[s.length - 1] === q)
|
|
378
|
-
return s.slice(1, -1);
|
|
379
|
-
// keep /.../flags as-is
|
|
380
|
-
return s;
|
|
381
|
-
};
|
|
382
|
-
export const transformContains = ({ script, needsHelpers }) => {
|
|
383
|
-
if (!script.includes(':contains('))
|
|
384
|
-
return { script, needsHelpers };
|
|
385
|
-
const result = transformSelectorCalls(script, (selector, method) => {
|
|
386
|
-
const parsed = parseFirstPseudo(selector);
|
|
387
|
-
if (parsed.kind !== 'contains')
|
|
388
|
-
return null;
|
|
389
|
-
return method === 'all'
|
|
390
|
-
? createHelperCall('hasTextAll', parsed.base, parsed.text)
|
|
391
|
-
: createHelperCall('hasText', parsed.base, parsed.text);
|
|
392
|
-
});
|
|
393
|
-
return {
|
|
394
|
-
script: result.output,
|
|
395
|
-
needsHelpers: needsHelpers || result.changed,
|
|
396
|
-
};
|
|
397
|
-
};
|
|
398
|
-
export const transformHas = ({ script, needsHelpers }) => {
|
|
399
|
-
if (!script.includes(':has('))
|
|
400
|
-
return { script, needsHelpers };
|
|
401
|
-
const result = transformSelectorCalls(script, (selector, method) => {
|
|
402
|
-
const parsed = parseFirstPseudo(selector);
|
|
403
|
-
if (parsed.kind !== 'has')
|
|
404
|
-
return null;
|
|
405
|
-
return method === 'all'
|
|
406
|
-
? createHelperCall('hasAll', parsed.base, parsed.inner)
|
|
407
|
-
: createHelperCall('has', parsed.base, parsed.inner);
|
|
408
|
-
});
|
|
409
|
-
return {
|
|
410
|
-
script: result.output,
|
|
411
|
-
needsHelpers: needsHelpers || result.changed,
|
|
412
|
-
};
|
|
413
|
-
};
|
|
414
|
-
// :nth-match(...)
|
|
415
|
-
export const transformNthMatch = ({ script, needsHelpers }) => {
|
|
416
|
-
if (!script.includes(':nth-match('))
|
|
417
|
-
return { script, needsHelpers };
|
|
418
|
-
const result = transformSelectorCalls(script, (selector, method) => {
|
|
419
|
-
const parsed = parseFirstPseudo(selector);
|
|
420
|
-
if (parsed.kind !== 'nthMatch')
|
|
421
|
-
return null;
|
|
422
|
-
// If base is empty, treat as global
|
|
423
|
-
const base = (parsed.base || '').trim();
|
|
424
|
-
if (method === 'all')
|
|
425
|
-
return createHelperCall('nthMatchAll', base, parsed.inner, String(parsed.index));
|
|
426
|
-
return createHelperCall('nthMatch', base, parsed.inner, String(parsed.index));
|
|
427
|
-
});
|
|
428
|
-
return {
|
|
429
|
-
script: result.output,
|
|
430
|
-
needsHelpers: needsHelpers || result.changed,
|
|
431
|
-
};
|
|
432
|
-
};
|
|
433
|
-
export const transformDescendant = ({ script, needsHelpers }) => {
|
|
434
|
-
if (!script.includes('>>'))
|
|
435
|
-
return { script, needsHelpers };
|
|
436
|
-
const result = transformSelectorCalls(script, (selector, method) => {
|
|
437
|
-
if (!selector.includes('>>'))
|
|
438
|
-
return null;
|
|
439
|
-
return method === 'all'
|
|
440
|
-
? createHelperCall('querySelectorAll', selector)
|
|
441
|
-
: createHelperCall('querySelector', selector);
|
|
442
|
-
});
|
|
443
|
-
return {
|
|
444
|
-
script: result.output,
|
|
445
|
-
needsHelpers: needsHelpers || result.changed,
|
|
446
|
-
};
|
|
447
|
-
};
|
|
448
|
-
// Inject helpers if needed
|
|
449
|
-
export const injectHelpers = ({ script, needsHelpers }) => needsHelpers
|
|
450
|
-
? {
|
|
451
|
-
script: helperFunctions +
|
|
452
|
-
'\n/* Playwright selectors automatically transformed */\n' +
|
|
453
|
-
script,
|
|
454
|
-
needsHelpers,
|
|
455
|
-
}
|
|
456
|
-
: { script, needsHelpers };
|
|
457
|
-
// Main transformation pipeline
|
|
458
|
-
export const transformPlaywrightSyntax = (script) => pipe({ script, needsHelpers: false }, transformHas, transformHasText, transformVisible, transformText, transformContains, transformNthMatch, transformTextEngine, transformDescendant, injectHelpers);
|
|
459
|
-
// Memoized version for performance
|
|
460
|
-
export const memoizedTransform = memoize(transformPlaywrightSyntax);
|
|
461
|
-
// Public API
|
|
462
|
-
export const transformScript = (originalScript) => {
|
|
463
|
-
const result = memoizedTransform(originalScript);
|
|
464
|
-
return result.script;
|
|
465
|
-
};
|
|
466
|
-
// Testing utilities
|
|
467
|
-
export const testTransformation = (input, expected) => {
|
|
468
|
-
const { script } = transformPlaywrightSyntax(input);
|
|
469
|
-
return script === expected;
|
|
470
|
-
};
|
|
471
|
-
const transformSelectorCalls = (code, rewriter) => {
|
|
472
|
-
const start = skipLexer.parse(code);
|
|
473
|
-
if (!start)
|
|
474
|
-
return { output: code, changed: false };
|
|
475
|
-
const parsed = PROGRAM.parse(start);
|
|
476
|
-
if (!parsed.successful || parsed.candidates.length === 0)
|
|
477
|
-
return { output: code, changed: false };
|
|
478
|
-
const segments = parsed.candidates[0].result;
|
|
479
|
-
let changed = false;
|
|
480
|
-
const out = [];
|
|
481
|
-
for (const seg of segments) {
|
|
482
|
-
if (seg.kind === 'call') {
|
|
483
|
-
const replacement = rewriter(seg.selector, seg.method);
|
|
484
|
-
if (replacement) {
|
|
485
|
-
out.push(replacement);
|
|
486
|
-
changed = true;
|
|
487
|
-
}
|
|
488
|
-
else {
|
|
489
|
-
out.push(seg.original);
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
else {
|
|
493
|
-
out.push(seg.text);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
return { output: out.join(''), changed };
|
|
497
|
-
};
|