@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.
Files changed (89) hide show
  1. package/cli-wrapper.js +15 -14
  2. package/cli.js +1 -1
  3. package/config.d.ts +11 -6
  4. package/index.d.ts +7 -5
  5. package/index.js +1 -1
  6. package/package.json +34 -57
  7. package/LICENSE +0 -202
  8. package/lib/browserContextFactory.js +0 -326
  9. package/lib/browserServerBackend.js +0 -84
  10. package/lib/config.js +0 -286
  11. package/lib/context.js +0 -309
  12. package/lib/extension/cdpRelay.js +0 -346
  13. package/lib/extension/extensionContextFactory.js +0 -56
  14. package/lib/frameworkPatterns.js +0 -35
  15. package/lib/hooks/antiBotDetectionHook.js +0 -171
  16. package/lib/hooks/core.js +0 -144
  17. package/lib/hooks/eventConsumer.js +0 -52
  18. package/lib/hooks/events.js +0 -42
  19. package/lib/hooks/formatToolCallEvent.js +0 -16
  20. package/lib/hooks/frameworkStateHook.js +0 -182
  21. package/lib/hooks/grouping.js +0 -72
  22. package/lib/hooks/jsonLdDetectionHook.js +0 -175
  23. package/lib/hooks/networkFilters.js +0 -82
  24. package/lib/hooks/networkSetup.js +0 -59
  25. package/lib/hooks/networkTrackingHook.js +0 -67
  26. package/lib/hooks/pageHeightHook.js +0 -75
  27. package/lib/hooks/registry.js +0 -42
  28. package/lib/hooks/requireTabHook.js +0 -26
  29. package/lib/hooks/schema.js +0 -89
  30. package/lib/hooks/waitHook.js +0 -33
  31. package/lib/index.js +0 -39
  32. package/lib/mcp/inProcessTransport.js +0 -72
  33. package/lib/mcp/proxyBackend.js +0 -115
  34. package/lib/mcp/server.js +0 -86
  35. package/lib/mcp/tool.js +0 -38
  36. package/lib/mcp/transport.js +0 -181
  37. package/lib/playwrightTransformer.js +0 -497
  38. package/lib/program.js +0 -110
  39. package/lib/response.js +0 -186
  40. package/lib/sessionLog.js +0 -121
  41. package/lib/tab.js +0 -249
  42. package/lib/tools/common.js +0 -55
  43. package/lib/tools/console.js +0 -33
  44. package/lib/tools/dialogs.js +0 -47
  45. package/lib/tools/evaluate.js +0 -53
  46. package/lib/tools/extractFrameworkState.js +0 -214
  47. package/lib/tools/files.js +0 -45
  48. package/lib/tools/form.js +0 -57
  49. package/lib/tools/getSnapshot.js +0 -37
  50. package/lib/tools/getVisibleHtml.js +0 -52
  51. package/lib/tools/install.js +0 -51
  52. package/lib/tools/keyboard.js +0 -78
  53. package/lib/tools/mouse.js +0 -99
  54. package/lib/tools/navigate.js +0 -70
  55. package/lib/tools/network.js +0 -123
  56. package/lib/tools/networkDetail.js +0 -229
  57. package/lib/tools/networkSearch/bodySearch.js +0 -147
  58. package/lib/tools/networkSearch/grouping.js +0 -28
  59. package/lib/tools/networkSearch/helpers.js +0 -32
  60. package/lib/tools/networkSearch/searchHtml.js +0 -67
  61. package/lib/tools/networkSearch/types.js +0 -1
  62. package/lib/tools/networkSearch/urlSearch.js +0 -82
  63. package/lib/tools/networkSearch.js +0 -268
  64. package/lib/tools/pdf.js +0 -40
  65. package/lib/tools/repl.js +0 -402
  66. package/lib/tools/screenshot.js +0 -79
  67. package/lib/tools/scroll.js +0 -126
  68. package/lib/tools/snapshot.js +0 -144
  69. package/lib/tools/tabs.js +0 -59
  70. package/lib/tools/tool.js +0 -33
  71. package/lib/tools/utils.js +0 -74
  72. package/lib/tools/wait.js +0 -55
  73. package/lib/tools.js +0 -67
  74. package/lib/utils/adBlockFilter.js +0 -87
  75. package/lib/utils/codegen.js +0 -51
  76. package/lib/utils/extensionPath.js +0 -10
  77. package/lib/utils/fileUtils.js +0 -36
  78. package/lib/utils/graphql.js +0 -258
  79. package/lib/utils/guid.js +0 -22
  80. package/lib/utils/httpServer.js +0 -39
  81. package/lib/utils/log.js +0 -21
  82. package/lib/utils/manualPromise.js +0 -111
  83. package/lib/utils/networkFormat.js +0 -12
  84. package/lib/utils/package.js +0 -20
  85. package/lib/utils/result.js +0 -2
  86. package/lib/utils/sanitizeHtml.js +0 -98
  87. package/lib/utils/truncate.js +0 -103
  88. package/lib/utils/withTimeout.js +0 -7
  89. package/src/index.ts +0 -50
@@ -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
- };